Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 52 additions & 15 deletions crates/engine/src/parser/oracle.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::borrow::Cow;

use crate::parser::oracle_nom::error::{OracleError, OracleResult};
use nom::branch::alt;
use nom::bytes::complete::{tag, take_until, take_while};
Expand Down Expand Up @@ -1158,9 +1160,33 @@ fn item_replacement(item: &OracleItemIr) -> Option<&ReplacementDefinition> {
}
}

fn item_ability(item: &OracleItemIr) -> Option<&AbilityDefinition> {
/// CR 607.2d: the ability side of document-relation discovery.
///
/// Returns `Cow` because the two spell-bearing node shapes own their definition
/// at different times. A pre-lowered item already holds an `AbilityDefinition`
/// and lends it out; an IR-native item holds only an `EffectChainIr` and owns no
/// definition at all until lowering builds one, so it must lower and hand back
/// the result owned. A plain `&AbilityDefinition` cannot express the second case
/// — there is nothing to borrow from — and a plain `AbilityDefinition` would
/// clone the first case at all seven call sites, most of which scan every item
/// on the card.
///
/// This is where the trigger side's reasoning stops applying.
/// `item_trigger` keeps a borrow and pushes exhaustiveness down onto
/// `TriggerNodeIr::definition()`, because both of its shapes own a
/// `TriggerDefinition` — `TriggerNodeIr::Assembled` carries one directly. There
/// is no equivalent layer to push this obligation onto: a spell node's IR
/// payload is an `EffectChainIr`, not an enum of definition-owning
/// representations, so both shapes are named here. `_ => None` is safe for the
/// same reason it is safe there: every remaining variant is genuinely `None`.
///
/// Lowering is the identity conversion `lower_oracle_ir` (the `Spell` arm) will
/// perform for the same item, so a relation predicate sees exactly the
/// definition the relation will later be applied to.
fn item_ability(item: &OracleItemIr) -> Option<Cow<'_, AbilityDefinition>> {
match &item.node {
OracleNodeIr::PreLoweredSpell(def) => Some(def),
OracleNodeIr::PreLoweredSpell(def) => Some(Cow::Borrowed(def)),
OracleNodeIr::Spell(effect_ir) => Some(Cow::Owned(lower_effect_chain_ir(effect_ir))),
_ => None,
}
}
Expand Down Expand Up @@ -1421,7 +1447,7 @@ fn detect_linked_choice_type_statics(
}
)
});
let is_dig = item_ability(item).is_some_and(ability_chain_has_dig)
let is_dig = item_ability(item).is_some_and(|def| ability_chain_has_dig(&def))
|| item_trigger(item)
.and_then(|trigger| trigger.execute.as_deref())
.is_some_and(ability_chain_has_dig);
Expand Down Expand Up @@ -1542,7 +1568,7 @@ fn chosen_subtype_kind_from_persisted_choice_items(
items
.iter()
.filter_map(item_ability)
.find_map(chosen_subtype_kind_from_ability)
.find_map(|def| chosen_subtype_kind_from_ability(&def))
})
.or_else(|| {
items
Expand Down Expand Up @@ -1643,7 +1669,7 @@ fn detect_linked_choice_persisted_player(
) {
let has_durable_reader = items.iter().any(|item| {
item_static(item).is_some_and(static_references_source_chosen_player)
|| item_ability(item).is_some_and(ability_references_source_chosen_player)
|| item_ability(item).is_some_and(|def| ability_references_source_chosen_player(&def))
|| item_trigger(item).is_some_and(trigger_references_source_chosen_player)
});
if !has_durable_reader {
Expand All @@ -1652,7 +1678,7 @@ fn detect_linked_choice_persisted_player(
let choosers: Vec<OracleItemId> = items
.iter()
.filter(|item| {
item_ability(item).is_some_and(ability_chain_has_player_choice)
item_ability(item).is_some_and(|def| ability_chain_has_player_choice(&def))
|| item_trigger(item)
.and_then(|trigger| trigger.execute.as_deref())
.is_some_and(ability_chain_has_player_choice)
Expand Down Expand Up @@ -1703,9 +1729,9 @@ fn detect_linked_choice_copy_chosen_host(
items: &[OracleItemIr],
relations: &mut Vec<DocumentRelationIr>,
) {
let chooser = items
.iter()
.find(|item| item_ability(item).is_some_and(ability_is_as_enters_choose_permanent_gap));
let chooser = items.iter().find(|item| {
item_ability(item).is_some_and(|def| ability_is_as_enters_choose_permanent_gap(&def))
});
let copy_static = items.iter().find(|item| {
item_static(item).is_some_and(|s| {
s.modifications
Expand Down Expand Up @@ -3075,12 +3101,12 @@ fn ability_is_active_player_punisher(def: &AbilityDefinition) -> bool {
fn detect_active_player_punisher(items: &[OracleItemIr], relations: &mut Vec<DocumentRelationIr>) {
let Some(coerce) = items
.iter()
.find(|item| item_ability(item).is_some_and(ability_is_active_player_coerce))
.find(|item| item_ability(item).is_some_and(|def| ability_is_active_player_coerce(&def)))
else {
return;
};
for item in items {
if item_ability(item).is_some_and(ability_is_active_player_punisher) {
if item_ability(item).is_some_and(|def| ability_is_active_player_punisher(&def)) {
relations.push(DocumentRelationIr::ActivePlayerPunisher {
coerce: coerce.id,
punisher: item.id,
Expand Down Expand Up @@ -3699,13 +3725,19 @@ impl<'a> DocEmitter<'a> {
///
/// `take_last_spell` pops the emission-ordered spell stack, which equals
/// `abilities.last_mut()` regardless of triggers/statics emitted in between.
///
/// Reads the popped item through `lower_spell_node`, which accepts BOTH
/// spell node shapes. It must: `emit` pushes either shape onto
/// `spells_emitted` and `take_last_spell` pops it with no variant filter, so
/// a destructure that names one shape panics the parser the first time an
/// IR-native spell precedes a mutating line.
fn mutate_last_spell(&mut self, f: impl FnOnce(&mut AbilityDefinition)) {
let Some(item) = self.builder.take_last_spell() else {
return;
};
let OracleItemIr { source, node, .. } = item;
let OracleNodeIr::PreLoweredSpell(mut def) = node else {
unreachable!("take_last_spell returns only PreLoweredSpell items");
let Some(mut def) = lower_spell_node(&node) else {
unreachable!("`spells_emitted` holds only spell nodes, and both spell shapes lower");
};
f(&mut def);
self.reemit_spell(&source, def);
Expand Down Expand Up @@ -5852,8 +5884,13 @@ pub(crate) fn parse_oracle_ir(
node: base_node,
..
} = base_item;
let OracleNodeIr::PreLoweredSpell(mut base) = base_node else {
unreachable!("pop_last_spell returns only PreLoweredSpell items");
// Both spell node shapes, via the shared reader:
// `pop_last_spell` pops `spells_emitted`, which `emit`
// fills from either shape with no variant filter.
let Some(mut base) = lower_spell_node(&base_node) else {
unreachable!(
"`spells_emitted` holds only spell nodes, and both spell shapes lower"
);
};
// Save the base ability's continuation chain in else_ability
// so the engine can run it when the condition is NOT met.
Expand Down
44 changes: 30 additions & 14 deletions crates/engine/src/parser/oracle_ir/doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -914,14 +914,13 @@ impl OracleDocBuilder {
// dispatch loop baked in.
//
// Match is EXHAUSTIVE over `OracleNodeIr` (no `_`), mirroring `emit`'s
// printed-slot match above: a future node variant — or the still
// never-constructed `Spell` IR variant once a later commit emits it —
// must fail to compile here until its slot behavior is decided, rather
// than being silently skipped (which would mis-index every later
// trigger/ability). `Trigger` is destructured to its one payload variant
// for the same reason: adding an IR-native trigger payload must break
// this match, because the stamp targets `execute`, which a decomposition
// does not have until lowering builds it.
// printed-slot match above: a future node variant must fail to compile
// here until its slot behavior is decided, rather than being silently
// skipped (which would mis-index every later trigger/ability).
// `Trigger` is destructured to its one payload variant for the same
// reason: adding an IR-native trigger payload must break this match,
// because the stamp targets `execute`, which a decomposition does not
// have until lowering builds it.
let mut trigger_slot = 0usize;
let mut ability_slot = 0usize;
for item in self.items.values_mut() {
Expand All @@ -938,12 +937,29 @@ impl OracleDocBuilder {
stamp_retained_printed_slot(def, ability_slot, PrintedItemKind::Ability);
ability_slot += 1;
}
// No retain modification can reach these today: the `*` IR variants
// are never constructed in unit 3a, and the remaining categories do
// not carry a copy-except body. Left explicit (not `_`) so a new
// slot-bearing node is a compile error, per the note above.
OracleNodeIr::Spell(_)
| OracleNodeIr::Static(_)
// CR 707.9a printed slots count PRINTED abilities. The set of
// variants that advance `ability_slot` here must equal the set
// `lower_oracle_ir` (`oracle.rs`) pushes into `result.abilities`,
// which is in turn the set `emit` pushes onto `spells_emitted`
// above: both spell shapes. Three matches over one set.
//
// Advances WITHOUT stamping, on purpose — do not try to make this
// arm symmetric with the arm above. An IR-native spell body has no
// `AbilityDefinition` until lowering builds one, and
// `stamp_retained_printed_slot` takes `&mut AbilityDefinition`, so
// there is nothing here to stamp. The printed slot is consumed all
// the same: skipping the advance stamps every LATER ability one
// slot low, silently binding a copy's "except it has this ability"
// to the wrong ability.
OracleNodeIr::Spell(_) => {
ability_slot += 1;
}
// Neither stamps nor counts: `RetainPrinted*FromSource` exists only
// for the trigger and ability categories (CR 707.9a), so these
// carry no slot to rewrite and consume neither counter this walk
// maintains. Left explicit (not `_`) so a new slot-bearing node is
// a compile error, per the note above.
OracleNodeIr::Static(_)
| OracleNodeIr::PreLoweredStatic(_)
| OracleNodeIr::Replacement(_)
| OracleNodeIr::PreLoweredReplacement(_)
Expand Down
Loading
Loading