-
-
Notifications
You must be signed in to change notification settings - Fork 153
fix(parser): correct the enters-with conjunct doc, and support elided counts #7490
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,7 +2,7 @@ use nom::branch::alt; | |||||||||||||||||||||||||||||
| use nom::bytes::complete::{tag, take_till1, take_until}; | ||||||||||||||||||||||||||||||
| use nom::character::complete::{multispace0, multispace1, satisfy}; | ||||||||||||||||||||||||||||||
| use nom::combinator::{all_consuming, eof, map, not, opt, peek, rest, value, verify}; | ||||||||||||||||||||||||||||||
| use nom::multi::separated_list1; | ||||||||||||||||||||||||||||||
| use nom::multi::many0; | ||||||||||||||||||||||||||||||
| use nom::sequence::{preceded, terminated}; | ||||||||||||||||||||||||||||||
| use nom::Parser; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
@@ -10868,28 +10868,112 @@ pub(crate) fn parse_with_counters_suffix_spanned( | |||||||||||||||||||||||||||||
| /// Those two are the cards this combinator actually reaches, confirmed against | ||||||||||||||||||||||||||||||
| /// the CI parse diff. The self-referential "…enters with two +1/+1 counters and | ||||||||||||||||||||||||||||||
| /// a lifelink counter on it" shape (Dust Animus, Voidpouncer) prints the SAME | ||||||||||||||||||||||||||||||
| /// conjoined grammar but is parsed at a different seam that calls | ||||||||||||||||||||||||||||||
| /// `parse_counter_suffix_body_combinator` directly rather than through this | ||||||||||||||||||||||||||||||
| /// list, so it still lifts only the first conjunct. Routing that seam through | ||||||||||||||||||||||||||||||
| /// here is the follow-up; do not assume this function already covers it. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// `separated_list1` is the right combinator rather than a hand-rolled loop | ||||||||||||||||||||||||||||||
| /// because nom backtracks the separator when the following element fails, which | ||||||||||||||||||||||||||||||
| /// is what stops the list from swallowing a non-counter conjunct: on "…counters | ||||||||||||||||||||||||||||||
| /// on it and you become the monarch" the `" and "` separator matches but | ||||||||||||||||||||||||||||||
| /// `parse_counter_suffix_body_combinator` rejects "you" at its leading | ||||||||||||||||||||||||||||||
| /// `parse_number`, so the list ends with the separator unconsumed and the | ||||||||||||||||||||||||||||||
| /// monarch instruction stays in the remainder. Same for "and draw X cards" | ||||||||||||||||||||||||||||||
| /// (Cosima) and "and with haste" (Voidpouncer) — every non-counter conjunct is | ||||||||||||||||||||||||||||||
| /// rejected by the element's mandatory leading count/article. | ||||||||||||||||||||||||||||||
| /// conjoined grammar but never reaches this combinator: a CR 614.1c | ||||||||||||||||||||||||||||||
| /// "[permanent] enters with …" line is an object-hosted REPLACEMENT, parsed by | ||||||||||||||||||||||||||||||
| /// `oracle_replacement::parse_enters_with_counters`, which carries its own | ||||||||||||||||||||||||||||||
| /// conjoined-list reader (`parse_enters_counter_entries`). That reader already | ||||||||||||||||||||||||||||||
| /// lifts every conjunct — pinned by `gated_self_enters_with_conjoined_counters` | ||||||||||||||||||||||||||||||
| /// there — so there is no missing routing to add. Do NOT "unify" the two by | ||||||||||||||||||||||||||||||
| /// pointing the replacement seam at this list: the two count axes are not the | ||||||||||||||||||||||||||||||
| /// same. The replacement reader opens each element with | ||||||||||||||||||||||||||||||
| /// `oracle_util::parse_count_expr` (X, `twice X`, `half X, rounded up`, | ||||||||||||||||||||||||||||||
| /// `N plus/minus X`) and rewrites X to the entering object's `CostXPaid`, while | ||||||||||||||||||||||||||||||
| /// this list opens on `nom_primitives::parse_number`, which takes digits and | ||||||||||||||||||||||||||||||
| /// English number words only. Routing the replacement path through here would | ||||||||||||||||||||||||||||||
| /// REGRESS the X-counted enters-with cards (Astral Cornucopia, Sin, Unending | ||||||||||||||||||||||||||||||
| /// Cataclysm), not extend them. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// What the two readers DO share is the ELEMENT grammar, and both take the | ||||||||||||||||||||||||||||||
| /// elided-count conjunct through the same combinator | ||||||||||||||||||||||||||||||
| /// ([`parse_countless_counter_element`]) so they cannot drift on it. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// `many0` over a `preceded(separator, element)` — rather than a hand-rolled | ||||||||||||||||||||||||||||||
| /// loop — is what stops the list from swallowing a non-counter conjunct: nom | ||||||||||||||||||||||||||||||
| /// backtracks the whole `preceded` when the element fails, so on "…counters on | ||||||||||||||||||||||||||||||
| /// it and you become the monarch" the `" and "` separator matches, both element | ||||||||||||||||||||||||||||||
| /// arms reject "you" (no leading count; "you become the monarch" is not a | ||||||||||||||||||||||||||||||
| /// recognized counter type), and the list ends with the separator unconsumed so | ||||||||||||||||||||||||||||||
| /// the monarch instruction stays in the remainder. Same for "and draw X cards" | ||||||||||||||||||||||||||||||
| /// (Cosima) and "and with haste" (Voidpouncer). | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// It is `many0` over an explicit first element rather than `separated_list1` | ||||||||||||||||||||||||||||||
| /// because the two positions no longer take the same parser: only a NON-LEADING | ||||||||||||||||||||||||||||||
| /// element may elide its count. | ||||||||||||||||||||||||||||||
| fn parse_enter_counters_clause_body( | ||||||||||||||||||||||||||||||
| input: &str, | ||||||||||||||||||||||||||||||
| ) -> OracleResult<'_, Vec<(CounterType, QuantityExpr)>> { | ||||||||||||||||||||||||||||||
| preceded( | ||||||||||||||||||||||||||||||
| tag("with "), | ||||||||||||||||||||||||||||||
| separated_list1(tag(" and "), parse_counter_suffix_body_combinator), | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| .parse(input) | ||||||||||||||||||||||||||||||
| let (rest, _) = tag("with ").parse(input)?; | ||||||||||||||||||||||||||||||
| // CR 122.1: the LEADING element must carry its own count — that mandatory | ||||||||||||||||||||||||||||||
| // number is what anchors the list and stops it claiming arbitrary prose. | ||||||||||||||||||||||||||||||
| // Later elements may elide it (see `parse_countless_counter_element`), so | ||||||||||||||||||||||||||||||
| // the tail tries the counted form first and falls back to the elided one. | ||||||||||||||||||||||||||||||
| let (rest, first) = parse_counter_suffix_body_combinator(rest)?; | ||||||||||||||||||||||||||||||
| let (rest, tail) = many0(preceded( | ||||||||||||||||||||||||||||||
| tag(" and "), | ||||||||||||||||||||||||||||||
| alt(( | ||||||||||||||||||||||||||||||
| parse_counter_suffix_body_combinator, | ||||||||||||||||||||||||||||||
| parse_countless_counter_element, | ||||||||||||||||||||||||||||||
| )), | ||||||||||||||||||||||||||||||
| )) | ||||||||||||||||||||||||||||||
|
Comment on lines
+10911
to
+10917
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Parse comma-separated counter elements. Lines 10911-10917 accept only Accept Proposed change- let (rest, tail) = many0(preceded(
- tag(" and "),
+ let (rest, tail) = many0(preceded(
+ alt((tag(", and "), tag(" and "), tag(", "))),
alt((
parse_counter_suffix_body_combinator,
parse_countless_counter_element,
)),📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSources: Coding guidelines, Path instructions |
||||||||||||||||||||||||||||||
| .parse(rest)?; | ||||||||||||||||||||||||||||||
| // "on it" terminates the LIST. A counted final element consumes it itself | ||||||||||||||||||||||||||||||
| // (the `opt` inside `parse_counter_suffix_body_combinator`), an elided one | ||||||||||||||||||||||||||||||
| // leaves it — strip it here either way so the consumed span is the same | ||||||||||||||||||||||||||||||
| // shape for both, which is what `parse_with_counters_suffix_spanned`'s | ||||||||||||||||||||||||||||||
| // callers slice on. | ||||||||||||||||||||||||||||||
| let (rest, _) = opt(tag::<_, _, OracleError<'_>>(" on it")).parse(rest)?; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| let mut counters = Vec::with_capacity(1 + tail.len()); | ||||||||||||||||||||||||||||||
| counters.push(first); | ||||||||||||||||||||||||||||||
| counters.extend(tail); | ||||||||||||||||||||||||||||||
| Ok((rest, counters)) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// CR 122.1: ONE element of a conjoined counter list whose count is ELIDED — | ||||||||||||||||||||||||||||||
| /// "…an additional +1/+1 counter and deathtouch counter on it" (March Toward | ||||||||||||||||||||||||||||||
| /// Perfection), "…an additional +1/+1 counter, reach counter, and trample | ||||||||||||||||||||||||||||||
| /// counter on it" (Arcane Archery), "…an additional +1/+1 counter, trample | ||||||||||||||||||||||||||||||
| /// counter, and vigilance counter on it" (Tenacious Pup). A corpus sweep over | ||||||||||||||||||||||||||||||
| /// every printed "enters/enter with … counter" and battlefield-rider line found | ||||||||||||||||||||||||||||||
| /// those three and no others, so this is the whole class, not a sample of it. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// English coordination lets the leading element's determiner distribute across | ||||||||||||||||||||||||||||||
| /// the later conjuncts — "an additional [+1/+1 counter] and [deathtouch | ||||||||||||||||||||||||||||||
| /// counter]" — so a later element can carry no count of its own. Each such | ||||||||||||||||||||||||||||||
| /// element is a SINGULAR counter noun, and CR 122.1 places counters | ||||||||||||||||||||||||||||||
| /// individually, so the elided count is exactly one. That is a fact about | ||||||||||||||||||||||||||||||
| /// English determiner scope, not a rules inference: no CR section governs the | ||||||||||||||||||||||||||||||
| /// elision, which is why none beyond CR 122.1 (what a counter is) is cited. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// Two guards keep this from over-claiming. `parse_counter_suffix_body_combinator` | ||||||||||||||||||||||||||||||
| /// is anchored by its mandatory leading number, and slices its counter type with | ||||||||||||||||||||||||||||||
| /// an unbounded `take_until(" counter")`; with the number gone that slice would | ||||||||||||||||||||||||||||||
| /// happily swallow any prose sitting in front of the word "counter". So instead: | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// * the type must be a RECOGNIZED counter — `parse_strict_counter_type`, i.e. | ||||||||||||||||||||||||||||||
| /// the P/T-modifier, keyword-counter and named-counter arms WITHOUT the | ||||||||||||||||||||||||||||||
| /// open-ended `take_till1 → Generic` fallback. An unrecognized token fails | ||||||||||||||||||||||||||||||
| /// the element rather than becoming a bogus `Generic`. | ||||||||||||||||||||||||||||||
| /// * the noun must be SINGULAR. A plural elided element ("two +1/+1 counters | ||||||||||||||||||||||||||||||
| /// and trample counters") is genuinely ambiguous about whether the head | ||||||||||||||||||||||||||||||
| /// count distributes across the conjunction; no card prints one, so it fails | ||||||||||||||||||||||||||||||
| /// closed instead of guessing. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// Valid only in NON-LEADING position — the first element must still carry its | ||||||||||||||||||||||||||||||
| /// own count, which is what keeps the anchor on the list as a whole. Callers | ||||||||||||||||||||||||||||||
| /// enforce that by reaching for this only after a separator has matched. | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// Deliberately does NOT consume a trailing " on it": that filler terminates the | ||||||||||||||||||||||||||||||
| /// LIST, not the element, so both callers strip it once after their loop ends. | ||||||||||||||||||||||||||||||
| pub(crate) fn parse_countless_counter_element( | ||||||||||||||||||||||||||||||
| input: &str, | ||||||||||||||||||||||||||||||
| ) -> nom::IResult<&str, (CounterType, QuantityExpr), OracleError<'_>> { | ||||||||||||||||||||||||||||||
| let (rest, counter_type) = nom_primitives::parse_strict_counter_type(input)?; | ||||||||||||||||||||||||||||||
| let (rest, _) = tag(" counter").parse(rest)?; | ||||||||||||||||||||||||||||||
| // Singular-only guard — see the plural note above. `not` does not consume, | ||||||||||||||||||||||||||||||
| // so the terminator is left intact for the caller. | ||||||||||||||||||||||||||||||
| not(tag::<_, _, OracleError<'_>>("s")).parse(rest)?; | ||||||||||||||||||||||||||||||
| Ok((rest, (counter_type, QuantityExpr::Fixed { value: 1 }))) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// CR 122.6: the enter-with-counters rider in LEADING position, tolerating the | ||||||||||||||||||||||||||||||
|
|
@@ -11954,6 +12038,73 @@ mod tests { | |||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// CR 122.1: a NON-LEADING conjunct may elide its count, and the elided | ||||||||||||||||||||||||||||||
| /// count is one. Building-block coverage for the battlefield-rider list — | ||||||||||||||||||||||||||||||
| /// the printed cards for this shape (March Toward Perfection, Arcane | ||||||||||||||||||||||||||||||
| /// Archery, Tenacious Pup) all reach the sibling reader in | ||||||||||||||||||||||||||||||
| /// `oracle_replacement`, so without this the element grammar would only ever | ||||||||||||||||||||||||||||||
| /// be exercised from one of its two callers. | ||||||||||||||||||||||||||||||
| #[test] | ||||||||||||||||||||||||||||||
| fn counter_clause_list_accepts_elided_count_conjunct() { | ||||||||||||||||||||||||||||||
| use crate::types::keywords::KeywordKind; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| let (rest, counters) = parse_enter_counters_clause_body( | ||||||||||||||||||||||||||||||
| "with an additional +1/+1 counter and deathtouch counter on it", | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| .expect("elided-count conjunct must parse"); | ||||||||||||||||||||||||||||||
| assert_eq!(rest, "", "the list-level \" on it\" must be consumed"); | ||||||||||||||||||||||||||||||
| assert_eq!( | ||||||||||||||||||||||||||||||
| counters, | ||||||||||||||||||||||||||||||
| vec![ | ||||||||||||||||||||||||||||||
| (CounterType::Plus1Plus1, QuantityExpr::Fixed { value: 1 }), | ||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||
| CounterType::Keyword(KeywordKind::Deathtouch), | ||||||||||||||||||||||||||||||
| QuantityExpr::Fixed { value: 1 } | ||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /// The elided element has no leading number to anchor it, so its two guards | ||||||||||||||||||||||||||||||
| /// carry the whole burden of not over-claiming. Each input must yield a | ||||||||||||||||||||||||||||||
| /// ONE-element list, with the unclaimed text left on the remainder: | ||||||||||||||||||||||||||||||
| /// | ||||||||||||||||||||||||||||||
| /// * an unrecognized type is rejected by `parse_strict_counter_type` | ||||||||||||||||||||||||||||||
| /// rather than becoming a `CounterType::Generic`. Note the conjunct here | ||||||||||||||||||||||||||||||
| /// carries NO article — with one it would take the COUNTED arm, whose | ||||||||||||||||||||||||||||||
| /// open-ended `take_until(" counter")` maps any token to `Generic`; that | ||||||||||||||||||||||||||||||
| /// arm is anchored by its number and is out of scope for this guard. | ||||||||||||||||||||||||||||||
| /// * a PLURAL elided conjunct is ambiguous about whether the head count | ||||||||||||||||||||||||||||||
| /// distributes, so it fails closed; | ||||||||||||||||||||||||||||||
| /// * the leading element still REQUIRES its count — an elided head would | ||||||||||||||||||||||||||||||
| /// let the list start anywhere. | ||||||||||||||||||||||||||||||
| #[test] | ||||||||||||||||||||||||||||||
| fn elided_count_conjunct_guards() { | ||||||||||||||||||||||||||||||
| for (input, expected_rest) in [ | ||||||||||||||||||||||||||||||
| // Not a counter type — must not become Generic("fresh idea"). | ||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||
| "with a +1/+1 counter and fresh idea counter on it", | ||||||||||||||||||||||||||||||
| " and fresh idea counter on it", | ||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||
| // Plural elided conjunct — fails closed. | ||||||||||||||||||||||||||||||
| ( | ||||||||||||||||||||||||||||||
| "with two +1/+1 counters and trample counters on it", | ||||||||||||||||||||||||||||||
| " and trample counters on it", | ||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||
| ] { | ||||||||||||||||||||||||||||||
| let (rest, counters) = | ||||||||||||||||||||||||||||||
| parse_enter_counters_clause_body(input).expect("leading element must still parse"); | ||||||||||||||||||||||||||||||
| assert_eq!(counters.len(), 1, "over-claimed on {input:?}: {counters:?}"); | ||||||||||||||||||||||||||||||
| assert_eq!(rest, expected_rest, "wrong stop point for {input:?}"); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // An elided LEADING element is not a list at all. | ||||||||||||||||||||||||||||||
| assert!( | ||||||||||||||||||||||||||||||
| parse_enter_counters_clause_body("with trample counter on it").is_err(), | ||||||||||||||||||||||||||||||
| "the leading element must carry its own count" | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| fn variable_x() -> QuantityExpr { | ||||||||||||||||||||||||||||||
| QuantityExpr::Ref { | ||||||||||||||||||||||||||||||
| qty: QuantityRef::Variable { | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Remove incorrect CR 122.1 citations from parser-grammar rules. CR 122.1 defines counters, but it does not specify leading counts, singular elision, or English determiner scope. (media.wizards.com)
crates/engine/src/parser/oracle_effect/lower.rs#L10906-L10909: replace the leading-count CR claim with parser-grammar rationale.crates/engine/src/parser/oracle_effect/lower.rs#L10932-L10946: replace the elided-count CR claim with parser-grammar rationale.crates/engine/src/parser/oracle_replacement.rs#L4061-L4069: remove the CR label from the input-routing rationale.crates/engine/src/parser/oracle_replacement.rs#L4743-L4746: remove the CR label from the list-anchor rationale.crates/engine/src/parser/oracle_replacement.rs#L4763-L4780: remove the CR label from the counted-versus-elided grammar description.crates/engine/src/parser/oracle_replacement.rs#L15733-L15737: correct the test documentation for elided counts.📍 Affects 2 files
crates/engine/src/parser/oracle_effect/lower.rs#L10906-L10909(this comment)crates/engine/src/parser/oracle_effect/lower.rs#L10932-L10946crates/engine/src/parser/oracle_replacement.rs#L4061-L4069crates/engine/src/parser/oracle_replacement.rs#L4743-L4746crates/engine/src/parser/oracle_replacement.rs#L4763-L4780crates/engine/src/parser/oracle_replacement.rs#L15733-L15737🤖 Prompt for AI Agents
Source: Path instructions