// v0.2 statute-aware rules, written from mas-v0.2-spec/SPECIFICATION.md: // stand-ins (6.3), equivalence (8), destination transforms (12.1), length (13). // Pure functions over a statute document; no I/O, no schema validation. import { parseLocalPart, render, MasSyntaxError, MAX_OCTETS, octets } from "./parser.mjs"; export class Statute { /** @param {object} doc a statute document (already validated by its publisher) */ constructor(doc) { this.namespace = doc.namespace; this.version = doc.mas_version; const v2 = this.version === "0.2"; this.p2 = Boolean(doc.profiles?.p2); this.resourceTypes = new Set(Object.keys(doc.resource_types ?? {})); this.modes = new Set(Object.entries(doc.interaction_modes ?? {}) .filter(([, m]) => m.enabled !== false).map(([k]) => k)); this.localPartCase = doc.equivalence?.local_part_case ?? "exact"; this.resourceIdCase = v2 ? doc.equivalence?.resource_id_case ?? "exact" : "exact"; this.altResource = v2 && Boolean(doc.alternate_syntax?.resource); this.altInteraction = v2 && Boolean(doc.alternate_syntax?.interaction); } } /** * Read a received local-part under a statute (6.3 matching order). * @returns {{canonical: string, stand_ins: string[], parsed: object}} */ export function interpret(local, statute) { let rest = local; const standIns = []; // 1. Interaction: no "!" yet, and the local-part ends in ---MODE, MODE enabled. if (statute.altInteraction && statute.p2 && !rest.includes("!")) { const at = rest.lastIndexOf("---"); if (at > 0) { const mode = rest.slice(at + 3); if (statute.modes.has(mode)) { rest = rest.slice(0, at) + "!" + mode; standIns.push("interaction"); } } } let parsed = parseLocalPart(rest, "p2"); // 2. Resource: the remaining target is TYPE--ID with TYPE declared and ID not // beginning with "-"; split at the first "--". if (statute.altResource && parsed.target_kind === "opaque") { const at = parsed.target.indexOf("--"); const id = parsed.target.slice(at + 2); if (at > 0 && statute.resourceTypes.has(parsed.target.slice(0, at)) && id && !id.startsWith("-")) { const target = parsed.target.slice(0, at) + "=" + parsed.target.slice(at + 2); parsed = parseLocalPart(render(target, parsed.facet, parsed.interaction), "p2"); standIns.push("resource"); } } return { canonical: render(parsed.target, parsed.facet, parsed.interaction), stand_ins: standIns, parsed }; } /** What a local-part names (8): the target under the statute's case rules. */ export function targetKey(parsed, statute) { const fold = statute.localPartCase === "ascii-insensitive"; if (parsed.target_kind === "resource") { const id = fold || statute.resourceIdCase === "insensitive" ? parsed.resource_id.toLowerCase() : parsed.resource_id; return `${parsed.resource_type}=${id}`; } return fold ? parsed.target.toLowerCase() : parsed.target; } export function equivalent(a, b, statute) { return targetKey(interpret(a, statute).parsed, statute) === targetKey(interpret(b, statute).parsed, statute); } export class CannotExpress extends Error { constructor(reason) { super(reason); this.reason = reason; } } const TRANSFORMS = ["drop-facet", "drop-interaction", "resource-alternate", "interaction-alternate", "lowercase"]; const LOSSY = new Set(["drop-facet", "drop-interaction"]); /** * Write a canonical local-part for a destination that needs `needs` (12.1). * Transforms apply only where they change something; lossy ones only on * explicit request. Refuses (CannotExpress) rather than emit a form the * namespace would read differently, or one over 64 octets (13). */ export function forDestination(local, statute, needs = [], { allowLossy = false } = {}) { for (const n of needs) if (!TRANSFORMS.includes(n)) throw new Error(`unknown transform ${n}`); if (needs.some((n) => LOSSY.has(n)) && !allowLossy) throw new CannotExpress("lossy transform not requested"); const p = parseLocalPart(local, "p2"); let facet = p.facet; let interaction = p.interaction; if (needs.includes("drop-facet")) facet = null; if (needs.includes("drop-interaction")) interaction = null; const want = { key: targetKey(p, statute), facet, interaction }; let target = p.target; if (needs.includes("resource-alternate") && p.target_kind === "resource") { if (!statute.altResource) throw new CannotExpress("statute does not enable the resource stand-in"); target = `${p.resource_type}--${p.resource_id}`; } let text = target + (facet !== null ? "+" + facet : ""); if (interaction !== null) { if (needs.includes("interaction-alternate")) { if (!statute.altInteraction || !statute.modes.has(interaction)) { throw new CannotExpress("statute does not enable the interaction stand-in for this mode"); } text += "---" + interaction; } else { text += "!" + interaction; } } if (needs.includes("lowercase") && text !== text.toLowerCase()) { if (statute.resourceIdCase !== "insensitive") throw new CannotExpress("statute does not make identifiers case-insensitive"); text = text.toLowerCase(); } if (octets(text) > MAX_OCTETS) throw new CannotExpress(`${octets(text)} octets after transforms`); let got; try { got = interpret(text, statute).parsed; } catch (e) { if (e instanceof MasSyntaxError) throw new CannotExpress(`not parseable: ${e.code}`); throw e; } if (targetKey(got, statute) !== want.key || got.facet !== want.facet || got.interaction !== want.interaction) { throw new CannotExpress(`${text} would be read as ${render(got.target, got.facet, got.interaction)}`); } return text; }