Files
UHD_Media_Tool/packages/domain/src/rules/expr.ts
T
rootandClaude Opus 5.5 e994a7bcfa feat(domain): rule groups — MP rule-string parser/serializer, builtin keywords, chain evaluation wired into slot
- rules/: RuleExpr tree, '>' layers with ! & | and parens, position-aware errors
  (unknown atom with suggestions, unsupported music atoms, parens, empty layer)
- builtin keyword table aligned with MP BUILTIN_RULE_SET; field-based judgement
  where parsed fields exist, MP regex otherwise; custom atoms (MP 7 fields)
- chain evaluation: hard excludes, ladder pri 100-k, groups AND-ed, pri from last
  laddered group, readable per-atom trace
- slot: SlotSpec.rules, rule.hard_exclude / rule.no_layer_matched violations,
  pri basis switches to priority when a rule ladder is present
- differential: 66 rule chains x 32 torrents vs MP python path (parse 64/64,
  pri 2090/2112, 22 registered mp_bug)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 14:50:43 +08:00

279 lines
9.2 KiB
TypeScript

import { BUILTIN_ATOM_MAP } from "./atoms.ts";
import type { RuleExpr, RuleLayer, RuleParseError } from "./types.ts";
// MP 规则串 ↔ 结构化层。语法对齐 MP RuleParser(pyparsing infix_notation):
// 规则串 = 层 ('>' 层)*;层内优先级 ! > & > |,可加括号;原子 = 字母开头的字母数字串,或数字+字母(4K、60FPS)。
// 偏离:MP 对未知原子视为不匹配、对多余 token 静默截断;我们在解析时报错并给位置(allowUnknown 时保留未知原子)。
export interface RuleParseOptions {
/** 自定义条件 id(大写);不在内置表也不在这里的原子报 unknown_atom */
customAtoms?: Iterable<string>;
/** 保留未知原子(求值时视为不匹配,对齐 MP);缺省 false 报错 */
allowUnknown?: boolean;
}
export interface RuleParseResult {
layers: RuleLayer[];
errors: RuleParseError[];
}
type Tok = { t: "atom" | "(" | ")" | "!" | "&" | "|" | "bad"; v: string; pos: number };
function lex(s: string, base: number): Tok[] {
const out: Tok[] = [];
let i = 0;
while (i < s.length) {
const ch = s[i]!;
if (/\s/.test(ch)) {
i++;
continue;
}
if ("()!&|".includes(ch)) {
out.push({ t: ch as Tok["t"], v: ch, pos: base + i });
i++;
continue;
}
const m = /^[A-Za-z0-9]+/.exec(s.slice(i));
if (m) {
out.push({ t: "atom", v: m[0], pos: base + i });
i += m[0].length;
continue;
}
out.push({ t: "bad", v: ch, pos: base + i });
i++;
}
return out;
}
function levenshtein(a: string, b: string): number {
const dp = Array.from({ length: b.length + 1 }, (_, j) => j);
for (let i = 1; i <= a.length; i++) {
let prev = dp[0]!;
dp[0] = i;
for (let j = 1; j <= b.length; j++) {
const tmp = dp[j]!;
dp[j] = Math.min(dp[j]! + 1, dp[j - 1]! + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
prev = tmp;
}
}
return dp[b.length]!;
}
/** 相近原子名(给 unknown_atom 提示) */
export function suggestAtoms(id: string, custom: Iterable<string> = []): string[] {
const all = [...[...BUILTIN_ATOM_MAP.values()].filter((a) => a.supported).map((a) => a.id), ...custom];
return all
.map((x) => ({ x, d: levenshtein(id, x) }))
.filter((e) => e.d <= Math.max(1, Math.floor(id.length / 2)) || e.x.startsWith(id) || id.startsWith(e.x))
.sort((a, b) => a.d - b.d)
.slice(0, 3)
.map((e) => e.x);
}
const ATOM_SHAPE = /^(?:[A-Za-z][A-Za-z0-9]*|[0-9]+[A-Za-z][A-Za-z0-9]*)$/;
class LayerParser {
i = 0;
constructor(
readonly toks: Tok[],
readonly layer: number,
readonly errors: RuleParseError[],
readonly custom: Set<string>,
readonly opts: RuleParseOptions,
) {}
peek() {
return this.toks[this.i];
}
err(code: RuleParseError["code"], message: string, pos: number, extra: Partial<RuleParseError> = {}) {
this.errors.push({ code, message, position: pos, layer: this.layer, ...extra });
}
or(): RuleExpr | null {
const first = this.and();
if (!first) return null;
const args = [first];
while (this.peek()?.t === "|") {
this.i++;
const n = this.and();
if (!n) return null;
args.push(n);
}
return args.length === 1 ? first : flatten({ op: "or", args });
}
and(): RuleExpr | null {
const first = this.unary();
if (!first) return null;
const args = [first];
while (this.peek()?.t === "&") {
this.i++;
const n = this.unary();
if (!n) return null;
args.push(n);
}
return args.length === 1 ? first : flatten({ op: "and", args });
}
unary(): RuleExpr | null {
const t = this.peek();
if (t?.t === "!") {
this.i++;
const a = this.unary();
return a ? { op: "not", arg: a } : null;
}
return this.primary();
}
primary(): RuleExpr | null {
const t = this.peek();
if (!t) {
const last = this.toks[this.toks.length - 1];
this.err("unexpected_token", "表达式不完整,缺少条件", last ? last.pos + last.v.length : 0);
return null;
}
if (t.t === "(") {
this.i++;
const e = this.or();
if (!e) return null;
if (this.peek()?.t !== ")") {
this.err("unbalanced_paren", "括号不配对:缺少 )", t.pos);
return null;
}
this.i++;
return e;
}
if (t.t === "atom") {
this.i++;
if (!ATOM_SHAPE.test(t.v)) {
this.err("unexpected_token", `「${t.v}」不是合法条件名(字母开头,或数字加字母如 4K)`, t.pos);
return null;
}
const id = t.v.toUpperCase();
const b = BUILTIN_ATOM_MAP.get(id);
if (b && !b.supported)
this.err("unsupported_atom", `${id}(${b.name})不支持:音乐资源条件没有迁移`, t.pos, { atom: id });
else if (!b && !this.custom.has(id) && !this.opts.allowUnknown) {
const suggestions = suggestAtoms(id, this.custom);
this.err(
"unknown_atom",
`未知条件 ${id}${suggestions.length ? `,是不是 ${suggestions.join(" / ")}` : ""}`,
t.pos,
{ atom: id, suggestions },
);
}
return { op: "atom", id };
}
if (t.t === ")") this.err("unbalanced_paren", "括号不配对:多余的 )", t.pos);
else if (t.t === "bad") this.err("invalid_char", `不认识的字符「${t.v}」`, t.pos);
else this.err("unexpected_token", `「${t.v}」前缺少条件`, t.pos);
return null;
}
}
function flatten(e: RuleExpr): RuleExpr {
if (e.op !== "and" && e.op !== "or") return e;
const args: RuleExpr[] = [];
for (const a of e.args) {
const f = flatten(a);
if (f.op === e.op) args.push(...f.args);
else args.push(f);
}
return { op: e.op, args };
}
/** 规范化:同类 AND/OR 展平、原子大写(结构等价比较与指纹用) */
export function normalizeExpr(e: RuleExpr): RuleExpr {
switch (e.op) {
case "atom":
return { op: "atom", id: e.id.toUpperCase() };
case "not":
return { op: "not", arg: normalizeExpr(e.arg) };
default:
return flatten({ op: e.op, args: e.args.map(normalizeExpr) });
}
}
/** 解析一层表达式(不含 `>`);offset 用于报错位置 */
export function parseExpr(
s: string,
opts: RuleParseOptions = {},
layer = 0,
offset = 0,
): { expr: RuleExpr | null; errors: RuleParseError[] } {
const errors: RuleParseError[] = [];
const toks = lex(s, offset);
if (toks.length === 0) {
errors.push({ code: "empty_layer", message: `第 ${layer + 1} 层是空的`, position: offset, layer });
return { expr: null, errors };
}
const p = new LayerParser(
toks,
layer,
errors,
new Set([...(opts.customAtoms ?? [])].map((x) => x.toUpperCase())),
opts,
);
const expr = p.or();
if (expr && p.i < toks.length && errors.length === 0) {
const t = toks[p.i]!;
if (t.t === ")") p.err("unbalanced_paren", "括号不配对:多余的 )", t.pos);
else if (t.t === "bad") p.err("invalid_char", `不认识的字符「${t.v}」`, t.pos);
else p.err("unexpected_token", `「${t.v}」前缺少 & 或 |`, t.pos);
}
return { expr: errors.length ? null : expr, errors };
}
/** 规则串 → 结构化层(`>` 分层,第 1 层 pri=100) */
export function parseRuleString(s: string, opts: RuleParseOptions = {}): RuleParseResult {
const errors: RuleParseError[] = [];
const layers: RuleLayer[] = [];
if (!s.trim()) return { layers, errors: [{ code: "empty", message: "规则串是空的", position: 0, layer: 0 }] };
let offset = 0;
s.split(">").forEach((part, k) => {
const r = parseExpr(part, opts, k, offset);
errors.push(...r.errors);
if (r.expr) layers.push({ expr: r.expr });
offset += part.length + 1;
});
return { layers: errors.length ? [] : layers, errors };
}
const PREC = { or: 1, and: 2, not: 3, atom: 4 } as const;
/** 表达式 → 规则串(原子大写,只加必要的括号) */
export function serializeExpr(e: RuleExpr, parent = 0): string {
switch (e.op) {
case "atom":
return e.id.toUpperCase();
case "not":
return `!${serializeExpr(e.arg, PREC.not)}`;
case "and": {
const s = e.args.map((a) => serializeExpr(a, PREC.and)).join(" & ");
return parent > PREC.and ? `(${s})` : s;
}
case "or": {
const s = e.args.map((a) => serializeExpr(a, PREC.or)).join(" | ");
return parent > PREC.or ? `(${s})` : s;
}
}
}
/** 结构化层 → 规则串 */
export function serializeLayers(layers: readonly RuleLayer[]): string {
return layers.map((l) => serializeExpr(l.expr)).join(" > ");
}
/** 表达式引用到的原子 id(去重) */
export function atomsOf(e: RuleExpr, out = new Set<string>()): Set<string> {
if (e.op === "atom") out.add(e.id.toUpperCase());
else if (e.op === "not") atomsOf(e.arg, out);
else for (const a of e.args) atomsOf(a, out);
return out;
}
/** MP pyparsing as_list() 形状 → RuleExpr(差分测试用):"A" | ["not", X] | [X, "and", Y, "and", Z] */
export function fromMpParseList(v: unknown): RuleExpr {
if (typeof v === "string") return { op: "atom", id: v.toUpperCase() };
const a = v as unknown[];
if (a.length === 1) return fromMpParseList(a[0]);
if (a[0] === "not") return { op: "not", arg: fromMpParseList(a.length === 2 ? a[1] : a.slice(1)) };
const op = a[1] as "and" | "or";
return flatten({ op, args: a.filter((_, i) => i % 2 === 0).map(fromMpParseList) });
}