Files
reverse-engineering-anything/src/domain/analysisView/environmentStateWriteValues.ts
T

381 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash } from "node:crypto";
import type { JsonValue } from "../jsonValue.js";
/** How a recorded clock value is spelled: ISO-8601 with its separator, fraction digits and zone, or an epoch number. */
export type ClockFormat =
| {
readonly kind: "iso";
readonly separator: "T" | " ";
readonly fraction: number;
readonly zone: string;
}
| { readonly kind: "epoch"; readonly unit: "s" | "ms" };
interface ClockReading {
readonly ms: number;
/** Nanoseconds after the floored millisecond, so distinct recorded instants stay distinct. */
readonly subMillisecond: number;
readonly format: ClockFormat;
}
/** Compare recorded instants without rounding away their submillisecond digits. */
export const compareClockInstants = (
left: Pick<ClockReading, "ms" | "subMillisecond">,
right: Pick<ClockReading, "ms" | "subMillisecond">,
): number => left.ms - right.ms || left.subMillisecond - right.subMillisecond;
const ISO_INSTANT =
/^(\d{4})-(\d{2})-(\d{2})([T ])(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})?$/u;
const MINUTE_MS = 60_000;
/** Minutes east of UTC that a zone suffix names; none and `Z` are UTC. */
const zoneMinutes = (zone: string): number | undefined => {
if (zone === "" || zone === "Z") return 0;
if (!/^[+-]\d{2}:?\d{2}$/u.test(zone)) return undefined;
const digits = zone.slice(1).replace(":", "");
const hours = Number(digits.slice(0, 2));
const remainder = Number(digits.slice(2, 4));
if (hours > 23 || remainder > 59) return undefined;
const minutes = hours * 60 + remainder;
return zone.startsWith("-") ? -minutes : minutes;
};
/** Whether a stored ISO clock format has a supported RFC 3339 offset suffix. */
export const isClockZone = (zone: string): boolean =>
zoneMinutes(zone) !== undefined;
const zoneMinutesForFormat = (zone: string): number => {
const minutes = zoneMinutes(zone);
if (minutes === undefined)
throw new TypeError(`Invalid clock zone in a derived format: ${zone}`);
return minutes;
};
/** The instant of an ISO-8601 date-time and how it is spelled, or undefined when it is not one. */
const readIsoClock = (value: string): ClockReading | undefined => {
const match = ISO_INSTANT.exec(value);
if (match === null) return undefined;
const [year, month, day, separator, hour, minute, second] = match
.slice(1, 8)
.map((part) => (part === "T" || part === " " ? part : Number(part)));
const fraction = match[8] ?? "";
const zone = match[9] ?? "";
const offsetMinutes = zoneMinutes(zone);
if (offsetMinutes === undefined) return undefined;
// setUTCFullYear avoids Date.UTC's special 1900 adjustment for years 0–99.
const rolled = new Date(0);
rolled.setUTCFullYear(year as number, (month as number) - 1, day as number);
rolled.setUTCHours(
hour as number,
minute as number,
second as number,
Number(`${fraction}000`.slice(0, 3)),
);
const local = rolled.getTime();
// Date setters roll invalid fields over; the round trip refuses them.
if (
rolled.getUTCFullYear() !== year ||
rolled.getUTCMonth() !== (month as number) - 1 ||
rolled.getUTCDate() !== day ||
rolled.getUTCHours() !== hour ||
rolled.getUTCMinutes() !== minute ||
rolled.getUTCSeconds() !== second
)
return undefined;
return {
ms: local - offsetMinutes * MINUTE_MS,
subMillisecond: Number(fraction.padEnd(9, "0").slice(3)),
format: {
kind: "iso",
separator: separator as "T" | " ",
fraction: fraction.length,
zone,
},
};
};
/** The instant a clock value names and its format: an ISO-8601 date-time, or epoch seconds or milliseconds. */
export const readClock = (
value: JsonValue | undefined,
): ClockReading | undefined => {
if (typeof value === "string") return readIsoClock(value);
if (typeof value !== "number" || !Number.isSafeInteger(value))
return undefined;
if (value >= 1e9 && value < 1e10)
return {
ms: value * 1000,
subMillisecond: 0,
format: { kind: "epoch", unit: "s" },
};
if (value >= 1e12 && value < 1e13)
return {
ms: value,
subMillisecond: 0,
format: { kind: "epoch", unit: "ms" },
};
return undefined;
};
const pad = (value: number, width: number): string =>
String(value).padStart(width, "0");
/** An ISO-8601 date-time from the UTC fields of `at`, with fraction digits and a zone suffix. */
const isoText = (
at: Date,
separator: string,
digits: string,
zone: string,
): string =>
`${pad(at.getUTCFullYear(), 4)}-${pad(at.getUTCMonth() + 1, 2)}-${pad(at.getUTCDate(), 2)}${separator}${pad(at.getUTCHours(), 2)}:${pad(at.getUTCMinutes(), 2)}:${pad(at.getUTCSeconds(), 2)}${digits === "" ? "" : `.${digits}`}${zone}`;
/** An instant spelled in a recorded clock format; fraction digits past milliseconds are zeros. */
export const formatClock = (ms: number, format: ClockFormat): JsonValue => {
if (format.kind === "epoch")
return format.unit === "s" ? Math.floor(ms / 1000) : ms;
const at = new Date(ms + zoneMinutesForFormat(format.zone) * MINUTE_MS);
const digits = `${pad(at.getUTCMilliseconds(), 3)}${"0".repeat(9)}`.slice(
0,
format.fraction,
);
return isoText(at, format.separator, digits, format.zone);
};
/**
* A clock value spelled in another ISO format: its wall clock in the format's zone, under the format's separator, with
* the value's own fraction digits padded to the format's width, so no digit is lost; an epoch format is computed from
* the instant.
*/
export const respellClock = (
value: JsonValue,
format: ClockFormat,
): JsonValue => {
const clock = readClock(value);
if (clock === undefined) return value;
if (format.kind === "epoch") return formatClock(clock.ms, format);
const millis = ((clock.ms % 1000) + 1000) % 1000;
// An epoch value's fraction is its milliseconds, without trailing zeros.
const digits =
clock.format.kind === "epoch"
? pad(millis, 3).replace(/0+$/u, "")
: `${pad(millis, 3)}${pad(clock.subMillisecond, 6)}`.slice(
0,
clock.format.fraction,
);
const at = new Date(
clock.ms - millis + zoneMinutesForFormat(format.zone) * MINUTE_MS,
);
return isoText(
at,
format.separator,
digits.padEnd(format.fraction, "0"),
format.zone,
);
};
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
export const isUuid = (value: JsonValue | undefined): value is string =>
typeof value === "string" && UUID.test(value);
/** An integer value as a number, from a JSON number or a string of digits. */
export const integerOf = (value: JsonValue | undefined): number | undefined =>
typeof value === "number" && Number.isSafeInteger(value)
? value
: typeof value === "string" && /^\d{1,15}$/u.test(value)
? Number(value)
: undefined;
/**
* The next value of a sequence. With nothing taken yet, that is the next held integer, or 1 when
* the scope holds none. Otherwise it is one past the later of that and the last value taken.
* A missing side leaves a negative value negative.
*/
export const nextSequence = (
held: number | undefined,
taken: number | undefined,
): number =>
taken === undefined ? (held ?? 1) : Math.max(held ?? taken + 1, taken + 1);
/** An integer spelled as the recorded value spells integers: a number, or a string of digits. */
export const spelledLike = (exemplar: JsonValue, value: number): JsonValue =>
maskedCount(exemplar) !== undefined
? fillMask(exemplar as string, value)
: typeof exemplar === "string"
? String(value)
: value;
const MASKED_DIGITS = /^\d{2,}(?:-\d{2,})+$/u;
/** The count `-`-separated digit groups of one width spell, such as an asset number `000-005`; dates, versions and addresses are not counts. */
const maskedCount = (value: JsonValue | undefined): number | undefined => {
if (typeof value !== "string" || !MASKED_DIGITS.test(value)) return undefined;
const groups = value.split("-");
if (groups.some((group) => group.length !== groups[0]!.length))
return undefined;
const digits = groups.join("");
return digits.length <= 15 ? Number(digits) : undefined;
};
/** A count that outgrew the exemplar's mask, read only when it is spelled as serving spells it, so dates never match. */
const outgrownMask = (
value: JsonValue | undefined,
exemplar: JsonValue | undefined,
): number | undefined => {
if (typeof value !== "string" || maskedCount(exemplar) === undefined)
return undefined;
const digits = value.replaceAll("-", "");
if (!/^\d{1,15}$/u.test(digits)) return undefined;
const count = Number(digits);
return fillMask(exemplar as string, count) === value ? count : undefined;
};
/** A sequence's number: an integer, digit groups that count, or with the sequence's exemplar a count that outgrew its mask. */
export const sequenceNumber = (
value: JsonValue | undefined,
exemplar?: JsonValue,
): number | undefined =>
integerOf(value) ?? maskedCount(value) ?? outgrownMask(value, exemplar);
/** A number written into a mask's digit positions, zero-padded; digits that outgrow the mask lead it. */
const fillMask = (mask: string, value: number): string => {
const padded = String(value).padStart(mask.replace(/\D/gu, "").length, "0");
let next = padded.length - 1;
let out = "";
for (let at = mask.length - 1; at >= 0; at -= 1) {
const character = mask[at]!;
if (/\d/u.test(character)) {
out = padded[next]! + out;
next -= 1;
} else out = character + out;
}
return padded.slice(0, next + 1) + out;
};
/** Lower case, with every run of other characters joined by one `-` and none at either end. */
export const slugOf = (text: string): string =>
text
.normalize("NFKD")
.replace(/[̀-ͯ]/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "-")
.replace(/^-+|-+$/gu, "");
const isAlphanumeric = (character: string | undefined): boolean =>
character !== undefined && /[\p{L}\p{N}]/u.test(character);
/** Source offsets stay UTF-16, while delimiter classification reads complete code points. */
const previousCharacter = (text: string, at: number): string | undefined => {
if (at === 0) return undefined;
const last = text.charCodeAt(at - 1);
return last >= 0xdc00 && last <= 0xdfff && at > 1
? text.slice(at - 2, at)
: text[at - 1];
};
const nextCharacter = (text: string, at: number): string | undefined => {
const point = text.codePointAt(at);
return point === undefined ? undefined : String.fromCodePoint(point);
};
/** Start offsets of `token` in `text` where neither neighbour is a letter or digit. */
export const delimitedOffsets = (text: string, token: string): number[] => {
const offsets: number[] = [];
if (token.length === 0) return offsets;
let at = text.indexOf(token);
while (at >= 0) {
const delimited =
!isAlphanumeric(previousCharacter(text, at)) &&
!isAlphanumeric(nextCharacter(text, at + token.length));
if (delimited) offsets.push(at);
// A rejected match may overlap a delimited one, so the search moves on by one character.
at = text.indexOf(token, at + (delimited ? token.length : 1));
}
return offsets;
};
/** `text` with every delimited occurrence of `token` replaced. */
export const replaceDelimited = (
text: string,
token: string,
replacement: string,
): string => {
let result = "";
let from = 0;
for (const at of delimitedOffsets(text, token)) {
result += text.slice(from, at) + replacement;
from = at + token.length;
}
return result + text.slice(from);
};
/** Whether any string inside a value holds `token` delimited. */
export const containsDelimited = (value: JsonValue, token: string): boolean => {
const pending: JsonValue[] = [value];
for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
if (typeof next === "string") {
if (delimitedOffsets(next, token).length > 0) return true;
} else if (typeof next === "object" && next !== null)
// One at a time: spreading a wide array into push overflows the call stack.
for (const child of Array.isArray(next) ? next : Object.values(next))
pending.push(child);
}
return false;
};
/** SHA-256 bytes of a seed, extended by counter blocks to the requested length. */
const seededBytes = (seed: string, length: number): Uint8Array => {
const bytes = new Uint8Array(length);
for (let block = 0; block * 32 < length; block += 1) {
const digest = createHash("sha256")
.update(`${seed}#${String(block)}`)
.digest();
bytes.set(
digest.subarray(0, Math.min(32, length - block * 32)),
block * 32,
);
}
return bytes;
};
/** A version 4 UUID derived from a seed, in the recorded value's letter case. */
export const seededUuid = (seed: string, exemplar: string): string => {
const bytes = seededBytes(seed, 16);
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Buffer.from(bytes).toString("hex");
const uuid = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
return exemplar === exemplar.toUpperCase() && /[A-F]/u.test(exemplar)
? uuid.toUpperCase()
: uuid;
};
const DIGITS = "0123456789";
const LOWER = "abcdefghijklmnopqrstuvwxyz";
const UPPER = LOWER.toUpperCase();
/** The characters a token of this spelling draws from: hexadecimal, or the digit and letter cases it uses. */
const tokenAlphabet = (exemplar: string): string => {
if (/^[0-9a-f]+$/u.test(exemplar) && /[0-9]/u.test(exemplar))
return `${DIGITS}abcdef`;
if (/^[0-9A-F]+$/u.test(exemplar) && /[0-9]/u.test(exemplar))
return `${DIGITS}ABCDEF`;
const alphabet =
(/[0-9]/u.test(exemplar) ? DIGITS : "") +
(/[a-z]/u.test(exemplar) ? LOWER : "") +
(/[A-Z]/u.test(exemplar) ? UPPER : "");
return alphabet === "" ? LOWER : alphabet;
};
/** A token as long as the recorded one, from its alphabet, keeping its other characters in place. */
export const seededToken = (seed: string, exemplar: string): string => {
const alphabet = tokenAlphabet(exemplar);
const bytes = seededBytes(seed, exemplar.length);
return [...exemplar]
.map((character, index) =>
/[0-9A-Za-z]/u.test(character)
? alphabet[bytes[index]! % alphabet.length]!
: character,
)
.join("");
};