Skip to content
Open
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
18 changes: 14 additions & 4 deletions api/parsing/hand_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
)
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand Down
5 changes: 4 additions & 1 deletion api/parsing/pyparsing_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -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({">", "<", ">=", "<=", "=", "!=", ":"})
Expand Down
38 changes: 38 additions & 0 deletions api/parsing/tests/test_parser_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[
Expand Down
Loading