lib/expr.ts
11 KB · sha256:1361ee70f4e7f0e5d5a0e024bbf1b753537a2af945ecfa32694dc7f8db405002
export type ExprValue = number | string | boolean | null;
export type ExprNode =
| { kind: "literal"; value: ExprValue }
| { kind: "placeholder"; key: string }
| { kind: "and"; children: ExprNode[] }
| { kind: "or"; children: ExprNode[] }
| { kind: "not"; child: ExprNode }
| { kind: "compare"; op: CompareOp; left: ExprNode; right: ExprNode };
export type CompareOp =
| "=="
| "!="
| "<"
| "<="
| ">"
| ">="
| "contains"
| "matches";
export class ExprParseError extends Error {
constructor(message: string, public readonly source: string, public readonly pos: number) {
super(`${message} at col ${pos + 1} of \`${source}\``);
}
}
export interface CompiledExpr {
source: string;
ast: ExprNode;
}
export function compileExpr(source: string): CompiledExpr {
const tokens = tokenize(source);
const parser = new Parser(tokens, source);
const ast = parser.parseExpr();
parser.expectEnd();
return { source, ast };
}
export type PlaceholderResolver = (key: string) => string | null;
export function evalExpr(expr: CompiledExpr, resolve: PlaceholderResolver): boolean {
return toBool(evalNode(expr.ast, resolve));
}
function evalNode(node: ExprNode, resolve: PlaceholderResolver): ExprValue {
switch (node.kind) {
case "literal":
return node.value;
case "placeholder": {
const raw = resolve(node.key);
return raw == null ? "" : raw;
}
case "and":
for (const c of node.children) if (!toBool(evalNode(c, resolve))) return false;
return true;
case "or":
for (const c of node.children) if (toBool(evalNode(c, resolve))) return true;
return false;
case "not":
return !toBool(evalNode(node.child, resolve));
case "compare":
return compare(node.op, evalNode(node.left, resolve), evalNode(node.right, resolve));
}
}
function toBool(v: ExprValue): boolean {
if (v == null) return false;
if (typeof v === "boolean") return v;
if (typeof v === "number") return v !== 0 && !Number.isNaN(v);
const s = String(v).trim().toLowerCase();
if (s === "" || s === "false" || s === "0" || s === "no" || s === "off") return false;
return true;
}
function compare(op: CompareOp, l: ExprValue, r: ExprValue): boolean {
if (op === "contains") return String(l ?? "").includes(String(r ?? ""));
if (op === "matches") {
try {
return new RegExp(String(r ?? "")).test(String(l ?? ""));
} catch {
return false;
}
}
const numericOnly = op === "<" || op === "<=" || op === ">" || op === ">=";
const ln = toNumber(l);
const rn = toNumber(r);
const numeric = numericOnly || (ln != null && rn != null);
if (numeric && ln != null && rn != null) {
switch (op) {
case "==": return ln === rn;
case "!=": return ln !== rn;
case "<": return ln < rn;
case "<=": return ln <= rn;
case ">": return ln > rn;
case ">=": return ln >= rn;
}
}
const ls = String(l ?? "");
const rs = String(r ?? "");
switch (op) {
case "==": return ls === rs;
case "!=": return ls !== rs;
case "<": return ls < rs;
case "<=": return ls <= rs;
case ">": return ls > rs;
case ">=": return ls >= rs;
}
}
function toNumber(v: ExprValue): number | null {
if (typeof v === "number") return Number.isFinite(v) ? v : null;
if (typeof v === "boolean") return v ? 1 : 0;
if (v == null) return null;
const cleaned = String(v).trim().replace(/[, _]/g, "");
if (cleaned === "") return null;
const n = Number(cleaned);
return Number.isFinite(n) ? n : null;
}
type Token =
| { kind: "lparen"; pos: number }
| { kind: "rparen"; pos: number }
| { kind: "and"; pos: number }
| { kind: "or"; pos: number }
| { kind: "not"; pos: number }
| { kind: "op"; value: CompareOp; pos: number }
| { kind: "placeholder"; key: string; pos: number }
| { kind: "number"; value: number; pos: number }
| { kind: "string"; value: string; pos: number }
| { kind: "boolean"; value: boolean; pos: number };
function tokenize(src: string): Token[] {
const out: Token[] = [];
let i = 0;
const len = src.length;
while (i < len) {
const ch = src[i];
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { i++; continue; }
if (ch === "(") { out.push({ kind: "lparen", pos: i }); i++; continue; }
if (ch === ")") { out.push({ kind: "rparen", pos: i }); i++; continue; }
if (ch === "{") {
const end = src.indexOf("}", i + 1);
if (end === -1) throw new ExprParseError("unterminated placeholder", src, i);
const key = src.slice(i + 1, end).trim();
if (!key) throw new ExprParseError("empty placeholder", src, i);
out.push({ kind: "placeholder", key, pos: i });
i = end + 1;
continue;
}
if (ch === '"' || ch === "'") {
const quote = ch;
let j = i + 1;
let buf = "";
while (j < len && src[j] !== quote) {
if (src[j] === "\\" && j + 1 < len) { buf += src[j + 1]; j += 2; continue; }
buf += src[j++];
}
if (j >= len) throw new ExprParseError("unterminated string", src, i);
out.push({ kind: "string", value: buf, pos: i });
i = j + 1;
continue;
}
if (ch === "=" && src[i + 1] === "=") { out.push({ kind: "op", value: "==", pos: i }); i += 2; continue; }
if (ch === "!" && src[i + 1] === "=") { out.push({ kind: "op", value: "!=", pos: i }); i += 2; continue; }
if (ch === "<" && src[i + 1] === "=") { out.push({ kind: "op", value: "<=", pos: i }); i += 2; continue; }
if (ch === ">" && src[i + 1] === "=") { out.push({ kind: "op", value: ">=", pos: i }); i += 2; continue; }
if (ch === "<") { out.push({ kind: "op", value: "<", pos: i }); i++; continue; }
if (ch === ">") { out.push({ kind: "op", value: ">", pos: i }); i++; continue; }
if (ch === "!") { out.push({ kind: "not", pos: i }); i++; continue; }
if ((ch >= "0" && ch <= "9") || (ch === "-" && /[0-9]/.test(src[i + 1] ?? ""))) {
let j = i + 1;
while (j < len && /[0-9_.,]/.test(src[j])) j++;
const raw = src.slice(i, j).replace(/[_,]/g, "");
const n = Number(raw);
if (!Number.isFinite(n)) throw new ExprParseError(`bad number "${raw}"`, src, i);
out.push({ kind: "number", value: n, pos: i });
i = j;
continue;
}
if (/[A-Za-z_]/.test(ch)) {
let j = i + 1;
while (j < len && /[A-Za-z0-9_]/.test(src[j])) j++;
const word = src.slice(i, j);
const lower = word.toLowerCase();
const pos = i;
i = j;
if (lower === "and" || lower === "&&") { out.push({ kind: "and", pos }); continue; }
if (lower === "or" || lower === "||") { out.push({ kind: "or", pos }); continue; }
if (lower === "not") { out.push({ kind: "not", pos }); continue; }
if (lower === "true") { out.push({ kind: "boolean", value: true, pos }); continue; }
if (lower === "false") { out.push({ kind: "boolean", value: false, pos }); continue; }
if (lower === "contains") { out.push({ kind: "op", value: "contains", pos }); continue; }
if (lower === "matches") { out.push({ kind: "op", value: "matches", pos }); continue; }
throw new ExprParseError(`unexpected identifier "${word}" (use {placeholder} for variables)`, src, pos);
}
if (ch === "&" && src[i + 1] === "&") { out.push({ kind: "and", pos: i }); i += 2; continue; }
if (ch === "|" && src[i + 1] === "|") { out.push({ kind: "or", pos: i }); i += 2; continue; }
throw new ExprParseError(`unexpected character "${ch}"`, src, i);
}
return out;
}
class Parser {
private i = 0;
constructor(private tokens: Token[], private src: string) {}
parseExpr(): ExprNode { return this.parseOr(); }
expectEnd(): void {
if (this.i < this.tokens.length) {
throw new ExprParseError("trailing tokens", this.src, this.tokens[this.i].pos);
}
}
private parseOr(): ExprNode {
let node = this.parseAnd();
const children: ExprNode[] = [node];
while (this.peek()?.kind === "or") { this.advance(); children.push(this.parseAnd()); }
return children.length === 1 ? node : { kind: "or", children };
}
private parseAnd(): ExprNode {
let node = this.parseUnary();
const children: ExprNode[] = [node];
while (this.peek()?.kind === "and") { this.advance(); children.push(this.parseUnary()); }
return children.length === 1 ? node : { kind: "and", children };
}
private parseUnary(): ExprNode {
if (this.peek()?.kind === "not") {
this.advance();
return { kind: "not", child: this.parseUnary() };
}
return this.parseComparison();
}
private parseComparison(): ExprNode {
const left = this.parseAtom();
const next = this.peek();
if (next?.kind === "op") {
this.advance();
const right = this.parseAtom();
return { kind: "compare", op: next.value, left, right };
}
return left;
}
private parseAtom(): ExprNode {
const tok = this.peek();
if (!tok) throw new ExprParseError("unexpected end of expression", this.src, this.src.length);
if (tok.kind === "lparen") {
this.advance();
const inner = this.parseExpr();
const close = this.peek();
if (close?.kind !== "rparen") throw new ExprParseError("expected `)`", this.src, close?.pos ?? this.src.length);
this.advance();
return inner;
}
if (tok.kind === "placeholder") { this.advance(); return { kind: "placeholder", key: tok.key }; }
if (tok.kind === "number") { this.advance(); return { kind: "literal", value: tok.value }; }
if (tok.kind === "string") { this.advance(); return { kind: "literal", value: tok.value }; }
if (tok.kind === "boolean") { this.advance(); return { kind: "literal", value: tok.value }; }
throw new ExprParseError(`unexpected token (${tok.kind})`, this.src, tok.pos);
}
private peek(): Token | undefined { return this.tokens[this.i]; }
private advance(): Token { return this.tokens[this.i++]; }
}
export function placeholdersOf(expr: CompiledExpr): string[] {
const keys = new Set<string>();
walk(expr.ast, (n) => { if (n.kind === "placeholder") keys.add(n.key); });
return [...keys];
}
function walk(node: ExprNode, visit: (n: ExprNode) => void): void {
visit(node);
if (node.kind === "and" || node.kind === "or") {
for (const c of node.children) walk(c, visit);
} else if (node.kind === "not") {
walk(node.child, visit);
} else if (node.kind === "compare") {
walk(node.left, visit);
walk(node.right, visit);
}
}
interface PlaceholderApiRef {
setPlaceholders(player: OfflinePlayer, text: string): string;
}
export function makePapiResolver(player: OfflinePlayer): PlaceholderResolver {
const papiRef = (globalThis as { papi?: { PlaceholderAPI?: PlaceholderApiRef } }).papi?.PlaceholderAPI;
if (!papiRef) return () => null;
return (key) => {
const wrapped = `%${key}%`;
try {
const out = papiRef.setPlaceholders(player, wrapped);
return out === wrapped ? null : out;
} catch {
return null;
}
};
}