304 lines
9.4 KiB
TypeScript
304 lines
9.4 KiB
TypeScript
const NUMBER_RE = /\d+(?:\.\d+)?/;
|
|
|
|
function lookup_unit_table(unitTable: Record<string, number>, 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(`^(?<num>${numRe.source})\\s*(?<unit>${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<string, number> = {
|
|
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 = /^(?<num>\d+(?:\.\d+)?)(?<unit>[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 = /^(?:(?:(?<days>\d+):)?(?<hours>\d+):)?(?<mins>\d+):(?<secs>\d{1,2})(?<ms>[.:]\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<string, any>): boolean {
|
|
const STRING_OPERATORS: Record<string, (a: string, b: string) => 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<string, (a: any, b: any) => 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<string, (v: any) => 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(
|
|
`^(?<key>[a-z_]+)\\s*(?<negation>!\\s*)?(?<op>${cmpOps})(?<noneInclusive>\\s*\\?)?\\s*(?:(?<quote>["'])(?<quoted>.+?)(?:\\k<quote>)|(?<plain>.+?))?$`
|
|
);
|
|
|
|
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 = /^(?<op>!?)\s*(?<key>[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<string, any> = {}): boolean {
|
|
return new MatchFilterParser(expr).evaluate(dct);
|
|
}
|
|
|
|
evaluate(dct: Record<string, any>): 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) => `(?<T${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<string, any>): 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<string, any>): boolean => MatchFilterParser.run(expr, dct)
|
|
|
|
export { MatchFilterParser, match_str }
|