import type { FieldType, Group, Operator, Rule } from './types'; // Split a group body into top-level parts separated by a single connective, respecting quotes and // one level of parens. Returns the parts and the connective, or null if the split is malformed or the // connectives are mixed (mixed AND/OR at one level is out of subset). function splitTopLevel(input: string): { parts: string[]; match: 'all' | 'any' } | null { const parts: string[] = []; const ops: string[] = []; let depth = 0; let quoted = false; let token = ''; for (let i = 0; i < input.length; i++) { const ch = input[i]; if (ch === '\\') { token += ch + (input[i + 1] ?? ''); i++; continue; } // escape pair, anywhere if (ch === '"') { quoted = !quoted; token += ch; continue; } if (quoted) { token += ch; continue; } if (ch === '(') { depth++; token += ch; continue; } if (ch === ')') { depth--; token += ch; continue; } if (depth === 0 && input.startsWith(' AND ', i)) { parts.push(token); ops.push('AND'); token = ''; i += 4; continue; } if (depth === 0 && input.startsWith(' OR ', i)) { parts.push(token); ops.push('OR'); token = ''; i += 3; continue; } token += ch; } if (depth !== 0 || quoted) return null; parts.push(token); if (ops.length === 0) return { parts, match: 'all' }; const allAnd = ops.every((o) => o === 'AND'); const allOr = ops.every((o) => o === 'OR'); if (!allAnd && !allOr) return null; // mixed connectives return { parts, match: allAnd ? 'all' : 'any' }; } // Reverse of compile's date normalization: the index format yyyyMMdd back to the input's yyyy-MM-dd. function fromIndexDate(value: string): string { return /^\d{8}$/.test(value) ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}` : value; } function unquote(value: string): string { return value.replace(/\\(["\\])/g, '$1'); } // A trailing '*' is an operator wildcard iff it is not escaped — i.e. preceded by an even run of backslashes. function endsWithOperatorStar(s: string): boolean { if (!s.endsWith('*')) return false; let backslashes = 0; let i = s.length - 2; while (i >= 0 && s[i] === '\\') { backslashes++; i--; } return backslashes % 2 === 0; } function parseAtom(atom: string, fieldTypes: Record): Rule | null { const trimmed = atom.trim(); const negate = trimmed.startsWith('NOT '); const body = negate ? trimmed.slice(4).trim() : trimmed; const colon = body.indexOf(':'); if (colon < 0) return null; const field = body.slice(0, colon); const raw = body.slice(colon + 1); const type = fieldTypes[field]; if (!type) return null; // Ranges: {a TO b} (exclusive) and [a TO b] (inclusive between) const range = raw.match(/^([[{])(\S+) TO (\S+)([\]}])$/); if (range) { const [, , lo, hi, close] = range; if (type !== 'number' && type !== 'date') return null; const fmt = (x: string) => (type === 'date' ? fromIndexDate(x) : x); if (close === ']') return mk(field, 'between', fmt(lo), fmt(hi)); if (lo === '*') return mk(field, type === 'date' ? 'before' : 'lt', fmt(hi)); if (hi === '*') return mk(field, type === 'date' ? 'after' : 'gt', fmt(lo)); return null; } // Quoted phrase → is/isNot (text, enum) or matches/notMatches (fulltext) by field type if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) { const v = unquote(raw.slice(1, -1)); if (type === 'fulltext') return mk(field, negate ? 'notMatches' : 'matches', v); if (type === 'text' || type === 'enum') return mk(field, negate ? 'isNot' : 'is', v); return null; } if (negate) return null; // NOT only valid with quoted forms above // Wildcards (text only): *v* → contains, v* → startsWith. The operator stars are the // unescaped boundary stars; any literal * in the value was escaped to \* by the compiler. if (type === 'text' && endsWithOperatorStar(raw)) { if (raw.startsWith('*') && raw.length >= 2) { return mk(field, 'contains', unescapeWild(raw.slice(1, -1))); } if (raw[0] !== '*') { return mk(field, 'startsWith', unescapeWild(raw.slice(0, -1))); } return null; } // Bare token → numeric eq only (reject fuzzy ~, boosts ^, etc.) if (type === 'number' && /^[0-9.]+$/.test(raw)) return mk(field, 'eq', raw); return null; } function unescapeWild(value: string): string { return value.replace(/\\([\s\S])/g, '$1'); } function mk(field: string, operator: Operator, value: string, value2?: string): Rule { return value2 === undefined ? { field, operator, value } : { field, operator, value, value2 }; } function parseGroup(input: string, fieldTypes: Record, allowNested: boolean): Group | null { const split = splitTopLevel(input.trim()); if (!split) return null; const children: Array = []; for (const part of split.parts) { const p = part.trim(); if (p.startsWith('(') && p.endsWith(')')) { if (!allowNested) return null; // deeper than one level const inner = parseGroup(p.slice(1, -1), fieldTypes, false); if (!inner) return null; children.push(inner); } else { const rule = parseAtom(p, fieldTypes); if (!rule) return null; children.push(rule); } } if (children.length === 0) return null; return { match: split.match, children }; } export function parse(input: string, fieldTypes: Record): Group | null { if (!input.trim()) return null; return parseGroup(input, fieldTypes, true); }