124 lines
4.6 KiB
TypeScript
124 lines
4.6 KiB
TypeScript
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 (quoted) {
|
|
token += ch;
|
|
if (ch === '"' && input[i - 1] !== '\\') quoted = false;
|
|
continue;
|
|
}
|
|
if (ch === '"') { quoted = true; 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' };
|
|
}
|
|
|
|
function unquote(value: string): string {
|
|
return value.replace(/\\(["\\])/g, '$1');
|
|
}
|
|
|
|
function parseAtom(atom: string, fieldTypes: Record<string, FieldType>): 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;
|
|
if (close === ']') return mk(field, 'between', lo, hi);
|
|
if (lo === '*') return mk(field, type === 'date' ? 'before' : 'lt', hi);
|
|
if (hi === '*') return mk(field, type === 'date' ? 'after' : 'gt', 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
|
|
if (raw.startsWith('*') && raw.endsWith('*') && raw.length >= 2) {
|
|
if (type !== 'text') return null;
|
|
return mk(field, 'contains', unescapeWild(raw.slice(1, -1)));
|
|
}
|
|
if (/^[^*]+\*$/.test(raw)) {
|
|
if (type !== 'text') return null;
|
|
return mk(field, 'startsWith', unescapeWild(raw.slice(0, -1)));
|
|
}
|
|
|
|
// 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(/\\([+\-!(){}[\]^"~:\\/])/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<string, FieldType>, allowNested: boolean): Group | null {
|
|
const split = splitTopLevel(input.trim());
|
|
if (!split) return null;
|
|
|
|
const children: Array<Rule | Group> = [];
|
|
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<string, FieldType>): Group | null {
|
|
if (!input.trim()) return null;
|
|
return parseGroup(input, fieldTypes, true);
|
|
}
|