👁️
1import fc from "fast-check";
2import { describe, expect, it } from "vitest";
3import { parse } from "../parser";
4import type { SearchNode } from "../types";
5
6describe("parse", () => {
7 function expectParse(input: string): SearchNode {
8 const result = parse(input);
9 expect(result.ok).toBe(true);
10 if (!result.ok) throw new Error(result.error.message);
11 return result.value;
12 }
13
14 function expectParseError(input: string): string {
15 const result = parse(input);
16 expect(result.ok).toBe(false);
17 if (result.ok) throw new Error("Expected parse error");
18 return result.error.message;
19 }
20
21 describe("name expressions", () => {
22 it("parses bare word as name", () => {
23 const node = expectParse("bolt");
24 expect(node.type).toBe("NAME");
25 if (node.type === "NAME") {
26 expect(node.value).toBe("bolt");
27 expect(node.pattern).toBeNull();
28 }
29 });
30
31 it("parses quoted string as name", () => {
32 const node = expectParse('"Lightning Bolt"');
33 expect(node.type).toBe("NAME");
34 if (node.type === "NAME") {
35 expect(node.value).toBe("Lightning Bolt");
36 }
37 });
38
39 it("parses exact name with !", () => {
40 const node = expectParse("!Lightning");
41 expect(node.type).toBe("EXACT_NAME");
42 if (node.type === "EXACT_NAME") {
43 expect(node.value).toBe("Lightning");
44 }
45 });
46
47 it("parses regex as name", () => {
48 const node = expectParse("/bolt$/i");
49 expect(node.type).toBe("NAME");
50 if (node.type === "NAME") {
51 expect(node.pattern).toBeInstanceOf(RegExp);
52 expect(node.pattern?.test("Lightning Bolt")).toBe(true);
53 }
54 });
55 });
56
57 describe("field expressions", () => {
58 it("parses type field", () => {
59 const node = expectParse("t:creature");
60 expect(node.type).toBe("FIELD");
61 if (node.type === "FIELD") {
62 expect(node.field).toBe("type");
63 expect(node.operator).toBe(":");
64 expect(node.value).toEqual({ kind: "string", value: "creature" });
65 }
66 });
67
68 it("parses oracle field", () => {
69 const node = expectParse("o:flying");
70 expect(node.type).toBe("FIELD");
71 if (node.type === "FIELD") {
72 expect(node.field).toBe("oracle");
73 }
74 });
75
76 it("parses color field as colors", () => {
77 const node = expectParse("c:urg");
78 expect(node.type).toBe("FIELD");
79 if (node.type === "FIELD") {
80 expect(node.field).toBe("color");
81 expect(node.value.kind).toBe("colors");
82 if (node.value.kind === "colors") {
83 expect(node.value.colors).toEqual(new Set(["U", "R", "G"]));
84 }
85 }
86 });
87
88 it("parses identity field", () => {
89 const node = expectParse("id<=bg");
90 expect(node.type).toBe("FIELD");
91 if (node.type === "FIELD") {
92 expect(node.field).toBe("identity");
93 expect(node.operator).toBe("<=");
94 if (node.value.kind === "colors") {
95 expect(node.value.colors).toEqual(new Set(["B", "G"]));
96 }
97 }
98 });
99
100 it("parses ci: as identity alias", () => {
101 const node = expectParse("ci:wubrg");
102 expect(node.type).toBe("FIELD");
103 if (node.type === "FIELD") {
104 expect(node.field).toBe("identity");
105 }
106 });
107
108 it("parses numeric fields", () => {
109 const node = expectParse("cmc>=3");
110 expect(node.type).toBe("FIELD");
111 if (node.type === "FIELD") {
112 expect(node.field).toBe("manavalue");
113 expect(node.operator).toBe(">=");
114 expect(node.value).toEqual({ kind: "number", value: 3 });
115 }
116 });
117
118 it("parses power with star", () => {
119 const node = expectParse("pow=*");
120 expect(node.type).toBe("FIELD");
121 if (node.type === "FIELD") {
122 expect(node.value).toEqual({ kind: "string", value: "*" });
123 }
124 });
125
126 it("parses regex in field", () => {
127 const node = expectParse("o:/draw.*card/");
128 expect(node.type).toBe("FIELD");
129 if (node.type === "FIELD") {
130 expect(node.value.kind).toBe("regex");
131 }
132 });
133
134 it("parses format field", () => {
135 const node = expectParse("f:commander");
136 expect(node.type).toBe("FIELD");
137 if (node.type === "FIELD") {
138 expect(node.field).toBe("format");
139 expect(node.value).toEqual({ kind: "string", value: "commander" });
140 }
141 });
142 });
143
144 describe("boolean operators", () => {
145 it("parses implicit AND", () => {
146 const node = expectParse("t:creature c:g");
147 expect(node.type).toBe("AND");
148 if (node.type === "AND") {
149 expect(node.children).toHaveLength(2);
150 }
151 });
152
153 it("parses explicit OR", () => {
154 const node = expectParse("t:creature or t:artifact");
155 expect(node.type).toBe("OR");
156 if (node.type === "OR") {
157 expect(node.children).toHaveLength(2);
158 }
159 });
160
161 it("parses NOT", () => {
162 const node = expectParse("-t:creature");
163 expect(node.type).toBe("NOT");
164 if (node.type === "NOT") {
165 expect(node.child.type).toBe("FIELD");
166 }
167 });
168
169 it("parses parentheses", () => {
170 const node = expectParse("(t:creature or t:artifact) c:r");
171 expect(node.type).toBe("AND");
172 if (node.type === "AND") {
173 expect(node.children[0].type).toBe("OR");
174 }
175 });
176
177 it("NOT binds tighter than AND", () => {
178 const node = expectParse("-t:creature c:g");
179 expect(node.type).toBe("AND");
180 if (node.type === "AND") {
181 expect(node.children[0].type).toBe("NOT");
182 }
183 });
184
185 it("AND binds tighter than OR", () => {
186 const node = expectParse("a b or c d");
187 expect(node.type).toBe("OR");
188 if (node.type === "OR") {
189 expect(node.children).toHaveLength(2);
190 expect(node.children[0].type).toBe("AND");
191 expect(node.children[1].type).toBe("AND");
192 }
193 });
194 });
195
196 describe("complex queries", () => {
197 it("parses commander deckbuilding query", () => {
198 const node = expectParse("id<=bg t:creature cmc<=3");
199 expect(node.type).toBe("AND");
200 if (node.type === "AND") {
201 expect(node.children).toHaveLength(3);
202 }
203 });
204
205 it("parses nested groups", () => {
206 const node = expectParse("((a or b) (c or d))");
207 expect(node.type).toBe("AND");
208 });
209
210 it("parses word that looks like field but isnt", () => {
211 // "is" without : should be treated as name
212 const node = expectParse("is cool");
213 expect(node.type).toBe("AND");
214 if (node.type === "AND") {
215 expect(node.children[0].type).toBe("NAME");
216 expect(node.children[1].type).toBe("NAME");
217 }
218 });
219 });
220
221 describe("error handling", () => {
222 it("errors on empty query", () => {
223 const msg = expectParseError("");
224 expect(msg).toContain("Empty");
225 });
226
227 it("errors on unmatched paren", () => {
228 const msg = expectParseError("(foo");
229 expect(msg).toContain("parenthesis");
230 });
231
232 it("errors on trailing garbage", () => {
233 const msg = expectParseError("foo )");
234 expect(msg).toContain("Unexpected");
235 });
236 });
237
238 describe("span tracking", () => {
239 it("tracks span for simple term", () => {
240 const node = expectParse("bolt");
241 expect(node.span).toEqual({ start: 0, end: 4 });
242 });
243
244 it("tracks span for field expression", () => {
245 const node = expectParse("t:creature");
246 expect(node.span).toEqual({ start: 0, end: 10 });
247 });
248
249 it("tracks span for AND expression", () => {
250 const node = expectParse("foo bar");
251 expect(node.span).toEqual({ start: 0, end: 7 });
252 });
253 });
254
255 describe("property tests", () => {
256 const wordArb = fc
257 .stringMatching(/^[a-zA-Z][a-zA-Z0-9]{0,8}$/)
258 .filter((w) => w.toLowerCase() !== "or");
259 const fieldArb = fc.constantFrom("t", "o", "c", "cmc", "pow");
260 const opArb = fc.constantFrom(":", "=", ">=", "<=", ">", "<");
261
262 it("parses any valid field expression", () => {
263 fc.assert(
264 fc.property(fieldArb, opArb, wordArb, (field, op, value) => {
265 const result = parse(`${field}${op}${value}`);
266 expect(result.ok).toBe(true);
267 }),
268 { numRuns: 500 },
269 );
270 });
271
272 it("parses any sequence of words", () => {
273 fc.assert(
274 fc.property(
275 fc.array(wordArb, { minLength: 1, maxLength: 5 }),
276 (words) => {
277 const result = parse(words.join(" "));
278 expect(result.ok).toBe(true);
279 },
280 ),
281 { numRuns: 500 },
282 );
283 });
284
285 it("parses OR combinations", () => {
286 fc.assert(
287 fc.property(wordArb, wordArb, (a, b) => {
288 const result = parse(`${a} or ${b}`);
289 expect(result.ok).toBe(true);
290 if (result.ok) {
291 expect(result.value.type).toBe("OR");
292 }
293 }),
294 { numRuns: 500 },
295 );
296 });
297
298 it("parses NOT expressions", () => {
299 fc.assert(
300 fc.property(wordArb, (word) => {
301 const result = parse(`-${word}`);
302 expect(result.ok).toBe(true);
303 if (result.ok) {
304 expect(result.value.type).toBe("NOT");
305 }
306 }),
307 { numRuns: 500 },
308 );
309 });
310
311 it("parses grouped expressions", () => {
312 fc.assert(
313 fc.property(wordArb, wordArb, (a, b) => {
314 const result = parse(`(${a} or ${b})`);
315 expect(result.ok).toBe(true);
316 }),
317 { numRuns: 500 },
318 );
319 });
320 });
321});