| name | oracle-parser |
| description | Use when doing any parser work — adding new Oracle text patterns, verb forms, phrase helpers, target patterns, subject handling, effect chain composition, fixing Unimplemented fallbacks, or understanding the parser architecture. This is the SINGLE SOURCE OF TRUTH for all oracle parser knowledge. Covers the nom combinator mandate, parsing priority system, AST type system, all helper modules, and contribution checklists. |
Oracle Parser — Single Source of Truth
The Oracle parser converts MTGJSON Oracle text into typed AbilityDefinition structs that the engine executes. This skill is the authoritative reference for all parser work.
CR Verification Rule: Every CR number you write MUST be verified by grepping docs/MagicCompRules.txt BEFORE adding it to code. See Section 8.
1. Non-Negotiable Rules
These rules are defined in CLAUDE.md and are enforced without exception. Violations will be caught in review and must be fixed before merge.
⚠ RULE ZERO: Nom Combinators Are Mandatory — No Exceptions
All new parser code MUST use nom combinators from the very first line written. This is the single most important rule in the parser codebase. It has been violated repeatedly and is now enforced as a hard gate.
NEVER write any of these for parsing dispatch:
find(), split_once(), contains(), starts_with() — for dispatch routing
if lower.starts_with("destroy ") — use tag("destroy ").parse(lower) instead
if lower.contains("target") — use scan_at_word_boundaries or a nom combinator instead
text.find(' ').map(|i| &text[..i]) — use nom take_till or take_while
ALWAYS write:
tag("destroy ").parse(lower)? — for known prefix dispatch
alt((tag("destroy "), tag("exile "))).parse(lower)? — for multi-option dispatch
nom_on_lower(text, lower, parser_fn) — for mixed-case text bridging to nom
nom_on_lower_required(text, lower, parser_fn)? — same with ? propagation
nom_parse_lower(lower, parser_fn) — when remainder is unused
scan_at_word_boundaries(text, combinator) — for multi-position phrase matching
Nom combinator reference: See nom_combinators.md in this skill directory for the complete list of nom parsers and combinators organized by module. Consult this when choosing which combinator to use.
Copy-paste patterns + the enforcement gate: When you need to translate a string-method idiom into combinators, open crates/engine/src/parser/oracle_nom/PATTERNS.md — it indexes every common shape (strip prefix, strip suffix, optional trailing clause, alternatives, word-boundary scan, delimiter split, contains-check, peek-without-consume) with copy-pasteable code. The pre-commit hook scripts/check-parser-combinators.sh actively rejects new lines containing .strip_prefix(...), .strip_suffix(...), .contains("..."), .starts_with("..."), .ends_with("..."), .split_once(...), .find("..."), or .trim_end_matches("...") against string literals inside crates/engine/src/parser/. If a use is genuinely structural (post-tokenization punctuation cleanup, TextPair dual-string stripping, runtime char scans) annotate the line // allow-noncombinator: <one-line reason> per PATTERNS.md §9. Existing offenders are grandfathered; new code is gated.
The only acceptable uses of starts_with/strip_prefix in parser code:
TextPair::strip_prefix for dual-string case-bridging operations (this is structural, not dispatch)
- Runtime array loops or char-level scanners
- Dynamic (non-literal) prefixes that can't be known at compile time
If you catch yourself writing string matching for parsing, STOP and rewrite with combinators before proceeding. There is no "convert later" — write it correctly the first time. This rule exists because every past violation required a review round-trip to fix.
Example — the wrong way vs. the right way:
fn try_parse_destroy(lower: &str) -> Option<Effect> {
if lower.starts_with("destroy ") {
let rest = &lower["destroy ".len()..];
}
None
}
fn try_parse_destroy(lower: &str) -> Option<Effect> {
let (rest, _) = tag("destroy ").parse(lower).ok()?;
}
See: oracle_casting.rs for verb dispatch via tag().parse(), oracle_trigger.rs for alt() dispatch.
Other Non-Negotiable Rules
Each rule below is defined in CLAUDE.md. One-sentence principle + codebase example.
| Rule | Example Location |
|---|
| Never match verbatim Oracle text — decompose every phrase into typed building blocks (grammar + helpers + enums). A verbatim string match handles exactly one card and poisons the architecture. | Contrast: typed QuantityRef/Comparator vs. literal string |
Compose combinators by dimension — N independent axes = a sum of per-axis alt()/opt() calls inside one sequence, never a flat alt of full-string tags (the product). Variation in the middle or an optional segment still factors: recognize((tag(..), alt(..), tag(..), opt(..), tag(..))). Smell: a flat alt whose arms share a long common prefix and suffix. | oracle_nom/condition.rs multi-axis composition; PATTERNS.md §8b |
Nest by prefix dispatch — shared prefixes use preceded(tag(...), sub_combinator) to eliminate redundant matching. | oracle_trigger.rs phase trigger nesting |
Word-boundary scanning — try a combinator at each word boundary via scanning loop, not contains() chains. | oracle_casting.rs::scan_timing_restrictions, oracle_trigger.rs::scan_for_phase |
parse_inner_condition is the single authority for all game-state conditions. Trigger/static parsers MUST delegate to it. | oracle_nom/condition.rs::parse_inner_condition |
No boolean flags — parameterize with typed enums (ControllerRef, Comparator, Option<T>). | types/ability.rs effect variants |
No raw i32 for amounts — use QuantityExpr on all new effects. | QuantityExpr::Fixed vs QuantityExpr::Ref |
Separate abstraction layers — QuantityRef contains only dynamic references. Constants belong in QuantityExpr::Fixed. | QuantityExpr wrapping QuantityRef |
parse_number vs parse_number_or_x — use _or_x when X resolves to 0 (costs, P/T, counters). Use parse_number when X should remain as Variable("X") (effect quantities). | oracle_nom/primitives.rs |
All imports at file top — never inline use nom::* inside function bodies. | Project-wide convention |
| CR annotations mandatory — with grep verification. See Section 8. | docs/MagicCompRules.txt |
Self-Review Checklist
Ask these four questions after every parser change:
- Did I duplicate logic that an existing helper already handles?
- Is this inline extraction something that should use a shared building block?
- Would this logic work for 50 cards, or just the one I'm looking at?
- Did I extend the general pattern, or write a special case?
If any answer is wrong, stop and refactor before moving on.
2. Architecture Overview
Parse Pipeline — Document-Level Two-Phase (parse → IR → lower)
parse_oracle_text() (oracle.rs, near the bottom of the file) is the public
entry point and a thin wrapper over two phases: parse_oracle_ir() (IR
production) followed by lower_oracle_ir() (IR lowering). Diagnostics flow
through OracleDocIr.diagnostics → ParsedAbilities.parse_warnings.
The document IR is source-addressed: every item carries a stable
OracleItemId and an OracleUnitSource (byte + line span), and items are
emitted in Oracle source order — not category order. This is what makes the
CR 707.9a printed-ability slot ("except it has this ability") bind to the right
ability, and it is why preprocessor-emitted items (Saga chapters, Spacecraft
thresholds) no longer jump ahead of main-loop items.
Oracle text (from MTGJSON)
↓
parse_oracle_ir() — oracle.rs: the priority router (see §3)
├─ normalize_card_name_refs() — card name / "this creature" → ~ (once, at entry)
├─ DocEmitter — oracle.rs: the SINGLE source-order emission
│ authority. Wraps OracleDocBuilder; owns the
│ one per-line ordinal allocator. Every
│ emission — preprocessors AND the dispatch
│ loop — routes through it.
├─ pre-parsers (emit at their printed source line): Saga chapters
│ [oracle_saga.rs], Attraction visit lines [oracle_attraction.rs],
│ Class levels [oracle_class.rs], Leveler LEVEL blocks [oracle_level.rs],
│ Spacecraft "N+ |" thresholds [oracle_spacecraft.rs], Strive cost
├─ per line: strip_reminder_text(), then classify by priority slot (§3)
↓
OracleDocIr — oracle_ir/doc.rs:
│ .items Vec<OracleItemIr { id: OracleItemId,
│ source: OracleUnitSource,
│ node: OracleNodeIr }> — SOURCE ORDER
│ .relations Vec<DocumentRelationIr> — closed, cross-item producer→
│ consumer facts keyed by exact OracleItemId (never inferred
│ by scanning lowered shapes)
│ .diagnostics Vec<OracleDiagnostic>
↓
lower_oracle_ir() — oracle.rs: exhaustive match on each OracleNodeIr,
↓ iterating source-ordered items; applies relations
↓ BY ID. Folds into the grouped runtime type:
ParsedAbilities — abilities / triggers / statics / replacements /
keywords / casting options + parse_warnings
OracleSourceSpan precision (doc.rs) is typed, and honest about what it knows:
SpanPrecision | Meaning |
|---|
Exact | Card-absolute byte + line range. Every document item carries this. |
ChainRelative | Byte range is exact within one effect chain, not card-absolute (the document allocator is not yet threaded through ParseContext). The verbatim fragment is retained so a later unit can upgrade it. Minted by ClauseIrBuilder for per-clause provenance. U5 debt — the last non-card-absolute tier. |
A renderer must consult is_exact() before printing a first_line/start_byte as a card
position: a ChainRelative offset is truthful, but only relative to its chain. carries_fragment()
is the single authority for whether a tier may report a verbatim fragment, and
check_fragment_precision() enforces the coupling both ways (fail-closed) — a tier that cannot
locate a unit must not hand a renderer the whole card as "the offending clause". The retired
WholeDocument tier was that case; there is no longer any span that does not locate its unit.
The builder is the ordering authority, not the call order. OracleDocBuilder keys items by
(first_line, ordinal_within_span) and re-sorts, so emission order is irrelevant — a
preprocessor may emit a later line before the dispatch loop reaches an earlier one and the
document still comes out in source order (builder_returns_items_in_source_order_regardless_of_emission_order).
To perturb item order you must perturb the span, never the sequence of emit calls. Any
parity probe that reorders calls instead of spans will come back falsely green.
Cross-item relations — DocumentRelationIr (closed enum, doc.rs). Each variant names one
exact producer OracleItemId and one exact consumer OracleItemId, is bound at parse time, and
fails closed on ambiguity rather than pairing by adjacency or by scanning lowered shapes:
EtbExileLtbReturn — CR 607.1 linked exile/return pairs (Oblivion Ring, Fiend Hunter).
ActivePlayerPunisher — CR 102.1 + CR 608.2c coerce/punisher rebinding (Siren's Call).
LinkedChoice — CR 607.2d "choose a [value]" → "the chosen [value]". One parameterized
variant, not three siblings: the producer is always the choose clause; the variants differ
only in ChosenValueKind (what is chosen) and LinkedChoiceBinding (which consumer surface
reads it back). A fourth linked-choice shape must be a new enum value, never a new relation
variant.
Per-line classification inside parse_oracle_ir (simplified; §3 has the full slot table):
├─ Keywords-only → keyword extraction
├─ "When/Whenever/At" → parse_trigger_line() [oracle_trigger.rs]
├─ Contains ":" → activated ability parsing [oracle_cost.rs + oracle_effect/]
├─ is_static_pattern() → parse_static_line() [oracle_classifier.rs → oracle_static/]
├─ is_replacement_pattern() → parse_replacement_line() [oracle_classifier.rs → oracle_replacement.rs]
├─ Imperative verb → parse_effect_chain() [oracle_effect/]
├─ dispatch_line_nom() → parse_effect_chain_with_context() [oracle_dispatch.rs → oracle_effect/]
└─ Fallback → Effect::Unimplemented
IR Layer — oracle_ir/
| Sub-module | Purpose |
|---|
ast.rs | All parser AST types (ParsedEffectClause, ClauseAst, modal/loyalty AST — moved here from oracle_effect/types.rs, oracle_modal.rs, oracle.rs) |
doc.rs | Document-level IR: OracleDocIr, OracleItemIr, OracleNodeIr, OracleItemId, OracleUnitSource, OracleSourceSpan/SpanPrecision, DocumentRelationIr, OracleDocBuilder, PrintedTriggerIndex |
context.rs | ParseContext for stateful parsing (subject, actor, card_name, host_self_reference, …) |
diagnostic.rs | OracleDiagnostic — structured parse warnings |
effect_chain.rs | EffectChainIr, ClauseIr + typed clause provenance (ClauseId, ClauseDisposition, antecedents, reference uses) and the mandatory ClauseIrBuilder |
trigger.rs | TriggerIr + lowering |
static_ir.rs | StaticIr + lowering |
replacement.rs | ReplacementIr + lowering |
Clause Provenance — ClauseIr (oracle_ir/effect_chain.rs)
A clause does not just carry what it does; it carries where it came from and how it relates
to the clause before it. This is what replaced the old post-lowering tree-scan repairs.
Every ClauseIr carries a stable ClauseId, an OracleUnitSource, its declared antecedents, its
reference uses, and exactly one ClauseDisposition:
ClauseDisposition | Meaning (CR 608.2c: later text may modify earlier text) |
|---|
Emit | Ordinary clause — emit it. |
Continue { antecedent, continuation } | Continues a named earlier instruction (search → destination, reveal → rider). |
BranchOtherwise { antecedent } | The else branch of a named earlier instruction. |
ReplaceMeaning { antecedent } | Re-interprets a named earlier instruction. |
Absorb { antecedent } | Folded into a named earlier instruction. |
All ClauseIr construction goes through ClauseIrBuilder. There is no Default, and no public
struct literal — missing identity or provenance is a compile error, not a silent None. The gate
is rg 'ClauseIr \{', which may match only the struct declaration and the builder's single internal
construction.
Antecedents are named, never searched. When two compatible producers exist, the parser names
the antecedent by ClauseId/AntecedentId or target slot. The assembler must never choose by
searching the lowered tree for the nearest matching Effect variant. That search was the bug.
Effect-Chain Assembly — oracle_effect/assembly.rs
assemble_effect_chain(&EffectChainIr) -> AbilityDefinition is the single source-order assembly
traversal. It owns an arena of output nodes plus an AssemblyEnv, so an earlier node can be
amended by an explicitly-bound later continuation without recursively walking a partially-built tree.
Parsed IR is immutable during assembly; the assembler owns the mutable output nodes and the typed
environment. Final materialization (arena links → Box<AbilityDefinition>) is mechanical and contains
no semantic pattern matching.
The arena is keyed by node index, not by ClauseId. A ClauseId names a parsed clause, but a
continuation may push an output node that no clause owns (a search destination, a rest-destination
patch), so a ClauseId-only key cannot address every amendable node. Arena { nodes: Vec<ArenaNode> }
therefore addresses nodes by position, and AssemblyEnv carries a typed role registry per
amendable class — conditional_nodes, optional_head_nodes, search_destination_nodes,
dig_or_reveal_until_nodes, destroy_like_nodes, face_down_profile_nodes — each a Vec<usize> in
emission order.
They are lists, not last-only slots, because a guarded selector may have to walk past a
candidate that fails its guard (BranchOtherwise skips an optional head that already has a sub).
A binding is then (AntecedentRole, AntecedentSelector): the role names which class of node is
being bound, the selector names which one (LastEmitted, LastWithRole, …). The walk is over the
typed candidate list — never over the output tree. Scanning the lowered tree for the nearest
matching Effect variant is the bug this whole layer exists to delete.
Roles are deliberately narrow and non-nested. DigOrRevealUntil (the RestDestination
patchable set) is a different set from DigOrMill (the DigFromAmong anchor) even though both
contain Dig. Widening a role to a superset to save a variant re-opens the nearest-match trap for
every card in the difference.
lower_effect_chain_ir (lower.rs) is a one-line delegator to assemble_effect_chain —
a name-preserving wrapper kept so the traversal relocation did not have to touch every call site in
one commit. It is scaffolding, not an authority. Do not add logic to it. The ~30 pub(super)
clause-lowering helpers still live in lower.rs; relocating them into assembly.rs is a later
increment, not a settled design.
Known Milestone-1 debt (do not mistake these for the target architecture)
OracleNodeIr::PreLowered{Spell,Trigger,Static,Replacement} still exist (26 references in
oracle.rs). They carry an already-assembled engine type instead of typed IR, and are produced by
the preprocessors and the complex dispatch paths. Retiring them is item-granular work: the
document already gives every item an OracleItemId and an exact span, so what is missing is the
per-node IR type, not the addressing.
TriggerBody::PreLowered still exists, for the whole-body recognizers
(parse_vote_block, parse_separate_into_piles, try_parse_inline_modal, …). These take an
entire multi-sentence body and refuse to let it be chain-fragmented, so they have no _ir sibling.
Converting them is a genuine bring-up (it changes lowering), which a parity migration may not
absorb — deferred to the recognizer→IR bring-up plan
(.planning/architecture-remediation/05-recognizer-ir-bringup.md).
SpanPrecision::ChainRelative is the last non-card-absolute span tier: ClauseIrBuilder's
allocator is seeded over the chain text because the document allocator is not yet threaded through
ParseContext. Honest, not fabricated — the fragment is retained so a later unit can upgrade it to
Exact. This is now the only tier a position renderer must refuse to print as a card position.
- Undeclared positional bindings remain outside the effect-chain arena. 28
defs.last_mut()-style
sites (sequence.rs 20, lower.rs 4, assembly.rs 3, mod.rs 1) still bind "the previous thing"
by position rather than by declared role, plus a handful of clause-stream .rev() lookbacks in
mod.rs. Each is a latent mis-binding site of the same species the arena was built to kill.
A byte-identity gate cannot validate converting these — see the warning below.
ChainLoweringMode (oracle_effect/mod.rs) encodes a deliberate asymmetry: the standalone
chain entry point runs 8 bypass recognizers, the with-context one runs 9 (it also runs
try_parse_exile_pile_shuffle_cloak). This is preserved byte-for-byte, as typed data with an
exhaustive match. It is suspected-accidental history, but unifying it would silently change lowering
across the die-roll cards, so it is a post-Milestone-1 follow-up gated on finding a discriminating card.
Byte-identity is structurally blind to a narrowed binding. When the positional walk-backs were
converted to declared roles, every call site turned out to see exactly one candidate on today's
pool — so LastEmitted, LastWithRole, and any other nearest-match rule are all output-identical,
and a full-pool byte-for-byte parity run cannot tell a correct binding from a wrongly-collapsed one.
Validating a binding change needs a forced diagonal (drive the selector to a different candidate
and confirm the output moves) or a synthetic discriminating card. Never sign off a binding
migration on byte-identity alone.
Retired in Milestone 1 (do not re-introduce): the category-ordered parsed_abilities_to_doc_ir
Class façade and its SpanPrecision::WholeDocument span tier — Class is now an ordinary preprocessor
emitting at printed source lines; the four post-lowering shape-repair passes removed in U7; and the
lowered-tree scans replaced by the arena's declared-role bindings.
Nom Combinator Layer — oracle_nom/
All parser branches delegate atomic parsing to shared nom 8.0 combinators:
| Sub-module | Purpose |
|---|
primitives.rs | Numbers, mana symbols, colors, counters, P/T, roman numerals, word-boundary guards |
target.rs | Target phrase combinators, controller suffix, combat status |
quantity.rs | Quantity expression combinators, "for each" patterns |
duration.rs | Duration phrase combinators ("until end of turn", etc.) |
condition.rs | Condition phrase combinators ("if", "unless", "as long as") |
filter.rs | Filter property combinators (zone, type, controller, "with") |
error.rs | OracleError / OracleResult type aliases, oracle_err error constructor. Parse-failure authority is Effect::unimplemented(name, fragment) (types/ability.rs) — never hand-construct Effect::Unimplemented { .. } literals |
context.rs | Re-export shim for ParseContext (canonical home: oracle_ir/context.rs) |
bridge.rs | nom_on_lower, nom_on_lower_required, nom_parse_lower — mixed-case bridging |
enchant.rs | "Enchant {filter}" attachment-restriction combinators |
return_as_aura.rs | "return ~ ... attached to" / return-as-Aura combinators |
Two-Phase Parse/Lower Architecture (clause level)
Within the effect branch, the parser uses the same two-phase approach at clause
granularity: parse → AST → lower → Effect.
parse_effect_clause() — entry point (oracle_effect/mod.rs)
→ clause_shell::peel_clause() — strip structural slots first (see below)
→ parse_clause_ast() — classify sentence shape → ClauseAst
→ lower_clause_ast() — convert AST to Effect
→ lower_subject_predicate_ast() — for SubjectPredicate clauses
→ lower_imperative_clause() — for Imperative clauses
→ parse_imperative_effect() — try special cases, then delegate
→ parse_imperative_family_ast() — classify verb family (imperative.rs)
→ lower_imperative_family_ast() — convert to Effect
Clause Shell — Structural Slot Peeling (clause_shell.rs)
This is the destination architecture for structural slots. Phase 2 of the
no-text-swallowing refactor (see data/parser-swallow-progress.md) inverts
slot-consumption responsibility: instead of every body parser recognizing AND
consuming surrounding structural slots inline (and silently dropping them when
it forgets — the swallowing bug class), peel_clause(text) recursively strips
slot-bearing prefixes/suffixes off the clause, accumulating them into a
ClauseContext (synthesized attributes). The bare imperative remainder is
handed to the existing body parsers, and the context is applied back onto the
parsed result (apply_optional, duration(), condition()), so no recognized
slot can be dropped.
Slots live in the shell today:
- Optional —
"you may [verb]" → ClauseContext.optional (CR 608.2d), with
a specialized-phrase blocklist (is_specialized_you_may_phrase) for "you may"
constructions whose dedicated body parsers need the full surface form
(alt-costs, impulse-draw permission, causative "have", retarget, Dig
keep-from-among, specialized reveals).
- Duration — trailing duration suffix →
ClauseContext.duration, delegating
to strip_trailing_duration so the suffix table stays single-source (with an
is_specialized_duration_carrier guard for parsers that need the suffix as a
disambiguation signal, e.g. impulse-draw vs CastFromZone).
- Leading condition —
"if [cond], [effect]" → ClauseContext.condition,
delegating to strip_leading_general_conditional (which routes through
parse_inner_condition).
Call site: parse_effect_clause() in oracle_effect/mod.rs (single peel
point; oracle_trigger.rs and oracle_effect/subject.rs reference the peel
in comments where their behavior depends on it). If the peeled variant lands
in Unimplemented, the original text is retried — the shell is conservative.
Rule for new work: when adding handling for a new structural slot type
(durations, optional clauses, conditions, and future siblings like APNAP order
or activation limits), migrate it into the shell rather than adding another
per-callsite strip_* pass: add a field on ClauseContext, a branch in
peel_inner, an apply_* method, and remove the linear strip_* calls at the
body-parser call sites. Do not add new inline slot consumption to body parsers.
Parser Dispatch Architecture
- Nom combinators handle ALL parsing dispatch — atomic, structural, sentence-level verb dispatch, and top-level routing.
TextPair provides dual-string case-bridging (subject-predicate decomposition, clause AST classification). TextPair::strip_prefix is correct for these structural operations.
oracle_classifier.rs owns reusable line-classification helpers such as trigger-prefix, static-pattern, and replacement-pattern detection. oracle.rs remains the priority router that calls them.
oracle_special.rs owns the router-adjacent special helpers for solve conditions, Defiler two-line statics, die-roll tables, self-reference normalization for static parsing, and keyword-line parsers like Escape/Harmonize/Cumulative Upkeep.
oracle_effect/conditions.rs owns leading-condition splitting and ability-condition helpers. oracle_effect/mod.rs remains the clause/effect orchestrator and re-exports split_leading_conditional.
oracle_effect/search.rs owns search/seek filter parsing helpers. oracle_effect/mod.rs re-exports the stable search helper surface used by imperative and continuation parsing.
- New parser code MUST use nom combinators.
starts_with/strip_prefix for parsing dispatch is NOT acceptable (see Rule Zero).
3. Parsing Priority System
Lines in parse_oracle_ir() (oracle.rs) are classified slot by slot. First
match wins — the binding order is the SOURCE ORDER of the slots in the line
loop, NOT the numeric labels. The labels are historical and non-monotonic
(8b (early) runs before 0; loyalty 11 runs before the 3x keyword-line
slots). When inserting a new slot, place it by evaluation position and grep
// Priority in oracle.rs to see the live order; the table below mirrors that
grep in evaluation order (one row per // Priority <label>: comment — the CI
gate scripts/check-skill-doc.sh asserts the row count matches the code).
Before the line loop, parse_oracle_ir runs pre-parsers that consume whole
line ranges: Saga chapters, Attraction visit lines, Class level sections (early
return), Leveler LEVEL blocks, Spacecraft N+ | thresholds, and the Strive
cost scan. Consumed line indices are skipped by the loop.
Unlabeled handlers interleaved between labeled slots are shown as — rows.
| Label | Pattern | Router | Module |
|---|
14 | Line empty after reminder-text + "X can't be 0." stripping (stamps min_x_value on the previous ability first) | skip | oracle.rs |
8b (early) | "As an additional cost to cast this spell …" (non-Defiler) — must precede static classifiers that match embedded "this spell costs {N} less" tails | parse_additional_cost_line() → result.additional_cost | oracle_casting.rs |
0 | Semicolon-separated keyword line ("Defender; reach"); colon guard excludes activated abilities | per-part keyword extraction | oracle.rs |
1 | Modal block: "Choose one —" header + mode lines, or Spree + + lines (consumes multiple lines) | parse_oracle_block() + lower_oracle_block() | oracle_modal.rs |
| — | "Equip {cost}" / "Equip — {cost}" (not "Equipped …"); "Crew N" with trailing cadence sentence | try_parse_equip(), parse_crew_keyword() | oracle.rs |
1b | Keyword-only line (guard: "{kw} abilities you activate cost {N} less" is a static, not a keyword line) | parse_router_keyword_list() (strict — see §3a) | oracle_keyword.rs |
2 | "Enchant {filter}" | skip (handled externally) | — |
| — | Commander-permission / deck-construction copy-limit sentences (skip); named equip " — Equip {cost}" | try_parse_equip() | oracle.rs |
11 | Planeswalker loyalty +N: / −N: / 0: / [+N]: (runs here despite the label) | try_parse_loyalty_line() | oracle.rs |
| — | Granted-quoted statics: is_granted_static_line() ("enchanted/equipped/all/… has/gains "…"" ), incl. compound can't-win/lose split | parse_static_line_with_graveyard_keyword_continuation() | oracle_static/ |
3b | "To solve — {condition}" (CR 719.1) | parse_solve_condition() → result.solve_condition | oracle_special.rs |
3c | "Channel — {cost}, Discard this card: {effect}" | activated-ability build | oracle.rs |
3d | "Boast — {cost}: {effect}" (implicit attacked-this-turn + once-per-turn restrictions, CR 702.142a) | activated-ability build | oracle.rs |
3e | "Exhaust — {cost}: {effect}" (implicit activate-only-once, CR 702.177a) | activated-ability build | oracle.rs |
3e2 | "Power-up — {cost}: {effect}" (activate only once per game, MV cost reduction if entered this turn, CR 602.5b) | activated-ability build | oracle.rs |
3f | "Forecast — {cost}: {effect}" (hand-only, your upkeep, once per turn, CR 702.57a-b) | activated-ability build | oracle.rs |
4 | Activated ability — contains ":" with cost-like prefix | find_activated_colon() + parse_activated_ability_definition() | oracle_cost.rs + oracle_effect/ |
5-pre | Trigger-framed "… enters with [counters] on it" — CR 614.1c replacement despite When/Whenever framing | parse_replacement_line() | oracle_replacement.rs |
5-6 | Triggered abilities — has_trigger_prefix() (When/Whenever/At); compound triggers produce multiple TriggerDefinitions (CR 603.2) | parse_trigger_line() | oracle_trigger.rs |
6b | Ability-word-prefixed activated/trigger lines ("Threshold — {T}: …", "Heroic — Whenever …") — must precede static/replacement gates | strip word + re-route | oracle.rs |
6c-defiler | Defiler cycle: "As an additional cost to cast [color] permanent spells, you may pay N life. Those spells cost {C} less…" — static, not self-cost | parse_defiler_cost_reduction() | oracle_special.rs |
6c-altcost | "You may pay X rather than pay the mana cost for [filter] spells you cast" (CR 118.9; Fist of Suns class) | parse_spells_alternative_cost() | oracle_static/cost_mod.rs |
6c-altcost-b | "You may cast [filter] by paying {X} rather than paying their mana costs" (Primal Prayers) | parse_cast_spells_alternative_cost_multi() | oracle_static/cost_mod.rs |
6c-altcost-c | "You may collect evidence N rather than pay …" (Conspiracy Unraveler class) | parse_collect_evidence_alt_cost() | oracle_static/cost_mod.rs |
6c-altcost-d | "For each {C} in a cost, you may pay 2 life rather than pay that mana" (K'rrik class, CR 107.4f) | static-line parse | oracle_static/ |
6c-altcost-e | "You may [cost] rather than pay [keyword] cost[s]" (New Perspectives / Heart of Kiran class) | parse_alternative_keyword_cost() | oracle_static/cost_mod.rs |
6d | Compound "enters tapped and doesn't untap during your untap step" — decomposed into ETB-tapped replacement (CR 614.1c) + CantUntap static (CR 502.3) | both parsers run | oracle.rs |
6e | Cross-layer compound "<subject> can't <P1> and can't <P2>" — each conjunct routed to both layer parsers (Blossombind: Untap-prevention replacement CR 701.26b + AddCounter-prevention replacement CR 614.6) so a conjunct isn't dropped by is_static_pattern claiming the whole line | parse_static_replacement_compound() | oracle.rs |
6f | Compound "<continuous grant or restriction> and can't become/be untapped" (Frozen in Ice class) — leading grant/restriction clause stays a static modification, trailing clause becomes a broad Untap-prevention replacement (CR 701.26b + CR 614.6) so is_static_pattern at 7 doesn't silently absorb and drop the untap prohibition | try_split_and_cant_become_untapped() | oracle.rs |
7 | Static/continuous patterns — is_static_pattern(); spell lines with explicit durations and damage verbs are deferred to 9; copy-replacement lines route to the replacement parser first | parse_static_line_multi() family | oracle_classifier.rs → oracle_static/ |
8 | Replacement patterns — is_replacement_pattern(); one paragraph can yield multiple ETB replacements | parse_replacement_line() | oracle_classifier.rs → oracle_replacement.rs |
8c | Leyline clause "If this card is in your opening hand, you may begin the game with it on the battlefield" (CR 103.6) | parse_begin_game_clause() | oracle.rs |
8c-strive | Strive lines — skip (cost extracted by the pre-loop scan) | skip | oracle.rs |
| — | Casting restrictions ("Cast this spell only …"), spell casting options, die-roll tables (try_parse_die_roll_table, consumes header + table lines), Suspend/Specialize/Harmonize/Mayhem keyword-cost extraction | various | oracle_casting.rs, oracle_special.rs, oracle_keyword.rs |
8f | Kicker / Multikicker / Replicate cost lines — before the spell catch-all so they don't become Unimplemented | keyword extraction | oracle.rs |
9 | Card is Instant/Sorcery → strict keyword-cost line first (consume-on-success, §3a), else imperative spell body | parse_router_keyword_line() (strict) → parse_effect_chain() | oracle_keyword.rs + oracle_effect/ |
| — | Flashback-equal-to-mana-cost, Commander ninjutsu, Escape em-dash, Cumulative upkeep keyword extraction | keyword extraction | oracle.rs / oracle_keyword.rs |
12 | Roman numeral chapters (saga) | skip (pre-parsed) | — |
13 | Keyword cost lines (is_keyword_cost_line) — extract parameterized keyword (e.g. "Morph {2}{B}") then skip. Consume-on-success: only an all-consuming strict parse advances the line (see §3a) | parse_router_keyword_line() (strict) | oracle_keyword.rs |
13b | Kicker/Multikicker leftovers | skip (handled by keywords) | — |
13c | Vehicle tier lines "N+ | keyword(s)" | skip | oracle_classifier.rs |
13d | "Activate only…" constraint line | skip | — |
13e | "X can't be 0." annotation → min_x_value on previous ability | defensive fallback | oracle.rs |
14 | Ability word prefix ("Landfall —") — strip, map known words to typed conditions, re-classify the body | strip_ability_word_with_name() + ability_word_to_condition() | oracle.rs |
14a | Nom fallback dispatch — try effect, trigger, static, and replacement sub-parsers | dispatch_line_nom() | oracle_dispatch.rs |
15 | Final fallback | Effect::Unimplemented with diagnostic trace | — |
3a. Keyword Lines — Strict Router vs Permissive Grant (oracle_keyword.rs)
There are two keyword-parsing surfaces. They are not interchangeable, and using
the wrong one at a router boundary is the silent-swallow bug class.
| Surface | Contract | Where it may be used |
|---|
parse_keyword_line_core() | Remainder-preserving core: Option<(Keyword, &str /* unconsumed */)>. The single authority both wrappers are built on. | Internal — call a wrapper, not this. |
parse_router_keyword_line() | STRICT / all-consuming. Candidate recognizer → reminder strip → core → all_consuming permitted tail (P/R/M modifiers). Returns a typed RoutedKeywordLine only when the line parses completely. | The only surface that may license a router to CONSUME a whole line. |
parse_router_keyword_fragment() | STRICT. One keyword phrase: core + all-consuming permitted tail + modifiers. The primitive the other strict surfaces are built on. | Any router intercept parsing a single keyword phrase (flashback, suspend, specialize, buyback, escalate, commander ninjutsu, …). |
parse_router_keyword_list() | STRICT. The keyword-LIST sibling: comma parts, MTGJSON validation, protection expansion — with every part all-consuming. | Router slots and routing classifiers facing a keyword list (priorities 0 and 1b, is_semicolon_keyword_line, is_spell_resolution_instruction_line). parse_router_keyword_line cannot serve here: it parses ONE keyword and takes no MTGJSON names, so it cannot see a bare-keyword line ("Flying, vigilance"). |
parse_granted_keyword_fragment() / extract_granted_keyword_list() | PERMISSIVE. Takes the leading keyword and discards the remainder — by design. | Embedded grant contexts only: static/token/vote/class-level/effect-payload payloads ("…gains vanishing 3 if …"), where a trailing clause belongs to the enclosing sentence. |
Consume-on-success (the rule). A candidate recognizer (is_keyword_cost_line)
is a filter, not evidence. A router may advance past a line only after
parse_router_keyword_line returns a typed keyword all-consumingly, or after it
explicitly emits Effect::unimplemented. Advancing on a permissive parse drops the
discarded remainder's semantics with no keyword and no diagnostic — the card then
renders as fully supported while its text was never modelled. Cycling {2} if you control an artifact must fall through to an honest, exact-unit Effect::Unimplemented,
not vanish.
Registry. ROUTER_KEYWORD_CASES (oracle_keyword.rs, #[cfg(test)]) is the typed
family registry. A set-equality test pins it against is_keyword_cost_line's candidate
prefix set: every prefix has exactly one case, with a valid fixture, a semantic-suffix
rejection, and a declared production reach. Adding a prefix to the recognizer without a
strict parser fails the build. KNOWN_NOUN_PARAM_LEAKS is a ratchet listing the
families whose noun/filter parameter still absorbs a trailing clause — entries are
deleted as each is fixed, never added to.
The boundary is fully enforced. Every router slot and routing classifier now parses
through a strict surface; the permissive symbols are not even imported into oracle.rs.
Gate G in scripts/check-parser-combinators.sh is the plain whole-file invariant: a
permissive keyword-parser symbol appearing anywhere inside parse_oracle_ir,
is_semicolon_keyword_line, or is_spell_resolution_instruction_line fails the build, at
any count. Note the ordering consequence this closed: priority 1b runs long before the
strict routers at 9/13, so while it was permissive it claimed keyword lines first
whenever MTGJSON named the keyword — which silently shadowed the strict wiring downstream
for exactly the cards MTGJSON knows about.
is_static_pattern() — oracle_classifier.rs
Gates Priority 7. Returns false for target-leading lines, then matches
STATIC_CONTAINS_PATTERNS (word-boundary scan: "gets +", "have ", "can't
attack", "nonland ", "you may spend mana as though", …), STATIC_PREFIX_PATTERNS,
and is_static_compound_pattern() (graveyard/top-of-library/exile cast
permissions, "spells can't be cast", flash grants, …). Check the constants in
oracle_classifier.rs for the full lists.
is_replacement_pattern() — oracle_classifier.rs
Gates Priority 8. Matches REPLACEMENT_CONTAINS_PATTERNS ("would ",
"prevent all", "enters tapped/untapped/prepared", "enter as a copy of",
"become a copy of"), trailing " enter tapped/untapped", counter-prohibition
phrases (CR 614.17), and is_replacement_compound_pattern() (as-enters-choose,
enters/escapes + counter, tapped-for-mana + instead, madness discard).
4. Core Concepts
4a. Subject Stripping — The Key Design Decision
strip_subject_clause() removes subjects like "you", "target creature", "its controller" and recurses on the predicate. This simplifies parsing but discards semantic information.
Rule: If the subject encodes game-relevant information, intercept with a try_parse_* helper before stripping.
When to intercept: Subject determines WHO is affected, WHAT is referenced, or creates a sentence-internal dependency.
When stripping is fine: "You draw three cards" (caster always draws), "Destroy target creature" (target is in verb phrase).
"Its controller gains life equal to its power"
❌ strip_subject_clause → loses "its controller" → GainLife { player: Controller } BUG
✅ try_parse_targeted_controller_gain_life() → GainLife { player: TargetedController, amount: TargetPower }
The try_parse_* intercept pattern is used in:
try_parse_subject_predicate_ast() in subject.rs — for subject-verb clauses
lower_imperative_clause() in mod.rs — for imperative clauses with semantic subjects
4b. ClauseAst Type System
Top-level sentence classification — ClauseAst:
| Variant | Shape | Example |
|---|
Imperative | Bare verb, no subject | "draw three cards" |
SubjectPredicate | Subject + verb | "target creature gets +2/+2" |
Conditional | Wrapped conditional | "if you control a creature, draw a card" |
Predicate types — PredicateAst:
| Variant | Detected by | Example |
|---|
Continuous | "gets/get", "has/have" | "gets +2/+2 and has flying" |
Become | "becomes" | "becomes a 3/3 creature" |
Restriction | "can't", "cannot" | "can't attack or block" |
ImperativeFallback | None of the above | Falls back to imperative parsing |
Imperative family dispatch — ImperativeFamilyAst (oracle_ir/ast.rs). The enum
has three layers of variants: direct families, Structured(ImperativeAst)
wrapping the structured families (Numeric, Targeted, SearchCreation,
HandReveal, Choose, Utility), and a tail of keyword-action leaves
(Explore, Connive, Investigate, Learn, Manifest, Proliferate,
Populate, Goad, RollDie, VentureIntoDungeon, …):
| Family | Sub-parser | Verb patterns |
|---|
CostResource | parse_cost_resource_ast() | add mana, pay life, deal damage |
ZoneCounter | parse_zone_counter_ast() | destroy, exile, counter, put counter |
Structured(Numeric) | parse_numeric_imperative_ast() | draw, gain life, lose life, pump, scry, surveil, mill |
Structured(Targeted) | parse_targeted_action_ast() | tap, untap, sacrifice, discard, return, fight, gain control |
Structured(SearchCreation) | parse_search_and_creation_ast() | search library, dig, create token, copy token |
Structured(HandReveal) | parse_hand_reveal_ast() | look at hand, reveal hand, reveal top |
Structured(Choose) | parse_choose_ast() | target-only, named choice, reveal hand filter |
Structured(Utility) | parse_utility_imperative_ast() | prevent, regenerate, copy, transform, attach |
Shuffle | parse_shuffle_ast() | shuffle, shuffle into library |
Put | parse_put_ast() | put into/on top of |
YouMay | "you may" prefix | Wraps inner effect (generic optionals are peeled earlier by clause_shell) |
Approximate dispatch order in parse_imperative_family_ast(): keyword-grant
intercepts → CostResource → ZoneCounter → Numeric → Targeted → SearchCreation →
Utility → Shuffle → HandReveal → Choose → keyword-action leaves → Put → YouMay.
Read the function for the binding order before inserting a new family.
4c. Clause Splitting & Continuations
split_clause_sequence(text) splits multi-sentence text on . (Sentence), , then (Then), and certain , boundaries. Respects parentheses and possessive apostrophes.
Continuation absorption — a follow-up clause modifies a preceding effect:
| Pattern | Continuation | What it does |
|---|
| Search → "put into your hand" | SearchDestination | Appends ChangeZone sub_ability |
| RevealHand → "choose a nonland card" | RevealHandFilter | Patches card filter |
| Mana → "spend this mana only..." | ManaRestriction | Patches spend restriction |
| Counter → "that spell loses all abilities" | CounterSourceStatic | Patches source_static |
| Token → "suspect it" | SuspectLastCreated | Appends Suspect sub_ability |
Key functions: parse_followup_continuation_ast(), parse_intrinsic_continuation_ast(), continuation_absorbs_current(), apply_clause_continuation() — all in oracle_effect/sequence.rs.
4d. QuantityExpr / QuantityRef
pub enum QuantityExpr {
Ref { qty: QuantityRef },
Fixed { value: i32 },
}
QuantityRef contains ONLY dynamic references (HandSize, LifeTotal, ObjectCount, TargetPower, Variable, etc.). Constants belong in QuantityExpr::Fixed — never put Fixed(i32) inside QuantityRef.
| Oracle phrase | Mapping |
|---|
| "3 damage" | QuantityExpr::Fixed { value: 3 } |
| "damage equal to its power" | QuantityExpr::Ref { qty: TargetPower } |
| "X damage" | QuantityExpr::Ref { qty: Variable { name: "X" } } |
| "for each creature you control" | QuantityExpr::Ref { qty: ObjectCount { filter } } |
4e. Self-Reference Normalization
Before parsing, normalize_self_refs() replaces the card's name and phrases like "this creature" with ~. The canonical phrase list lives in oracle_util.rs as SELF_REF_TYPE_PHRASES — update the constant, not each consumer.
parse_target() handles both ~ and type phrases → TargetFilter::SelfRef automatically. Any parser function checking self-references gets this for free via parse_target.
5. Deep Dive — oracle_effect/ Directory
oracle_effect/
├── mod.rs — Orchestrator: parse_effect_chain(), parse_effect_clause(), compound detection
├── conditions.rs — Leading condition splitting, AbilityCondition extraction, condition bridges
├── imperative.rs — Imperative verb family parsing: parse_*_ast() + lower_*_ast()
├── lower.rs — Clause lowering helpers: strip_trailing_duration / strip_leading_duration
│ (the live duration suffix/prefix tables), damage-player scopes
├── search.rs — Search/seek parsing helpers: search filters, seek details, destinations
├── subject.rs — Subject-predicate parsing: try_parse_subject_predicate_ast()
├── sequence.rs — Clause boundary splitting and continuation absorption
├── token.rs — Token creation: "create a 1/1 white Spirit token with flying"
├── animation.rs — Animation/become: "becomes a 3/3 creature with flying"
├── become_copy_except.rs — Shared ", except <body>" clause for copy effects (CR 707.9 + CR 613.1a)
├── counter.rs — Counter mechanics: put/remove/move/double counters
└── mana.rs — Mana production and spend restrictions
AST type definitions (ClauseAst, ImperativeFamilyAst, ParsedEffectClause,
etc.) live in oracle_ir/ast.rs — the former oracle_effect/types.rs was
moved there as part of the IR layer.
Subject-Predicate Parsing — subject.rs
try_parse_subject_predicate_ast() parses sentences with explicit subjects.
Subject resolution via parse_subject_application():
| Subject text | Result |
|---|
| "target creature" | Explicit target with TargetFilter |
| "all creatures", "each creature" | Mass filter |
| "~", "it", "this creature" | SelfRef |
| "enchanted creature" | EnchantedCreature |
| "equipped creature" | EquippedCreature |
| "defending player" | DefendingPlayer |
| "creatures you control" | Typed filter with controller: You |
Predicate hierarchy: try_parse_subject_continuous_clause() → try_parse_subject_become_clause() → try_parse_subject_restriction_clause() → fallback to strip_subject_clause() + imperative.
Imperative Family Verb Patterns
Numeric (parse_numeric_imperative_ast): draw N, gain N life, lose N life, gets +X/+Y, scry N, surveil N, mill N. Also used by try_parse_for_each_effect() via with_for_each_quantity().
ZoneCounter (parse_zone_counter_ast): destroy target/all, exile target/all, counter target spell, put N counters on target (delegates to counter.rs), remove N counters.
Targeted (parse_targeted_action_ast): tap/untap target, sacrifice, discard N, return to hand/battlefield, fight, gain control of.
CostResource (parse_cost_resource_ast): add {mana} (delegates to mana.rs), pay N life, pay {mana}, deal damage.
SearchCreation (parse_search_and_creation_ast): search your library, look at top N (dig), create token (delegates to token.rs), token copy.
Token (token.rs): Parses count → P/T → supertypes → colors → types → name → keywords → "where X is" expressions.
Animation (animation.rs): Parses "becomes a 3/3 [colors] [types] [keywords]" → AnimationSpec → Vec<ContinuousModification>.
Counter (counter.rs): try_parse_put_counter, try_parse_remove_counter, try_parse_move_counters, try_parse_multiply_counter, try_parse_double_effect.
Mana (mana.rs): try_parse_add_mana_effect (fixed symbols, colorless, any color, chosen color), parse_mana_spend_restriction, try_parse_activate_only_condition.
Compound Action Detection — mod.rs
try_split_targeted_compound() — "verb target X and verb2 it": uses parse_target() remainder to find split, inherits parent target via replace_target_with_parent()
try_parse_compound_shuffle() — "shuffle X and Y into libraries": two ChangeZone effects
try_parse_for_each_effect() — "draw a card for each creature": delegates to parse_numeric_imperative_ast() + with_for_each_quantity() + thread_for_each_subject()
parse_damage_player_scope() / parse_damage_each_player_scope() — shared damage-player routing helpers. Use these for exact each player / each opponent / each foe damage phrases before falling back to DamageAll. Keep this semantic split in oracle_effect/mod.rs; do not push it into parse_target(), which remains object/filter-oriented.
Special-Case Matchers in parse_effect_clause()
| Matcher | Pattern | Effect |
|---|
try_parse_damage_prevention_disabled() | "damage can't be prevented" | GenericEffect + DamagePreventionDisabled |
try_parse_still_a_type() | "it's still a land" | GenericEffect + AddType |
try_parse_for_each_effect() | "draw a card for each creature" | Numeric AST + for-each quantity |
try_parse_equal_to_quantity_effect() | "mill cards equal to hand size" | Effect with QuantityExpr |
6. Other Parser Modules
| Module | Purpose | Invoked at Slot |
|---|
oracle_classifier.rs | Shared line-classification helpers: trigger prefixes (has_trigger_prefix), is_static_pattern() (~line 398), is_replacement_pattern(), granted-static and vehicle-tier detection. Called by oracle.rs, oracle_dispatch.rs, and class parsing. | Gates for 5-6, 7, 8, 13c |
oracle_dispatch.rs | Nom fallback dispatch for effect/static/replacement candidates before Unimplemented. | 14a |
clause_shell.rs | Structural-slot peeling (peel_clause / ClauseContext) — see §2. Destination for all new structural slot handling (optional, duration, leading condition today). | Inside parse_effect_clause() |
oracle_special.rs | Router-adjacent helpers for solve conditions, Defiler two-line statics, die-roll tables, static self-ref normalization, and keyword-line parsing (Escape/Harmonize/Cumulative Upkeep). | 3b, 6c-defiler, die-roll slot |
oracle_trigger.rs | Trigger parsing: subject + event decomposition, constraint parsing (OncePerTurn, OncePerGame). Uses parse_trigger_subject() → try_parse_event() pipeline. | 5-6 |
oracle_static/ | Directory — static ability parsing split into submodules (see below). Entry: parse_static_line() / parse_static_line_multi() in mod.rs / shared.rs, internally two-phase (parse_static_line_ir → lower_static_ir). | 7 |
oracle_replacement.rs | Replacement effects: priority-ordered pattern matching (as-enters-choose before shock-land before fast-land, etc.), builder pattern with ReplacementDefinition::new(). | 8, 5-pre |
oracle_condition.rs | Restriction conditions: source/control/graveyard/hand/event conditions for "Cast only if..." / "Activate only if..." patterns. | Used by 4 and casting-restriction lines |
oracle_cost.rs | Ability cost parsing: mana costs, tap/sacrifice/discard costs, parse_single_cost() for individual cost components. | 4 |
oracle_keyword.rs | Keyword extraction: comma-separated keyword lists, parameterized keywords (ward, kicker), keyword grants. | 0, 1b, 13 + keyword-cost slots |
oracle_casting.rs | Casting options/restrictions: additional costs ("As an additional cost"), alternative costs, timing restrictions (flash, sorcery speed), scan_timing_restrictions(). | 8b (early) + casting-restriction slot |
oracle_modal.rs | Modal spell parsing: "Choose N" headers, bullet mode collection, parse_oracle_block() for block-level parsing. | 1 |
oracle_vote.rs | Council's-dilemma / Will-of-the-Council vote blocks (CR 701.38): "each player votes for A or B" + per-vote effects. | Within 9 and trigger bodies |
oracle_separate_piles.rs | Pile-separation shape (CR 700.3): "separates ... into two piles" three-sentence form. | Within 9 |
oracle_class.rs | Class card parsing (level-gated abilities). | Special pre-parse |
oracle_level.rs | Leveler card parsing (LEVEL N-M power/toughness ranges). | Special pre-parse |
oracle_saga.rs | Saga chapter parsing (roman numeral → chapter effects). | Special pre-parse |
oracle_attraction.rs | Attraction visit abilities and numbered visit lines (CR 717.5 + CR 702.159a). | Special pre-parse |
oracle_spacecraft.rs | Spacecraft pipe-delimited threshold lines "N+ | body" → charge-counter-gated statics/triggers/abilities (CR 721). | Special pre-parse |
The oracle_static/ Directory Split
The former single-file oracle_static.rs is now a directory. mod.rs owns the
public parse_static_line() (two-phase: parse_static_line_ir →
lower_static_ir), a shared prelude, and the re-export surface. Submodules:
| Sub-module | What lives here |
|---|
dispatch.rs | parse_static_line_inner — the priority-ordered static-pattern dispatch |
shared.rs | parse_static_line_multi, compound-line splitting, cross-submodule helpers |
anthem.rs | P/T modification statics: "get +1/+1", dynamic/base P/T, "where X is" binding |
keyword_grant.rs | Keyword/ability grants: parse_continuous_modifications(), quoted-ability grants, graveyard keyword grants |
evasion.rs | Combat statics: can't-block/attack splits, block exceptions, must-attack |
restriction.rs | Casting/activation prohibitions: strip_casting_prohibition_subject(), cast-and-activate-only-during |
cost_mod.rs | Cost modification statics: alternative costs, cost payment prohibitions |
type_change.rs | Type-changing statics: "is a", "becomes", additive type clauses |
cda.rs | Characteristic-defining abilities |
grammar.rs | Shared static-line grammar combinators |
static_helpers.rs | Misc static construction helpers |
loyalty.rs | Loyalty-related static helpers |
mana_transform.rs | Mana-type transformation statics (retain-unspent-mana, etc.) |
Event-Context References
parse_event_context_ref() in oracle_target.rs handles trigger-event anaphoric references:
| Oracle phrase | TargetFilter variant |
|---|
| "that spell's controller" | TriggeringSpellController |
| "that player" | TriggeringPlayer |
| "that source" / "that permanent" | TriggeringSource |
| "defending player" | DefendingPlayer |
Must be checked BEFORE standard parse_target() for trigger-based effects.
The Possessive vs. Targeting Fork
Critical decision point — silent failure when wrong:
"Look at your hand" → contains_possessive → target: Controller
"Look at target opponent's hand" → parse_target → target: Typed { controller: Opponent }
- Possessive forms that fall to
parse_target → no target found → Unimplemented
- Targeting forms matched by
contains_possessive → targeting phase skipped → wrong player affected
7. Building Block Reference
Search these modules BEFORE writing any new utility. Duplicating what already exists is a defect.
| Module | What Lives Here | Use When |
|---|
oracle_nom/primitives.rs | Numbers (digits, English words, articles), mana symbols/costs, colors, counter types, P/T modifiers, roman numerals, parse_article_number (word-boundary guard — prevents "another" → "a"), scan_at_word_boundaries, scan_contains | Parsing any atomic Oracle text element |
oracle_nom/target.rs | Target phrase combinators, controller suffix, color prefix, combat status, self-reference, event-context refs | Parsing "target X" or type descriptions in nom pipelines |
oracle_nom/quantity.rs | Quantity expressions, quantity refs, "equal to" patterns, "for each" patterns | Parsing counts and dynamic amounts in nom pipelines |
oracle_nom/duration.rs | Duration phrase combinators (parse_duration, parse_optional_duration, parse_cast_snapshot_suffix) | Parsing inline duration phrases in nom pipelines — but see the duration doctrine below for where NEW duration patterns go |
oracle_nom/condition.rs | parse_condition (prefix + inner), parse_inner_condition (single authority for all game-state conditions) | Parsing "if/unless/as long as" — ALWAYS delegate here |
oracle_nom/filter.rs | Zone filters, controller filters, property filters ("tapped", "attacking", "with flying", "with a +1/+1 counter") | Parsing object property constraints |
oracle_nom/error.rs | OracleError / OracleResult type aliases, oracle_err (error constructor for hand-rolled combinators). For "parser couldn't handle this", use Effect::unimplemented(name, fragment) from types/ability.rs — the single authority; literal Effect::Unimplemented { .. } construction is gated for new code | Error handling at parser dispatch boundaries |
oracle_nom/bridge.rs | nom_on_lower (run nom on lowercase, map consumed bytes back to original-case remainder), nom_on_lower_required (Result variant), nom_parse_lower (discard remainder) | Bridging mixed-case Oracle text to lowercase nom combinators |
oracle_nom/context.rs | ParseContext (subject, quantity_ref, card_name, in_trigger, in_replacement) | Threading parse state across combinator boundaries |
oracle_util.rs | TextPair (dual original/lowercase slices with strip_prefix/strip_suffix), parse_number wrapper, mana symbol parsing, strip_reminder_text, normalize_card_name_refs, possessive/pronoun matching (contains_possessive, contains_object_pronoun, starts_with_possessive), match_phrase_variants, merge_or_filters, SELF_REF_TYPE_PHRASES, SELF_REF_PARSE_ONLY_PHRASES | Case-bridging structural ops, shared string utilities, phrase matching |
oracle_target.rs | parse_target (full target extraction), parse_type_phrase (type descriptions without "target"), parse_player_reference, parse_event_context_ref, parse_zone_suffix | High-level target/filter extraction from Oracle text |
oracle_quantity.rs | Frozen legacy fall-through — parse_quantity_ref (semantic interpretation), parse_cda_quantity (CDAs), parse_for_each_clause ("for each [filter]") | Existing call sites only. Do NOT add new QuantityRef recognition here — it goes in oracle_nom/quantity.rs (see doctrine below) |
Where New Grammar Goes — Single-Authority Doctrine
Each grammar axis has exactly one home for NEW pattern recognition. Adding a
pattern anywhere else creates a second authority that will drift:
- Durations — one grammar. Two duration tables exist today:
oracle_nom/duration.rs::parse_duration (the nom combinator grammar) and
oracle_effect/lower.rs::strip_trailing_duration (+ strip_leading_duration)
— the suffix/prefix tables that the clause shell and the static/effect
parsers actually run against, and the richer of the two ("for the rest of
the game", "until ~ leaves the battlefield", mid-clause durations).
Consolidation into oracle_nom/duration.rs is planned; until that port
happens, new duration patterns go in strip_trailing_duration's table in
oracle_effect/lower.rs (and strip_leading_duration for leading forms),
so the clause shell picks them up for free. Do not add a third recognizer.
QuantityRef recognition — new dynamic-quantity phrases go in
oracle_nom/quantity.rs (parse_quantity_ref combinator and friends),
never in oracle_quantity.rs, which is frozen legacy fall-through.
- Type phrases / targets — new type-phrase and target work composes the
oracle_nom/target.rs combinators. oracle_target.rs remains the
high-level extraction surface, but its building blocks are the nom
combinators — extend those, not bespoke string logic.
- Conditions —
parse_inner_condition in oracle_nom/condition.rs is the
single condition recognizer (output type: StaticCondition). Its output is
adapted into other condition layers by three bridges that MUST be kept
exhaustive when StaticCondition gains variants:
static_condition_to_trigger_condition (oracle_trigger.rs),
static_condition_to_ability_condition (oracle_effect/conditions.rs), and
static_condition_to_restriction_condition (oracle_condition.rs). A new
StaticCondition variant that is silently unconvertible in a bridge is a
swallow bug waiting to surface.
- Structural slots (optional, duration, leading condition, future siblings)
—
clause_shell.rs (see §2). New slot types migrate into the shell.
Damage-player routing (oracle_effect/mod.rs) — exact player-set phrases in damage effects have a dedicated helper path:
| Helper | Purpose | Use When |
|---|
parse_damage_player_scope() | Parse the player noun for damage phrases: player, opponent, foe | Reusing the noun parse across simple and compound damage clauses |
parse_damage_each_player_scope() | Parse exact each player/opponent/foe with punctuation-only tails allowed | Routing DealDamage text to DamageEachPlayer instead of DamageAll |
Rule: if the Oracle text is a damage effect that names a set of players, resolve that at the effect layer with these helpers. Do not teach parse_target() that each opponent is a player-damage target, because that would blur the object-target/filter boundary and reintroduce object-vs-player bugs.
Sub-Ability Chains & Target Propagation
parse_effect_chain() splits on . boundaries and links clauses as sub_ability. At runtime, resolve_ability_chain() walks the chain. When a parent ability has targets but the sub-ability does not, targets propagate automatically. Sub-abilities do NOT need their own target lists.
8. CR Annotation Protocol
MANDATORY for any code implementing MTG game rules. Non-optional.
Verification — Before Writing ANY CR Number
grep -n "^701.21" docs/MagicCompRules.txt
grep -n "^702.122" docs/MagicCompRules.txt
grep -n "^704.5a" docs/MagicCompRules.txt
If you cannot find the rule number, do NOT write the annotation. Flag it as "needs manual verification" instead. 701.x and 702.x numbers are arbitrary sequential assignments — LLMs consistently hallucinate them.
A wrong CR number is worse than no CR number. It creates false confidence that code was verified against the wrong rule.
Format
- Prefix: Always
CR. Never Rule, MTG Rule, or bare numbers.
- Description is mandatory — bare
CR 704.5a with no explanation is not acceptable.
+ for interacting rules, / for alternative/overlapping rules.
- Only annotate game logic, not boilerplate/plumbing.
9. Checklists
9a. Adding a New Parser Pattern
Phase 1 — Identify Where It Belongs
- Imperative verb/family → the relevant
parse_*_ast() in imperative.rs
- Subject + predicate →
try_parse_subject_* in subject.rs
- Token creation →
token.rs
- Animation/become →
animation.rs
- Counter mechanics →
counter.rs
- Mana production →
mana.rs
- Continuation/absorption →
sequence.rs
- Structural slot (optional / duration / leading condition) →
clause_shell.rs (see §2)
- Duration phrase →
strip_trailing_duration table in oracle_effect/lower.rs (see §7 doctrine)
- Dynamic quantity →
oracle_nom/quantity.rs (never oracle_quantity.rs)
- Trigger →
oracle_trigger.rs
- Static →
oracle_static/ (dispatch in dispatch.rs, category submodules per §6)
- Replacement →
oracle_replacement.rs
- Routing gate →
is_static_pattern() / is_replacement_pattern() in oracle_classifier.rs (~line 398)
Phase 2 — Add the Pattern
Phase 3 — Handle the Subject
Phase 4 — Chain Composition
Phase 5 — Routing
Phase 6 — Tests & Verification
9b. Adding a New Effect Type
Cross-reference the /add-engine-effect skill for the full 8-phase lifecycle (types → handler → targeting → parser → interactive → multiplayer → frontend → AI → tests).
9c. Adding a New Trigger Event
Cross-reference the /add-trigger skill. Parser-specific: add pattern in try_parse_event(), wire subject into valid_card/valid_source, add tests.
Simple-verb events (e.g., stations, crews a vehicle, saddles a mount, becomes saddled): add a SimpleEvent::* variant in parse_simple_event, then a tag(...) arm in the appropriate alt() group. Compound events (e.g., saddles a mount or crews a vehicle) MUST precede their singular components so the compound matches first. Dispatch sets def.mode + valid_card (or valid_source for pronoun-context subjects).
Actor-side compound-subject matchers: when a trigger's subject filter may include non-source creatures (e.g., "Tiana or another legendary creature you control crews a Vehicle"), the runtime matcher MUST consult trigger.valid_card against the event's actor list (e.g., event.creatures) via matches_target_filter from game/filter.rs. See match_crews / match_saddles / match_saddles_or_crews + the shared match_actor_against_filter helper in trigger_matchers.rs for the canonical pattern.
Condition-scoped constraint recognition: trigger-frequency qualifiers like "for the first time each turn" must be detected against the post-split_trigger condition text only, NOT the full Oracle text — otherwise any card whose EFFECT text coincidentally contains the phrase is silently constrained. The phrase is then stripped from condition_text before dispatch so verbatim handlers (e.g., "whenever you cycle another card") hit unchanged, and the constraint is applied as a fallback in parse_trigger_line only when no stronger text-based constraint (OnlyDuringYourMainPhase, OncePerTurn via explicit text) was set. See the condition-scoped assignment block in parse_trigger_line for the canonical pattern.
9d. Adding a New Phrase Helper
- Identify phrase variants
- Implement via
match_phrase_variants() in oracle_util.rs
- Export from module
- Add tests for all variants
9e. Adding a New Replacement Pattern
- Add
parse_* function matching the Oracle text
- Insert at correct priority in
parse_replacement_line() — before any overlapping pattern
- Add parser tests
10. Common Pitfalls
| Mistake | Consequence | Fix |
|---|
starts_with("verb ") for dispatch | Bypasses nom, no structured errors | tag("verb ").parse(lower) or nom_on_lower |
&text[N..] hardcoded byte offset | Off-by-one, mixed-case breakage | nom_on_lower calculates remainder automatically |
find() / split_once() / contains() for parsing | Bypasses nom architecture | Use nom combinators — Rule Zero |
| Reimplementing number/color/mana parsing | Duplicates existing combinators | Delegate to oracle_nom::primitives |
tag("a") without word boundary | "another" falsely matches as "a" | Use parse_article_number |
parse_number for X-cost values | X not converted to 0 | Use parse_number_or_x |
Hardcoding amount: 1 when unparseable | Gap invisible in coverage | Return Effect::Unimplemented |
| Boolean flags on effect types | Undefined combinations, obscured intent | Use enum variant |
Losing subject via strip_subject_clause | "Its controller gains life" → wrong player | Add try_parse_* interceptor |
| Pattern too broad, shadows existing | Existing cards break | Specific before general; test existing patterns |
parse_target for possessive forms | No target found → Unimplemented | Use contains_possessive → Controller |
contains_possessive for targeting forms | Targeting skipped → wrong player | Use parse_target → typed filter |
| Monolithic condition parsing | Fragile, card-specific | Use subject+event decomposition |
| Splitting on " and " naively | Breaks compound effects | Use try_split_targeted_compound |
Putting Fixed(i32) inside QuantityRef | Wrong abstraction layer | QuantityRef = dynamic only; Fixed in QuantityExpr |
Editing mod.rs when sub-module is right | Bloats orchestrator | Token → token.rs, mana → mana.rs, counters → counter.rs, leading conditions → conditions.rs |
unwrap() on parse results | Parser panics on unknown text | Return None or Effect::Unimplemented |
Not recognizing ~ as self-reference | Self-targeting fails | parse_target handles both ~ and type phrases |
Inline use nom::* in function bodies | CLAUDE.md prohibition | All imports at file top |
Unimplemented with misleading name | Coverage miscategorizes gap | Actual verb as name, full text as description |
Peek-vs-chomp — upstream scan_* / detector reads marker text without consuming, downstream loop re-encounters and warns or drops it | "Swallow:*" warning emitted even though semantic was captured upstream; or qualifier text silently dropped on routing | Either single-pass read-and-chomp in the upstream helper, OR add a matching consume-without-record arm in the downstream dispatch loop. See scan_distinct_names_clause (peek) ↔ parse_search_filter_suffixes "with different name[s]" (chomp) for the canonical pair. |
11. Diagnostics — Swallow Detectors & parse_warnings
The parser must never silently discard Oracle text. Every clause must either be represented in the parsed AST OR cause the line to fail and yield Effect::Unimplemented carrying the original phrase. Anything in between is a parser lie.
The crates/engine/src/parser/swallow_check.rs module audits each card's parsed ParsedAbilities against its original Oracle text and emits a parse_warning for every marker phrase that has no AST representation. Findings surface in the coverage report via CardFace::parse_warnings (also written into each card's entry in client/public/card-data.json).
Reading current swallow gaps:
jq -r '[.[] | .parse_warnings // [] | .[]] | length' client/public/card-data.json
cargo run -p engine --bin coverage-report -- data --brief \
--write-warning-patterns /tmp/parser-warning-patterns.json >/tmp/coverage.json
jq -r '
[.[] | select(.category=="swallowed-clause")]
| sort_by(-.otherwise_supported_cards, -.card_count)
| .[0:25][]
| "\(.otherwise_supported_cards) otherwise / \(.card_count) cards / \(.single_gap_cards) single | \(.pattern) | \(.example_cards|join(", "))"
' /tmp/parser-warning-patterns.json
cargo run -p engine --bin coverage-report -- data \
--warning-category swallowed-clause \
--warning-pattern 'Replacement_Instead: instead' \
--warning-limit 20 >/tmp/warning-drilldown.json
cargo run -p engine --bin coverage-report -- data \
--warning-detector Replacement_Instead \
--warning-limit 20 >/tmp/warning-drilldown.json
cargo run -p engine --bin coverage-report -- data \
--warning-detector DynamicQty \
--warning-full \
--warning-limit 5 >/tmp/warning-drilldown-full.json
Detector class prefixes (one row per detector in swallow_check.rs):
| Prefix | What it flags |
|---|
Condition_If | "if " present in text but no condition/constraint/if_clause slot in AST |
Condition_Unless | "unless …" not bound to unless_filter / unless_* slot |
Condition_AsLongAs | "as long as …" not bound to a conditional static |
DynamicQty | "for each / equal to / the number of / twice / half" present but AST has only Fixed quantity values — the canonical count parsed but routed downstream as Fixed bug class |
Duration_ThisTurn / _UntilEndOfTurn / _NextTurn | duration phrase present but no duration slot populated |
Optional_YouMay / _MayHave | "you may …" / "may have it …" not bound to the optional flag |
Replacement_Instead | " instead" present but no replacement definition emitted or the detector has a false positive because the AST represented the replacement through another supported structure |
ActivateOnlyDuring / ActivateLimit | activation timing/limit phrase not bound to a restriction slot |
APNAP | "starting with you" / "in turn order" not bound to order metadata |
target-fallback: | secondary class — parse_target couldn't classify a noun phrase, or a downstream chomping loop encountered an unmatched filter suffix |
Current card-data.json stores parse warnings as structured diagnostics, not legacy strings:
{ "type": "SwallowedClause", "detector": "Replacement_Instead", "description": "...", "line_index": 0 }
Use --warning-detector <detector> for broad-family triage and --warning-pattern '<detector>: <normalized excerpt>' for exact shared-fix slices. A high supported_cards count in the drilldown means the warning is likely detector noise or an already-parsed semantic that swallow_check.rs does not recognize yet; inspect parsed_labels before adding parser behavior.
Workflow:
- Start with
parser-warning-patterns.json sorted by otherwise_supported_cards; this finds the largest likely false-positive or minor-chomp groups.
- Run
coverage-report --warning-pattern ... or --warning-detector ... and inspect supported, gap_count, parsed_labels, and gap_details before editing parser code.
- Classify the pattern: detector false positive, parsed primary effect with missing modifier, or real parser gap.
- When fixing a real swallow, identify the dispatch site that recognized the marker but failed to either capture or chomp it. The fix is almost always at one of two places: the upstream recognition (route through the right
try_parse_* interceptor) or the downstream chomping loop (add a missing arm). The peek-vs-chomp pitfall in §10 is the recurring root cause.
- After fixing, regenerate (
./scripts/gen-card-data.sh) and rerun the same drilldown; warnings should drop by exactly the affected class size unless other detectors were un-muted.
- Suppression rule — suppression is per source unit, never card-wide. A unit that owns an
Effect::Unimplemented has already declared its gap explicitly, so its own expectations are not re-reported as swallowed clauses; every other unit on the card is still audited. Fixing one unit's gap can therefore un-mute that unit's own detector warnings, but never another unit's.
Scope — the audit runs once per source unit (a distinct span of Oracle text), not once per card. Both halves are unit-scoped: the expectation comes from that unit's own fragment, and the evidence from the definitions that unit produced. line_index names the line the clause was swallowed on.
12. Self-Maintenance
After completing work using this skill:
- Verify references by running
./scripts/check-skill-doc.sh
- Update the priority table (§3) if slots were added/removed/renamed in
parse_oracle_ir — the gate compares the table against the // Priority <label>: comments