diff --git a/api/parsing/hand_parser.py b/api/parsing/hand_parser.py index bf1d5a7c5..732937bd5 100644 --- a/api/parsing/hand_parser.py +++ b/api/parsing/hand_parser.py @@ -53,7 +53,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} ) @@ -506,8 +509,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: @@ -527,7 +531,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 c6c18ac46..e6fe7690e 100644 --- a/api/parsing/pyparsing_based.py +++ b/api/parsing/pyparsing_based.py @@ -57,7 +57,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 957817555..86c2fbfb4 100644 --- a/api/parsing/tests/test_parser_parity.py +++ b/api/parsing/tests/test_parser_parity.py @@ -118,6 +118,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=[