mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 01:45:32 +00:00
483 lines
16 KiB
TypeScript
483 lines
16 KiB
TypeScript
import {
|
|
isAlias,
|
|
isMap,
|
|
isNode,
|
|
isScalar,
|
|
isSeq,
|
|
parseDocument,
|
|
Document,
|
|
Schema,
|
|
visit,
|
|
type ParsedNode,
|
|
type Node,
|
|
type ScalarTag,
|
|
type Tags,
|
|
} from "yaml";
|
|
|
|
const SOURCE_SCALAR_TAGS = new Set([
|
|
"tag:yaml.org,2002:int",
|
|
"tag:yaml.org,2002:float",
|
|
"tag:yaml.org,2002:timestamp",
|
|
]);
|
|
|
|
// Hermes's ruamel reader resolves dates even with an explicit YAML 1.2 directive.
|
|
const timestampTag = new Schema({ schema: "yaml-1.1" }).tags.find(
|
|
(tag): tag is ScalarTag =>
|
|
tag.tag === "tag:yaml.org,2002:timestamp" && tag.collection === undefined,
|
|
);
|
|
if (timestampTag === undefined)
|
|
throw new Error("The YAML 1.1 schema must provide its timestamp tag");
|
|
|
|
type HermesKey =
|
|
| { readonly kind: "numeric"; readonly value: bigint | number }
|
|
| { readonly kind: "timestamp"; readonly value: string }
|
|
| { readonly kind: "other"; readonly value: unknown };
|
|
|
|
const timestampSource =
|
|
/^(\d{4})-(\d{1,2})-(\d{1,2})(?:(?:[Tt]|[ \t]+)(\d{1,2}):(\d{2}):(\d{2})(?:\.([0-9]*))?(?:[ \t]*(Z|([-+])(\d{1,2})(?::(\d{2}))?))?)?$/;
|
|
|
|
const hermesTimestampImplicit =
|
|
/^(?:\d{4}-\d{2}-\d{2}|\d{4}-\d{1,2}-\d{1,2}(?:[Tt]|[ \t]+)\d{1,2}:\d{2}:\d{2}(?:\.[0-9]*)?(?:[ \t]*(?:Z|[-+]\d{1,2}(?::\d{2})?))?)$/;
|
|
|
|
interface HermesTimestampFacts {
|
|
readonly key: string;
|
|
readonly value: Date;
|
|
}
|
|
|
|
const floorDivide = (value: number, divisor: number): number =>
|
|
Math.floor(value / divisor);
|
|
|
|
/** Days since 1970-01-01 using the proleptic Gregorian calendar. */
|
|
const civilDay = (year: number, month: number, day: number): number => {
|
|
const adjustedYear = year - (month <= 2 ? 1 : 0);
|
|
const era = floorDivide(adjustedYear, 400);
|
|
const yearOfEra = adjustedYear - era * 400;
|
|
const adjustedMonth = month + (month > 2 ? -3 : 9);
|
|
const dayOfYear = floorDivide(153 * adjustedMonth + 2, 5) + day - 1;
|
|
const dayOfEra =
|
|
yearOfEra * 365 +
|
|
floorDivide(yearOfEra, 4) -
|
|
floorDivide(yearOfEra, 100) +
|
|
dayOfYear;
|
|
return era * 146097 + dayOfEra - 719468;
|
|
};
|
|
|
|
/** Parse the exact timestamp forms and values accepted by ruamel's safe loader. */
|
|
const parseHermesTimestamp = (
|
|
source: string,
|
|
): HermesTimestampFacts | undefined => {
|
|
const match = timestampSource.exec(source);
|
|
if (match === null) return undefined;
|
|
const [
|
|
,
|
|
yearText,
|
|
monthText,
|
|
dayText,
|
|
hourText,
|
|
minuteText,
|
|
secondText,
|
|
fraction,
|
|
zone,
|
|
sign,
|
|
zoneHourText,
|
|
zoneMinuteText,
|
|
] = match;
|
|
if (
|
|
yearText === undefined ||
|
|
monthText === undefined ||
|
|
dayText === undefined
|
|
)
|
|
return undefined;
|
|
const year = Number(yearText);
|
|
const month = Number(monthText);
|
|
const dayOfMonth = Number(dayText);
|
|
if (year < 1 || year > 9999 || month < 1 || month > 12) return undefined;
|
|
const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
const monthDays = [
|
|
31,
|
|
leapYear ? 29 : 28,
|
|
31,
|
|
30,
|
|
31,
|
|
30,
|
|
31,
|
|
31,
|
|
30,
|
|
31,
|
|
30,
|
|
31,
|
|
];
|
|
if (dayOfMonth < 1 || dayOfMonth > (monthDays[month - 1] ?? 0))
|
|
return undefined;
|
|
const date = `${yearText}-${monthText.padStart(2, "0")}-${dayText.padStart(2, "0")}`;
|
|
const day = civilDay(year, month, dayOfMonth);
|
|
if (hourText === undefined) {
|
|
const micros = BigInt(day) * 86_400_000_000n;
|
|
return { key: `date:${date}`, value: new Date(Number(micros / 1000n)) };
|
|
}
|
|
|
|
const hour = Number(hourText);
|
|
const minute = Number(minuteText);
|
|
const second = Number(secondText);
|
|
if (hour > 23 || minute > 59 || second > 59) return undefined;
|
|
const zoneHour = Number(zoneHourText ?? 0);
|
|
const zoneMinute = Number(zoneMinuteText ?? 0);
|
|
if (zoneHour > 23 || zoneMinute > 59) return undefined;
|
|
|
|
let microseconds = 0;
|
|
if (fraction !== undefined && fraction.length > 0) {
|
|
microseconds = Number((fraction.slice(0, 6) + "000000").slice(0, 6));
|
|
if (fraction.length > 6 && Number(fraction[6]) > 4) microseconds += 1;
|
|
}
|
|
const wholeSeconds = hour * 3600 + minute * 60 + second;
|
|
if (
|
|
year === 9999 &&
|
|
month === 12 &&
|
|
dayOfMonth === 31 &&
|
|
wholeSeconds === 86_399 &&
|
|
microseconds === 1_000_000
|
|
)
|
|
return undefined;
|
|
let micros =
|
|
BigInt(day) * 86_400_000_000n +
|
|
BigInt(wholeSeconds) * 1_000_000n +
|
|
BigInt(microseconds);
|
|
if (zone !== undefined && zone !== "Z") {
|
|
const offset = (zoneHour * 60 + zoneMinute) * (sign === "+" ? 1 : -1);
|
|
micros -= BigInt(offset) * 60_000_000n;
|
|
}
|
|
return {
|
|
key:
|
|
zone === undefined
|
|
? `datetime-local:${micros}`
|
|
: `datetime-utc:${micros}`,
|
|
value: new Date(Number(micros / 1000n - (micros % 1000n < 0n ? 1n : 0n))),
|
|
};
|
|
};
|
|
|
|
const numericValue = (value: unknown): bigint | number | undefined => {
|
|
if (typeof value === "boolean") return value ? 1n : 0n;
|
|
if (typeof value === "bigint" || typeof value === "number") return value;
|
|
return undefined;
|
|
};
|
|
|
|
const numericValuesEqual = (
|
|
left: bigint | number,
|
|
right: bigint | number,
|
|
): boolean => {
|
|
if (typeof left === "bigint" && typeof right === "bigint")
|
|
return left === right;
|
|
if (typeof left === "number" && typeof right === "number")
|
|
return left === right || (Number.isNaN(left) && Number.isNaN(right));
|
|
const integer = typeof left === "bigint" ? left : right;
|
|
const number = typeof left === "number" ? left : right;
|
|
return (
|
|
Number.isFinite(number) &&
|
|
Number.isInteger(number) &&
|
|
BigInt(number) === integer
|
|
);
|
|
};
|
|
|
|
const sameResolvedScalarValue = (left: unknown, right: unknown): boolean =>
|
|
Object.is(left, right) ||
|
|
(left instanceof Date &&
|
|
right instanceof Date &&
|
|
left.getTime() === right.getTime());
|
|
|
|
/**
|
|
* Write a parsed scalar with its source spelling while that spelling still
|
|
* reads as the node's value. Hermes reads config.yaml as YAML 1.1 with exact
|
|
* integers, so re-printing a JavaScript number changes what it reads: a
|
|
* Discord channel ID loses its low digits, `0600` (octal) becomes `600`, and
|
|
* `0.0000001` becomes `1e-7`, which YAML 1.1 does not define as a float.
|
|
* Timestamp source also retains precision and offsets absent from JS Dates.
|
|
*/
|
|
const keepScalarSource = (tag: Tags[number]): Tags[number] => {
|
|
if (typeof tag === "string" || tag.collection !== undefined) return tag;
|
|
const { stringify } = tag;
|
|
if (!SOURCE_SCALAR_TAGS.has(tag.tag) || stringify === undefined) return tag;
|
|
return {
|
|
...tag,
|
|
stringify: (item, context, ...rest) => {
|
|
const { source } = item;
|
|
if (source === undefined) return stringify(item, context, ...rest);
|
|
const reader = context.doc.schema.tags.find(
|
|
(candidate): candidate is ScalarTag =>
|
|
candidate.default === true && candidate.test?.test(source) === true,
|
|
);
|
|
const read = reader?.resolve(
|
|
source,
|
|
() => undefined,
|
|
context.doc.options,
|
|
);
|
|
return sameResolvedScalarValue(
|
|
isScalar(read) ? read.value : read,
|
|
item.value,
|
|
)
|
|
? source
|
|
: stringify(item, context, ...rest);
|
|
},
|
|
};
|
|
};
|
|
|
|
const createHermesYamlOptions = () => {
|
|
const timestamps = new Map<string, HermesTimestampFacts | undefined>();
|
|
const timestampFacts = (source: string): HermesTimestampFacts | undefined => {
|
|
if (timestamps.has(source)) return timestamps.get(source);
|
|
const facts = parseHermesTimestamp(source);
|
|
timestamps.set(source, facts);
|
|
return facts;
|
|
};
|
|
const hermesTimestampTag: ScalarTag = {
|
|
...timestampTag,
|
|
test: hermesTimestampImplicit,
|
|
resolve: (source) => {
|
|
const facts = timestampFacts(source);
|
|
if (facts === undefined)
|
|
throw new Error(`Invalid Hermes YAML timestamp: ${source}`);
|
|
return facts.value;
|
|
},
|
|
};
|
|
const keyFacts = new WeakMap<object, HermesKey>();
|
|
const hermesKey = (node: ParsedNode): HermesKey => {
|
|
const cached = keyFacts.get(node);
|
|
if (cached !== undefined) return cached;
|
|
if (!isScalar(node)) return { kind: "other", value: node };
|
|
const numeric = numericValue(node.value);
|
|
const key =
|
|
numeric !== undefined
|
|
? { kind: "numeric" as const, value: numeric }
|
|
: node.value instanceof Date
|
|
? {
|
|
kind: "timestamp" as const,
|
|
value:
|
|
timestampFacts(node.source ?? "")?.key ??
|
|
`invalid:${node.source ?? ""}`,
|
|
}
|
|
: { kind: "other" as const, value: node.value };
|
|
keyFacts.set(node, key);
|
|
return key;
|
|
};
|
|
/** Python dict key equality is Hermes' duplicate-key behavior (bool is int). */
|
|
const hermesKeysEqual = (
|
|
leftNode: ParsedNode,
|
|
rightNode: ParsedNode,
|
|
): boolean => {
|
|
const left = hermesKey(leftNode);
|
|
const right = hermesKey(rightNode);
|
|
if (left.kind !== right.kind) return false;
|
|
if (left.kind === "numeric" && right.kind === "numeric")
|
|
return numericValuesEqual(left.value, right.value);
|
|
return left.value === right.value;
|
|
};
|
|
return {
|
|
// Hermes defaults to YAML 1.1; an explicit document directive still wins.
|
|
// Use the same schema to interpret existing values and quote new strings.
|
|
version: "1.1" as const,
|
|
// Keep integer key identity exact; parseHermesConfigurationYaml's reviver
|
|
// restores the existing number-valued consumer contract.
|
|
intAsBigInt: true,
|
|
customTags: (tags: Tags) => {
|
|
const hasTimestamp = tags.some(
|
|
(tag) => typeof tag !== "string" && tag.tag === timestampTag.tag,
|
|
);
|
|
const withHermesTimestamp = hasTimestamp
|
|
? tags.map((tag) =>
|
|
typeof tag !== "string" && tag.tag === timestampTag.tag
|
|
? hermesTimestampTag
|
|
: tag,
|
|
)
|
|
: [...tags, hermesTimestampTag];
|
|
return withHermesTimestamp.map(keepScalarSource);
|
|
},
|
|
uniqueKeys: hermesKeysEqual,
|
|
};
|
|
};
|
|
|
|
/** Create a Hermes config using the same scalar rules as edits and readback. */
|
|
export const stringifyHermesConfigurationYaml = (
|
|
document: Record<string, unknown>,
|
|
): string =>
|
|
new Document(document, createHermesYamlOptions()).toString({ lineWidth: 0 });
|
|
|
|
// A comment-only document has no root; a bare marker has an unwritten null
|
|
// scalar. Explicit null values, tags and anchors remain actual values.
|
|
const isEmptyYamlRoot = (root: Document["contents"]): boolean =>
|
|
root === null ||
|
|
(isScalar(root) &&
|
|
root.value === null &&
|
|
root.source === "" &&
|
|
root.tag === undefined &&
|
|
root.anchor === undefined);
|
|
|
|
const parseHermesDocument = (text: string) => {
|
|
const document = parseDocument(text, createHermesYamlOptions());
|
|
const error = document.errors[0];
|
|
if (error !== undefined) throw error;
|
|
return document;
|
|
};
|
|
|
|
/** Parse a Hermes `config.yaml`, preserving the distinction between no root and explicit null. */
|
|
export const parseHermesConfigurationYaml = (text: string): unknown => {
|
|
const document = parseHermesDocument(text);
|
|
return isEmptyYamlRoot(document.contents)
|
|
? {}
|
|
: document.toJS({
|
|
reviver: (_key, value) =>
|
|
typeof value === "bigint" ? Number(value) : value,
|
|
});
|
|
};
|
|
|
|
/** Keep unchanged scalar nodes, including their quoting and explicit tags. */
|
|
const retainHermesScalarSource = (previous: unknown, next: Node): Node => {
|
|
if (isScalar(previous) && isScalar(next) && previous.value === next.value)
|
|
return previous;
|
|
if (isMap(previous) && isMap(next)) {
|
|
const entries = new Map(
|
|
previous.items.flatMap((pair) =>
|
|
isScalar(pair.key) && typeof pair.key.value === "string"
|
|
? [[pair.key.value, pair] as const]
|
|
: [],
|
|
),
|
|
);
|
|
for (const pair of next.items) {
|
|
const key = isScalar(pair.key) ? pair.key.value : undefined;
|
|
const prior = typeof key === "string" ? entries.get(key) : undefined;
|
|
if (prior === undefined) continue;
|
|
pair.key = prior.key;
|
|
if (isNode(pair.value))
|
|
pair.value = retainHermesScalarSource(prior.value, pair.value);
|
|
}
|
|
} else if (isSeq(previous) && isSeq(next)) {
|
|
next.items = next.items.map((item, index) =>
|
|
isNode(item)
|
|
? retainHermesScalarSource(previous.items[index], item)
|
|
: item,
|
|
);
|
|
}
|
|
return next;
|
|
};
|
|
|
|
/** Copy original bindings so moved aliases cannot bind to later anchors. */
|
|
const copyHermesAliasSource = (
|
|
source: unknown,
|
|
document: Document,
|
|
visiting = new Set<Node>(),
|
|
): unknown => {
|
|
const node = isAlias(source) ? source.resolve(document) : source;
|
|
if (isAlias(source) && node === undefined)
|
|
throw new TypeError("Cannot edit an unresolved Hermes YAML alias");
|
|
if (!isNode(node)) return node;
|
|
if (visiting.has(node))
|
|
throw new TypeError("Cannot edit cyclic Hermes YAML aliases");
|
|
visiting.add(node);
|
|
const copied = node.clone();
|
|
if (isScalar(copied) || isMap(copied) || isSeq(copied)) delete copied.anchor;
|
|
if (isSeq(node) && isSeq(copied))
|
|
copied.items = node.items.map((item) =>
|
|
copyHermesAliasSource(item, document, visiting),
|
|
);
|
|
else if (isMap(node) && isMap(copied))
|
|
copied.items.forEach((pair, index) => {
|
|
const original = node.items[index];
|
|
if (original === undefined) return;
|
|
pair.key = copyHermesAliasSource(original.key, document, visiting);
|
|
pair.value = copyHermesAliasSource(original.value, document, visiting);
|
|
});
|
|
if (isAlias(source)) {
|
|
delete copied.commentBefore;
|
|
delete copied.comment;
|
|
if (source.commentBefore !== undefined)
|
|
copied.commentBefore = source.commentBefore;
|
|
if (source.comment !== undefined) copied.comment = source.comment;
|
|
}
|
|
visiting.delete(node);
|
|
return copied;
|
|
};
|
|
|
|
/** External aliases keep their original bindings when their source is replaced. */
|
|
const retainHermesAliasConsumers = (
|
|
document: Document,
|
|
previous: unknown,
|
|
): void => {
|
|
if (!isNode(previous)) return;
|
|
const replaced = new Set<Node>();
|
|
visit(previous, {
|
|
Node: (_key, node) => {
|
|
replaced.add(node);
|
|
},
|
|
});
|
|
visit(document, {
|
|
Alias: (_key, alias, path) => {
|
|
const source = alias.resolve(document);
|
|
if (
|
|
source === undefined ||
|
|
!replaced.has(source) ||
|
|
path.some((parent) => isNode(parent) && replaced.has(parent))
|
|
)
|
|
return;
|
|
const copied = copyHermesAliasSource(alias, document);
|
|
return isNode(copied) ? copied : undefined;
|
|
},
|
|
});
|
|
};
|
|
|
|
/** Set or delete one value in a Hermes `config.yaml`, keeping the rest as written. */
|
|
export const editHermesConfigurationYaml = (
|
|
text: string,
|
|
path: readonly string[],
|
|
value: unknown,
|
|
): string => {
|
|
// A plain Document, so a bare `---` root can be replaced by a new map.
|
|
const document: Document = parseHermesDocument(text);
|
|
const root = document.contents;
|
|
if (isEmptyYamlRoot(root)) {
|
|
const map = document.createNode({});
|
|
if (isScalar(root))
|
|
map.commentBefore = [root.commentBefore, root.comment]
|
|
.filter((comment) => comment !== undefined)
|
|
.join("\n");
|
|
document.contents = map;
|
|
}
|
|
for (let length = 1; length < path.length; length++) {
|
|
const parent = path.slice(0, length);
|
|
const previous = document.getIn(parent, true);
|
|
if (isAlias(previous)) {
|
|
const copied = copyHermesAliasSource(previous, document);
|
|
if (isMap(copied)) {
|
|
// Edit a private mapping, keeping the alias source and its consumers.
|
|
document.setIn(parent, copied);
|
|
}
|
|
} else if (value !== undefined && !document.hasIn(parent)) {
|
|
// Implicit JS Map parents serialize as !!omap in YAML 1.1.
|
|
document.setIn(parent, document.createNode({}));
|
|
}
|
|
}
|
|
retainHermesAliasConsumers(document, document.getIn(path, true));
|
|
if (value === undefined) {
|
|
if (!document.deleteIn(path)) return text;
|
|
} else {
|
|
const previous = document.getIn(path, true);
|
|
// Let YAML's scalar update preserve numeric formats for a direct edit.
|
|
// Replacing containers needs explicit retention of unchanged child nodes.
|
|
document.setIn(
|
|
path,
|
|
isMap(previous) || isSeq(previous)
|
|
? retainHermesScalarSource(previous, document.createNode(value))
|
|
: value,
|
|
);
|
|
}
|
|
// Editing REA must not reflow unrelated user prompts or other YAML scalars.
|
|
return document.toString({
|
|
lineWidth: 0,
|
|
// Hermes tries the YAML 1.1 C loader before its directive-aware pure
|
|
// fallback. Quote new strings to avoid introducing YAML 1.1 coercions.
|
|
...(document.directives?.yaml.version === "1.2"
|
|
? {
|
|
defaultStringType: "QUOTE_DOUBLE" as const,
|
|
defaultKeyType: "QUOTE_DOUBLE" as const,
|
|
}
|
|
: {}),
|
|
});
|
|
};
|