- 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]>
279 lines
9.2 KiB
TypeScript
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) });
|
|
}
|