From 83bf23620dc369e6e8e64d21999379ca410aaf06 Mon Sep 17 00:00:00 2001 From: daveycodez Date: Sun, 23 Aug 2026 12:56:16 -0700 Subject: [PATCH] Parsing: The '!' Alias Only Holds When It Is Glued to Both Sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #971 made '!' an '=' alias on COLOR/MANA/NUMERIC/RARITY/YEAR/DATE, but both parsers applied it across whitespace, which Scryfall does not. Measured live on api.scryfall.com: c!w 5,071 the alias c !w 0 name-and-exact-name reading, not the alias c! w 2,061 not the alias either cmc!3 8,077 the alias cmc !3 0 cmc! 3 0 r!rare 11,770 the alias r !rare 0 So the alias requires the bang glued on BOTH sides. The hand parser now asks space_before of the bang and of the token after it, in the NUMERIC branch and the class-gated one; the pyparsing grammar spells the same rule as leave_whitespace() on the bang plus a (?=\S) lookahead. A spaced bang keeps exactly the exact-name-prefix reading a space always had — `c !w` parses as it did before #971, and `cmc !3` rejects as it did before #971. Seven parity cases pin it: both parsers agree on every spaced spelling, and none of them may produce the glued spelling's tree. Suite: api/parsing/tests 2,319 passed; ruff check and format clean. --- api/parsing/hand_parser.py | 18 +++++++++--- api/parsing/pyparsing_based.py | 5 +++- api/parsing/tests/test_parser_parity.py | 38 +++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/api/parsing/hand_parser.py b/api/parsing/hand_parser.py index 0682de0e9..f15f3057b 100644 --- a/api/parsing/hand_parser.py +++ b/api/parsing/hand_parser.py @@ -52,7 +52,10 @@ # On Scryfall '!' is an alias for '=' on these classes only (verified live, #903 cause C) — on # TEXT/LEGALITY it isn't an operator at all, and a trailing bang there falls through to the -# existing exact-name-prefix reading of the next factor instead. +# existing exact-name-prefix reading of the next factor instead. The alias also only holds when +# the bang is GLUED to both sides: measured live, `c!w` is 5,071 where `c !w` is 0 (name-and- +# exact-name reading) and `c! w` / `cmc! 3` are not the alias either — so a spaced bang keeps +# the exact-name-prefix reading a space always had. _BANG_ALIAS_CLASSES: frozenset[ParserClass] = frozenset( {ParserClass.COLOR, ParserClass.MANA, ParserClass.RARITY, ParserClass.YEAR, ParserClass.DATE} ) @@ -498,8 +501,9 @@ def parse_word_primary(self, word: str) -> QueryNode: # ── NUMERIC attribute ── if pc == ParserClass.NUMERIC: - if next_tok.type in (TT.OP, TT.BANG): - op = "=" if next_tok.type == TT.BANG else next_tok.value + bang_alias = next_tok.type == TT.BANG and not next_tok.space_before and not self.peek(1).space_before + if next_tok.type == TT.OP or bang_alias: + op = "=" if bang_alias else next_tok.value self.consume() return CardBinaryOperatorNode(CardAttributeNode(wl, ParserClass.NUMERIC), op, self.parse_num_expr_value()) if next_tok.type in _ARITH_OPS and not next_tok.space_before: @@ -519,7 +523,13 @@ def parse_word_primary(self, word: str) -> QueryNode: return lhs # ── known non-NUMERIC attribute ── - bang_alias = pc is not None and next_tok.type == TT.BANG and pc in _BANG_ALIAS_CLASSES + bang_alias = ( + pc is not None + and next_tok.type == TT.BANG + and not next_tok.space_before + and not self.peek(1).space_before + and pc in _BANG_ALIAS_CLASSES + ) if pc is not None and (next_tok.type == TT.OP or bang_alias): op = "=" if bang_alias else next_tok.value self.consume() diff --git a/api/parsing/pyparsing_based.py b/api/parsing/pyparsing_based.py index 2266ace76..c8985f6ca 100644 --- a/api/parsing/pyparsing_based.py +++ b/api/parsing/pyparsing_based.py @@ -58,7 +58,10 @@ # On Scryfall '!' is an alias for '=' on COLOR/MANA/NUMERIC/RARITY/YEAR/DATE only (verified live, # #903 cause C) — not on TEXT/LEGALITY, so those conditions keep using DEFAULT_OPERATORS. '!=' # still wins over bare '!' here: DEFAULT_OPERATORS is tried first and already matches it whole. -EQ_ALIAS_OPERATORS = DEFAULT_OPERATORS | Literal("!").set_parse_action(lambda: "=") +# The bang must be GLUED to both sides (leave_whitespace for the left, the lookahead for the +# right): measured live, `c!w` is 5,071 where `c !w` is 0 and `c! w` is not the alias either — +# a spaced bang keeps the exact-name-prefix reading it always had. +EQ_ALIAS_OPERATORS = DEFAULT_OPERATORS | Regex(r"!(?=\S)").leave_whitespace().set_parse_action(lambda: "=") _NUMERIC_LITERAL_RE = re.compile(r"^\d+(\.\d+)?$") _COMPARISON_OPERATORS = frozenset({">", "<", ">=", "<=", "=", "!=", ":"}) diff --git a/api/parsing/tests/test_parser_parity.py b/api/parsing/tests/test_parser_parity.py index c78bf098f..f1825a2e2 100644 --- a/api/parsing/tests/test_parser_parity.py +++ b/api/parsing/tests/test_parser_parity.py @@ -114,6 +114,44 @@ def test_bang_equals_alias_parity(query: str) -> None: assert_parsers_agree(query) +@pytest.mark.parametrize( + argnames=["query"], + argvalues=[ + ("c !w",), + ("c! w",), + ("c ! w",), + ("cmc !3",), + ("cmc! 3",), + ("r !rare",), + ("year! 2020",), + ], + ids=[ + "space_before_bang_color", + "space_after_bang_color", + "space_both_sides_color", + "space_before_bang_numeric", + "space_after_bang_numeric", + "space_before_bang_rarity", + "space_after_bang_year", + ], +) +def test_spaced_bang_is_not_the_alias(query: str) -> None: + """A '!' with a space on either side keeps the exact-name reading a space always had. + + Measured live: `c!w` answers 5,071 where `c !w` answers 0 (the name-and-exact-name reading) + and `c! w` / `cmc! 3` are not the alias either. Both parsers must agree, and neither may fold + the spaced spelling into `=` — a spaced spelling that errors (the numeric and year cases: + `!3` cannot be an exact name) proves the same thing, since the glued spelling parses. + """ + assert_parsers_agree(query) + try: + parsed = parse_scryfall_query(query) + except ValueError: + return # rejected outright, which is certainly not the alias + glued = parse_scryfall_query(query.replace(" ", "")) + assert parsed.root.to_json() != glued.root.to_json(), "the spaced bang folded into the alias" + + @pytest.mark.parametrize( argnames=["bang_query", "eq_query"], argvalues=[