const NUMBER_RE = /\d+(?:\.\d+)?/; function lookup_unit_table( unitTable: Record, s: string, strict = false, ): number | null { const numRe = strict ? NUMBER_RE : /\d+[,.]?\d*/; const unitsRe = Object.keys(unitTable) .map((u) => u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) .join('|'); const re = new RegExp(`^(?${numRe.source})\\s*(?${unitsRe})\\b`, strict ? '' : 'i'); const m = s.match(re); if (!m?.groups?.num || !m.groups.unit) { return null; } const numStr = m.groups.num.replace(',', '.'); const unit = m.groups.unit; const mult = unitTable[unit]; if (undefined === mult) return null; return Math.round(parseFloat(numStr) * mult); } function parse_filesize(s: string | null): number | null { if (!s) return null; const UNIT_TABLE: Record = { B: 1, b: 1, bytes: 1, KiB: 1024, KB: 1000, kB: 1024, kb: 1000, kilobytes: 1000, kibibytes: 1024, MiB: 1024 ** 2, MB: 1000 ** 2, megabytes: 1000 ** 2, mebibytes: 1024 ** 2, GiB: 1024 ** 3, GB: 1000 ** 3, gigabytes: 1000 ** 3, gibibytes: 1024 ** 3, TiB: 1024 ** 4, TB: 1000 ** 4, terabytes: 1000 ** 4, tebibytes: 1024 ** 4, PiB: 1024 ** 5, PB: 1000 ** 5, petabytes: 1000 ** 5, pebibytes: 1024 ** 5, }; return lookup_unit_table(UNIT_TABLE, s); } function parse_duration(s: string): number | null { if (!s?.trim()) return null; // Support ISO-ish short forms: "2m", "10s", "1h", "1d" const simpleRe = /^(?\d+(?:\.\d+)?)(?[smhd])$/i; const sm = s.match(simpleRe); if (sm?.groups?.num && sm.groups.unit) { const num = parseFloat(sm.groups.num); switch (sm.groups.unit.toLowerCase()) { case 's': return num; case 'm': return num * 60; case 'h': return num * 3600; case 'd': return num * 86400; } } // Support hh:mm:ss[.ms] const hmsRe = /^(?:(?:(?\d+):)?(?\d+):)?(?\d+):(?\d{1,2})(?[.:]\d+)?Z?$/; const m = s.match(hmsRe); if (m?.groups) { const { days, hours, mins, secs, ms } = m.groups; return ( parseFloat(days ?? '0') * 86400 + parseFloat(hours ?? '0') * 3600 + parseFloat(mins ?? '0') * 60 + parseFloat(secs ?? '0') + parseFloat((ms ?? '0').replace(':', '.')) ); } return null; } // ---------- Match One ---------- function _match_one(filterPart: string, dct: Record): boolean { const STRING_OPERATORS: Record boolean> = { '*=': (a, b) => a.includes(b), '^=': (a, b) => a.startsWith(b), '$=': (a, b) => a.endsWith(b), '~=': (a, b) => new RegExp(b).test(a), }; const COMPARISON_OPERATORS: Record boolean> = { '*=': STRING_OPERATORS['*=']!, '^=': STRING_OPERATORS['^=']!, '$=': STRING_OPERATORS['$=']!, '~=': STRING_OPERATORS['~=']!, '<=': (a, b) => a <= b, '<': (a, b) => a < b, '>=': (a, b) => a >= b, '>': (a, b) => a > b, '=': (a, b) => a == b, }; const UNARY_OPERATORS: Record boolean> = { '': (v) => (typeof v === 'boolean' ? v === true : v != null), '!': (v) => (typeof v === 'boolean' ? v === false : v == null), }; const cmpOps = Object.keys(COMPARISON_OPERATORS) .map((op) => op.replace(/([.*+?^${}()|[\]\\])/g, '\\$1')) .join('|'); const operatorRe = new RegExp( `^(?[a-z_]+)\\s*(?!\\s*)?(?${cmpOps})(?\\s*\\?)?\\s*(?:(?["'])(?.+?)(?:\\k)|(?.+?))?$`, ); const m = filterPart.trim().match(operatorRe); if (m?.groups) { const { key, negation, op, quote, quoted, plain } = m.groups; const unnegatedOp = COMPARISON_OPERATORS[op!]; if (!unnegatedOp) throw new Error(`Invalid operator '${op}'`); // Handle incomplete expressions (missing value) const value = quoted ?? plain ?? ''; if (!value && op !== '=' && op !== '!') { return false; // Incomplete non-equality expression } const opFn = negation ? (a: any, b: any) => !unnegatedOp(a, b) : unnegatedOp; let processedValue = value; if (quote) processedValue = processedValue.replace(new RegExp(`\\\\${quote}`, 'g'), quote); const actual = dct[key!]; if (actual === undefined || actual === null) { return false; } let numeric: number | null = null; // Try to convert comparison value to numeric first numeric = Number(processedValue); if (Number.isNaN(numeric)) { numeric = parse_filesize(processedValue) ?? parse_filesize(`${processedValue}B`) ?? parse_duration(processedValue); } // Also try to convert actual value to numeric if we have a numeric comparison let finalActual = actual; if (numeric !== null && typeof actual === 'string') { const numericActual = Number(actual); if (!Number.isNaN(numericActual)) { finalActual = numericActual; } else { const parsedActual = parse_filesize(actual) ?? parse_duration(actual); if (parsedActual !== null) { finalActual = parsedActual; } } } if (numeric !== null && op! in STRING_OPERATORS) { throw new Error(`Operator ${op} only supports string values!`); } return opFn(finalActual, numeric !== null ? numeric : processedValue); } // unary operator const unaryRe = /^(?!?)\s*(?[a-z_]+)$/; const mu = filterPart.trim().match(unaryRe); if (mu?.groups) { const { op, key } = mu.groups; const actual = dct[key!]; return UNARY_OPERATORS[op!]!(actual); } throw new Error(`Invalid filter part '${filterPart}'`); } // ---------- Parser ---------- type Node = ['ATOM', string] | ['AND', Node, Node] | ['OR', Node, Node]; class MatchFilterParser { expr: string; tokens: [string, string][]; pos: number; ast: Node; constructor(expr: string) { this.expr = expr; this.tokens = this._tokenize(expr); this.pos = 0; this.ast = this._parseOr(); } static run(expr: string, dct: Record = {}): boolean { return new MatchFilterParser(expr).evaluate(dct); } evaluate(dct: Record): boolean { return this._eval(this.ast, dct); } export(): string[] { return this._export(this.ast).map((parts) => parts.join('&')); } private _tokenize(expr: string): [string, string][] { // First, let's normalize spaces around operators to make parsing easier // Replace spaced operators with non-spaced ones let normalizedExpr = expr; const operators = ['<=', '>=', '<', '>', '\\*=', '\\^=', '\\$=', '~=', '=']; for (const op of operators) { // Replace "key op value" with "keyopvalue" (removing extra spaces) const pattern = new RegExp(`([a-z_]+)\\s*(!?)\\s*(${op})\\s*`, 'g'); normalizedExpr = normalizedExpr.replace(pattern, '$1$2$3'); } const tokenSpec: [RegExp, string | null][] = [ [/\|\|/, 'OR'], [/&/, 'AND'], [/\(/, 'LPAREN'], [/\)/, 'RPAREN'], [/[^\s&|()]+/, 'ATOM'], [/\s+/, null], ]; const regex = new RegExp( tokenSpec.map(([pat, _name], i) => `(?${pat.source})`).join('|'), 'g', ); const tokens: [string, string][] = []; let m: RegExpExecArray | null; while ((m = regex.exec(normalizedExpr)) !== null) { for (let i = 0; i < tokenSpec.length; i++) { if (m.groups?.[`T${i}`]) { const tokenSpecEntry = tokenSpec[i]; if (tokenSpecEntry) { const kind = tokenSpecEntry[1]; if (kind) tokens.push([kind, m[0]]); } break; } } } return tokens; } private _accept(kind: string): boolean { if (this.pos < this.tokens.length && this.tokens[this.pos]?.[0] === kind) { this.pos++; return true; } return false; } private _expect(kind: string): [string, string] { if (this.pos < this.tokens.length && this.tokens[this.pos]?.[0] === kind) { return this.tokens[this.pos++]!; } throw new SyntaxError(`Expected ${kind}`); } private _parseOr(): Node { let left = this._parseAnd(); while (this._accept('OR')) { const right = this._parseAnd(); left = ['OR', left, right]; } return left; } private _parseAnd(): Node { let left = this._parseAtom(); while (this._accept('AND')) { const right = this._parseAtom(); left = ['AND', left, right]; } return left; } private _parseAtom(): Node { if (this._accept('LPAREN')) { const node = this._parseOr(); this._expect('RPAREN'); return node; } const tok = this._expect('ATOM'); return ['ATOM', tok[1].trim()]; } private _eval(node: Node, dct: Record): boolean { if (node[0] === 'ATOM') { return _match_one(node[1], dct); } if (node[0] === 'AND') { return this._eval(node[1], dct) && this._eval(node[2], dct); } if (node[0] === 'OR') { return this._eval(node[1], dct) || this._eval(node[2], dct); } throw new Error('Invalid AST node ' + node[0]); } private _export(node: Node): string[][] { if (node[0] === 'ATOM') return [[node[1]]]; if (node[0] === 'AND') { const left = this._export(node[1]); const right = this._export(node[2]); return left.flatMap((l) => right.map((r) => [...l, ...r])); } if (node[0] === 'OR') { return [...this._export(node[1]), ...this._export(node[2])]; } throw new Error('Invalid AST node ' + node[0]); } } const match_str = (expr: string, dct: Record): boolean => MatchFilterParser.run(expr, dct); export { MatchFilterParser, match_str };