👁️
1/**
2 * AST to predicate compiler for Scryfall search
3 *
4 * Compiles a SearchNode AST into a function that tests cards.
5 */
6
7import { stripDiacritics } from "../normalize-text";
8import { type CardPredicate, compileField } from "./fields";
9import type { CompileError, Result, SearchNode } from "./types";
10import { ok } from "./types";
11
12// Re-export CardPredicate for convenience
13export type { CardPredicate };
14
15/**
16 * Compile an AST node into a card predicate function
17 */
18export function compile(node: SearchNode): Result<CardPredicate, CompileError> {
19 switch (node.type) {
20 case "AND":
21 return compileAnd(node.children);
22
23 case "OR":
24 return compileOr(node.children);
25
26 case "NOT":
27 return compileNot(node.child);
28
29 case "FIELD":
30 return compileField(node.field, node.operator, node.value, node.span);
31
32 case "NAME":
33 return ok(compileName(node.value, node.pattern));
34
35 case "EXACT_NAME":
36 return ok(compileExactName(node.value));
37 }
38}
39
40/**
41 * Compile AND node - all children must match
42 */
43function compileAnd(
44 children: SearchNode[],
45): Result<CardPredicate, CompileError> {
46 const predicates: CardPredicate[] = [];
47 for (const child of children) {
48 const result = compile(child);
49 if (!result.ok) {
50 return result;
51 }
52 predicates.push(result.value);
53 }
54 return ok((card) => predicates.every((p) => p(card)));
55}
56
57/**
58 * Compile OR node - any child must match
59 */
60function compileOr(
61 children: SearchNode[],
62): Result<CardPredicate, CompileError> {
63 const predicates: CardPredicate[] = [];
64 for (const child of children) {
65 const result = compile(child);
66 if (!result.ok) {
67 return result;
68 }
69 predicates.push(result.value);
70 }
71 return ok((card) => predicates.some((p) => p(card)));
72}
73
74/**
75 * Compile NOT node - child must not match
76 */
77function compileNot(child: SearchNode): Result<CardPredicate, CompileError> {
78 const result = compile(child);
79 if (!result.ok) {
80 return result;
81 }
82 return ok((card) => !result.value(card));
83}
84
85/**
86 * Normalize text for name comparison (lowercase + strip diacritics)
87 */
88function normalizeName(text: string): string {
89 return stripDiacritics(text).toLowerCase();
90}
91
92/**
93 * Compile name search - substring or regex match
94 */
95function compileName(value: string, pattern: RegExp | null): CardPredicate {
96 if (pattern) {
97 return (card) => {
98 // Match against main name
99 if (pattern.test(card.name)) return true;
100
101 // Match against card face names for multi-face cards
102 if (card.card_faces) {
103 for (const face of card.card_faces) {
104 if (pattern.test(face.name)) return true;
105 }
106 }
107
108 return false;
109 };
110 }
111
112 // Substring match (case-insensitive, diacritic-insensitive)
113 const normalized = normalizeName(value);
114 return (card) => {
115 // Match against main name
116 if (normalizeName(card.name).includes(normalized)) return true;
117
118 // Match against card face names for multi-face cards
119 if (card.card_faces) {
120 for (const face of card.card_faces) {
121 if (normalizeName(face.name).includes(normalized)) return true;
122 }
123 }
124
125 return false;
126 };
127}
128
129/**
130 * Compile exact name match
131 */
132function compileExactName(value: string): CardPredicate {
133 const normalized = normalizeName(value);
134 return (card) => {
135 // Match against main name exactly
136 if (normalizeName(card.name) === normalized) return true;
137
138 // Match against card face names for multi-face cards
139 if (card.card_faces) {
140 for (const face of card.card_faces) {
141 if (normalizeName(face.name) === normalized) return true;
142 }
143 }
144
145 return false;
146 };
147}