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]>
This commit is contained in:
1 parent
22856d0447
commit
e994a7bcfa
13 files changed
+23919
-10
No files matched your search
@@ -0,0 +1,278 @@
|
||||
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) });
|
||||
}
|
||||
Reference in new issue
Block a user