@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
1,235 lines • 44.4 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { LOCAL_ZONE, daysInMonth, posixTransitions, resolveTz, } from "./timezone.js";
import { systemDate } from "./clock.js";
/** The UTC day `ts` (epoch milliseconds) falls on, as `YYYY-MM-DD`. */
export function utcDateFolder(ts) {
return new Date(ts).toISOString().slice(0, 10);
}
/** UTC ISO text: whole seconds, or six fraction digits, matching Python to_iso_z. */
export function toIsoZ(date) {
return date
.toISOString()
.replace(/\.(\d{3})Z$/, (_, ms) => (ms === '000' ? 'Z' : `.${ms}000Z`));
}
export function nowIso() {
return toIsoZ(systemDate());
}
// Truncated to whole seconds, matching Python epoch_to_iso.
export function epochToIso(seconds) {
return new Date(Math.floor(seconds) * 1000).toISOString().replace('.000Z', 'Z');
}
// Inverse of epochToIso; a naive stamp (no Z/offset, e.g. a `touch -t`
// overlay time) is read as UTC so this matches the Python isoToEpoch. JS
// interprets an offset-less date-time as local, so append Z when absent.
// Truncated to whole seconds to mirror epochToIso.
export function isoToEpoch(iso) {
const text = /(Z|[+-]\d\d:?\d\d)$/.test(iso) ? iso : `${iso}Z`;
return Math.floor(Date.parse(text) / 1000);
}
// A date the user typed, as an epoch second, or null when it is not a date at
// all. Mirrors Python's isoTimestamp: an offset-less stamp is read as UTC and
// anything unparseable is null rather than NaN, so a caller can tell "not a
// date" from "the epoch".
export function isoTimestamp(value) {
if (value === undefined || value === null || value === '')
return null;
const text = /(Z|[+-]\d\d:?\d\d)$/.test(value) ? value : `${value}Z`;
const ms = Date.parse(text);
return Number.isNaN(ms) ? null : ms / 1000;
}
/**
* The instant `zone` shows `p` at, or null when it shows none: no instant
* reads the hour skipped when DST starts, and GNU date answers a clock
* setting for one with `invalid date`. The host's zone is not
* checked, since the Python twin reads a host-local moment naively and
* cannot refuse there.
*/
function localize(zone, p) {
const placed = zone.fromWall(p);
if (zone === LOCAL_ZONE)
return placed;
const shown = zone.parts(placed);
const kept = shown.year === p.year &&
shown.month === p.month &&
shown.day === p.day &&
shown.hour === p.hour &&
shown.minute === p.minute &&
shown.second === p.second;
return kept ? placed : null;
}
// Whether an epoch-seconds timestamp sits inside an inclusive mtime window.
// An unbounded window keeps everything; an unknown timestamp fails any
// bounded one. Mirrors the Python in_mtime_window.
export function inMtimeWindow(timestamp, mtimeMin, mtimeMax) {
if (mtimeMin == null && mtimeMax == null)
return true;
if (timestamp == null)
return false;
if (mtimeMin != null && timestamp < mtimeMin)
return false;
if (mtimeMax != null && timestamp > mtimeMax)
return false;
return true;
}
// The moments mirage holds, as Python's datetime does: 0001-01-01 00:00:00 to
// 9999-12-31 23:59:59 UTC.
const FIRST_SECOND = -62135596800;
const LAST_SECOND = 253402300799;
const DAY = 86400;
const NANOS = 1_000_000_000;
// date reads each number, and keeps each relative count, in a signed 64-bit
// integer, refusing one that overflows it.
const INT64 = 1n << 63n;
// The largest relative total a double holds exactly.
const EXACT = BigInt(Number.MAX_SAFE_INTEGER);
const BLANKS = ' \t\n\v\f\r';
const DIGITS = '0123456789';
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
const SIGNS = '+-';
const MONTHS = [
'JANUARY',
'FEBRUARY',
'MARCH',
'APRIL',
'MAY',
'JUNE',
'JULY',
'AUGUST',
'SEPTEMBER',
'OCTOBER',
'NOVEMBER',
'DECEMBER',
];
const WEEKDAYS = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
// Longer abbreviations date reads beside the three-letter ones.
const NAMED_DAYS = {
SEPT: ['month', 9],
TUES: ['weekday', 2],
WEDNES: ['weekday', 3],
THUR: ['weekday', 4],
THURS: ['weekday', 4],
};
// [kind, amount]: a unit counts months, days or seconds.
const UNITS = {
YEAR: ['months', 12],
MONTH: ['months', 1],
FORTNIGHT: ['days', 14],
WEEK: ['days', 7],
DAY: ['days', 1],
HOUR: ['seconds', 3600],
MINUTE: ['seconds', 60],
MIN: ['seconds', 60],
SECOND: ['seconds', 1],
SEC: ['seconds', 1],
};
const DAY_WORDS = { TOMORROW: 1, YESTERDAY: -1, TODAY: 0, NOW: 0 };
// "SECOND" is the unit, so the second ordinal has no word.
const ORDINALS = {
LAST: -1,
THIS: 0,
NEXT: 1,
FIRST: 1,
THIRD: 3,
FOURTH: 4,
FIFTH: 5,
SIXTH: 6,
SEVENTH: 7,
EIGHTH: 8,
NINTH: 9,
TENTH: 10,
ELEVENTH: 11,
TWELFTH: 12,
};
const SUFFIXES = { AGO: -1, HENCE: 1 };
const MERIDIANS = { AM: 0, 'A.M.': 0, PM: 12, 'P.M.': 12 };
// The zone names date accepts, measured on coreutils 9.7, each at its UTC
// offset in minutes as tzdata and RFC 5322 give it (SST is read at -12:00).
// The second set names daylight time, which takes no "DST" and no offset
// after it. Mirrors the Python tables.
const STANDARD_ZONES = {
UT: 0,
UTC: 0,
GMT: 0,
WET: 0,
WAT: 60,
CET: 60,
MET: 60,
MEZ: 60,
EET: 120,
CAT: 120,
SAST: 120,
EAT: 180,
MSK: 180,
IST: 330,
SGT: 480,
KST: 540,
JST: 540,
GST: 600,
NZST: 720,
ART: -180,
BRT: -180,
NST: -210,
AST: -240,
CLT: -240,
EST: -300,
CST: -360,
MST: -420,
PST: -480,
AKST: -540,
HST: -600,
HAST: -600,
SST: -720,
};
const DAYLIGHT_ZONES = {
WEST: 60,
BST: 60,
CEST: 120,
MEST: 120,
MESZ: 120,
EEST: 180,
MSD: 240,
NZDT: 780,
BRST: -120,
NDT: -150,
ADT: -180,
CLST: -180,
EDT: -240,
CDT: -300,
MDT: -360,
PDT: -420,
AKDT: -480,
HADT: -540,
};
// A zone correction reaches a whole day either way.
const ZONE_LIMIT = 24 * 60;
const UNIT_KINDS = ['months', 'days', 'seconds'];
// Names the zone's own abbreviations never stand in for.
const FIXED_NAMES = ['GMT', 'UT', 'UTC'];
/** A date expression date would answer with `invalid date`. */
class NotADate extends Error {
}
// Minutes east of UTC for a military zone letter, J excluded.
function military(letter) {
if (letter === 'Z')
return 0;
const at = letter.charCodeAt(0) - 65;
if (letter < 'J')
return (at + 1) * 60;
if (letter < 'N')
return at * 60;
return -(at - 12) * 60;
}
/**
* What an upper-cased word names, as [kind, value]: `meridian` (hours to
* add), `month` (1-12), `weekday` (0 is Sunday), `dst`, a unit
* (`months`/`days`/`seconds` with its size), `today` (and its neighbours, in
* days), `ordinal`, `suffix` (ago, hence), `zone` and `daylight` (minutes
* east) and `local` (J, the zone the expression is read in). Words are
* upper-cased, so no lookup reaches a name Object.prototype holds.
*/
function meaning(word) {
const meridian = MERIDIANS[word];
if (meridian !== undefined)
return ['meridian', meridian];
if (MONTHS.includes(word))
return ['month', MONTHS.indexOf(word) + 1];
if (WEEKDAYS.includes(word))
return ['weekday', WEEKDAYS.indexOf(word)];
const named = NAMED_DAYS[word];
if (named !== undefined)
return named;
const short = word.length === 3 || (word.length === 4 && word[3] === '.') ? word.slice(0, 3) : '';
if (/^[A-Z]{3}$/.test(short)) {
const month = MONTHS.findIndex((name) => name.startsWith(short));
if (month >= 0)
return ['month', month + 1];
const weekday = WEEKDAYS.findIndex((name) => name.startsWith(short));
if (weekday >= 0)
return ['weekday', weekday];
}
if (word === 'DST')
return ['dst', 0];
for (const unit of [word, word.endsWith('S') ? word.slice(0, -1) : '']) {
const found = UNITS[unit];
if (found !== undefined)
return found;
}
const today = DAY_WORDS[word];
if (today !== undefined)
return ['today', today];
const ordinal = ORDINALS[word];
if (ordinal !== undefined)
return ['ordinal', ordinal];
const suffix = SUFFIXES[word];
if (suffix !== undefined)
return ['suffix', suffix];
const bare = word.replaceAll('.', '');
const standard = STANDARD_ZONES[bare];
if (standard !== undefined && bare.length > 1)
return ['zone', standard];
const daylight = DAYLIGHT_ZONES[bare];
if (daylight !== undefined)
return ['daylight', daylight];
if (word.length === 1)
return word === 'J' ? ['local', 0] : ['zone', military(word)];
return null;
}
function bareToken(kind, text) {
return { kind, text, sign: '', value: 0, nanos: null };
}
// The kind and size of a unit word the parser looked at.
function unit(token) {
const found = meaning(token.text);
if (found === null)
throw new NotADate();
return found;
}
// The number whose digits start at `at`, and where it ends.
function lexNumber(text, at, sign) {
let stop = at;
while (stop < text.length && DIGITS.includes(text.charAt(stop)))
stop++;
const digits = text.slice(at, stop);
let nanos = null;
if (stop + 1 < text.length &&
'.,'.includes(text.charAt(stop)) &&
DIGITS.includes(text.charAt(stop + 1))) {
let end = stop + 1;
while (end < text.length && DIGITS.includes(text.charAt(end)))
end++;
const fraction = text.slice(stop + 1, end);
// Nanoseconds are all date keeps; further digits are dropped, though a
// negative number's nonzero ones still take it one nanosecond further
// down.
nanos = Number(fraction.slice(0, 9).padEnd(9, '0'));
if (sign === '-' && /[1-9]/.test(fraction.slice(9)))
nanos++;
stop = end;
}
const size = Number(digits);
if (BigInt(digits) >= INT64)
throw new NotADate();
return [{ kind: 'number', text: digits, sign, value: sign === '-' ? -size : size, nanos }, stop];
}
/**
* Split a date expression into tokens. White space and parenthesized
* comments (which nest, and run to the end when unclosed) separate tokens. A
* run of signs and blanks before digits signs the number with its last sign;
* one before anything else is dropped.
*/
function lex(text) {
const tokens = [];
let at = 0;
while (at < text.length) {
const c = text.charAt(at);
if (BLANKS.includes(c)) {
at++;
}
else if (c === '(') {
let depth = 0;
while (at < text.length) {
const here = text.charAt(at);
depth += here === '(' ? 1 : here === ')' ? -1 : 0;
at++;
if (depth === 0)
break;
}
}
else if (SIGNS.includes(c)) {
let sign = c;
let probe = at + 1;
while (probe < text.length && (SIGNS + BLANKS).includes(text.charAt(probe))) {
if (SIGNS.includes(text.charAt(probe)))
sign = text.charAt(probe);
probe++;
}
if (probe < text.length && DIGITS.includes(text.charAt(probe))) {
const [token, end] = lexNumber(text, probe, sign);
tokens.push(token);
at = end;
}
else {
at = probe;
}
}
else if (DIGITS.includes(c)) {
const [token, end] = lexNumber(text, at, '');
tokens.push(token);
at = end;
}
else if (LETTERS.includes(c)) {
let stop = at;
while (stop < text.length &&
(LETTERS.includes(text.charAt(stop)) || text.charAt(stop) === '.'))
stop++;
tokens.push(bareToken('word', text.slice(at, stop).toUpperCase()));
at = stop;
}
else {
tokens.push(bareToken('char', c));
at++;
}
}
return tokens;
}
// A year as typed: two digits are 1969-2068, any other count is literal.
function yearOf(token) {
const year = Math.abs(token.value);
if (token.text.length === 2)
return year + (year < 69 ? 2000 : 1900);
return year;
}
/**
* Reads the items of a tokenized date expression into a Reading. Each date,
* time of day, zone and day of the week may appear once; relative items add
* up. Every refusal throws NotADate.
*/
class Parser {
tokens;
names;
at = 0;
// Where the last ISO date ended, for the "T" that may follow it.
isoEnd = -1;
reading = {
date: null,
clock: null,
zone: null,
zoned: false,
named: false,
dst: null,
weekday: null,
months: 0n,
days: 0n,
seconds: 0n,
nanos: 0,
counts: new Map(),
relative: false,
epoch: null,
};
constructor(tokens, names) {
this.tokens = tokens;
this.names = names;
}
peek(ahead = 0) {
return this.tokens[this.at + ahead] ?? null;
}
take() {
const token = this.peek();
if (token === null)
throw new NotADate();
this.at++;
return token;
}
/**
* What a word names: a month, a weekday or a meridian, then the zone's own
* abbreviations, then every other word. One of the zone's names, dots
* dropped, is a `named` zone, 1 for daylight time and -1 for a name both
* seasons use; GMT, UT and UTC keep their fixed meaning even where the zone
* uses them.
*/
mean(token) {
const found = meaning(token.text);
if (found !== null && ['meridian', 'month', 'weekday'].includes(found[0]))
return found;
const bare = token.text.replaceAll('.', '');
const named = this.names.get(bare);
if (named !== undefined && !FIXED_NAMES.includes(bare)) {
return ['named', named === null ? -1 : named ? 1 : 0];
}
return found;
}
// The kind of the token `ahead`: a word's meaning, else its own.
kind(ahead = 0) {
const token = this.peek(ahead);
if (token === null)
return null;
if (token.kind === 'word')
return this.mean(token)?.[0] ?? null;
return token.kind;
}
// The value of a word whose kind was looked at.
value(token) {
const found = this.mean(token);
if (found === null)
throw new NotADate();
return found[1];
}
char(c, ahead = 0) {
const token = this.peek(ahead);
return token !== null && token.kind === 'char' && token.text === c;
}
// Whether the token `ahead` is a whole number with no sign.
plain(ahead = 0) {
const token = this.peek(ahead);
return token !== null && token.kind === 'number' && token.sign === '' && token.nanos === null;
}
// Whether the token `ahead` is a whole number `-` signs.
minus(ahead = 0) {
const token = this.peek(ahead);
return token !== null && token.kind === 'number' && token.sign === '-' && token.nanos === null;
}
// Whether the token `ahead` is a signed whole number: a signed fraction is
// never a zone correction, only seconds.
signed(ahead = 0) {
const token = this.peek(ahead);
return token !== null && token.kind === 'number' && token.sign !== '' && token.nanos === null;
}
unit(ahead = 0) {
return UNIT_KINDS.includes(this.kind(ahead) ?? '');
}
// Take a whole number with no sign.
whole() {
if (!this.plain())
throw new NotADate();
return this.take();
}
parse() {
if (this.char('@')) {
this.take();
const stamp = this.take();
if (stamp.kind !== 'number' || this.peek() !== null)
throw new NotADate();
const big = BigInt(NANOS);
const size = BigInt(stamp.text) * big + BigInt(stamp.nanos ?? 0);
const signed = stamp.sign === '-' ? -size : size;
const seconds = signed >= 0n ? signed / big : -((-signed + big - 1n) / big);
this.reading.epoch = [Number(seconds), Number(signed - seconds * big)];
return this.reading;
}
while (this.peek() !== null)
this.item();
return this.reading;
}
item() {
const token = this.take();
if (token.kind === 'number') {
this.number(token);
return;
}
const found = token.kind === 'word' ? this.mean(token) : null;
if (found === null)
throw new NotADate();
const [kind, value] = found;
if (kind === 'month') {
this.month(value);
}
else if (kind === 'weekday') {
this.setWeekday(0, value);
// Only a bare weekday takes a comma after it.
if (this.char(','))
this.take();
}
else if (kind === 'ordinal') {
if (this.kind() === 'weekday')
this.setWeekday(value, this.value(this.take()));
else if (this.unit())
this.relative(BigInt(value));
else
throw new NotADate();
}
else if (UNIT_KINDS.includes(kind)) {
this.add(kind, value, 1n);
}
else if (kind === 'today') {
this.tally('days', 1, BigInt(value));
}
else if (kind === 'zone' && token.text === 'T' && this.isoEnd === this.at - 1) {
// A time of day: H:M[:S], or an hour that a zone correction must follow.
if (this.plain() && this.char(':', 1)) {
this.clock(this.take(), false);
}
else if (this.plain() && this.signed(1)) {
this.setClock(this.take().value, 0, 0, 0);
this.setZone(this.zoneNumber(this.take()));
}
else {
throw new NotADate();
}
}
else if (kind === 'zone') {
this.zone(value * 60);
}
else if (kind === 'daylight') {
this.setZone(value * 60);
}
else if (kind === 'local') {
this.setZone(null);
}
else if (kind === 'named') {
let dst = value < 0 ? null : value === 1;
if (this.kind() === 'dst') {
this.take();
dst = true;
}
this.setZone(null);
this.reading.named = true;
this.reading.dst = dst;
}
else {
throw new NotADate();
}
}
// A standard zone, then DST, an offset, or a relative item of its own.
zone(base) {
let offset = base;
if (this.kind() === 'dst') {
this.take();
offset += 3600;
}
else if (this.signedZone()) {
offset += this.zoneNumber(this.take());
}
else if (this.signed() && this.unit(1)) {
// `UTC +1 day` is the zone and a relative item, which then takes no
// `ago`.
this.setZone(offset);
const count = this.take();
const [kind, size] = unit(this.take());
this.add(kind, size, BigInt(count.sign + count.text), false);
return;
}
this.setZone(offset);
}
number(token) {
if (token.nanos !== null) {
this.fraction(token);
}
else if (token.sign !== '') {
if (!this.unit())
throw new NotADate();
this.relative(BigInt(token.sign + token.text));
}
else if (this.char(':')) {
this.clock(token, true);
}
else if (this.char('/')) {
this.slashed(token);
}
else if (this.minus() && this.minus(1)) {
this.iso(token);
}
else if (this.kind() === 'month') {
this.dayMonth(token);
}
else if (this.unit()) {
this.relative(BigInt(token.text));
}
else if (this.kind() === 'weekday') {
this.setWeekday(token.value, this.value(this.take()));
}
else if (this.kind() === 'meridian') {
this.setClock(this.meridian(token.value, this.value(this.take())), 0, 0, 0);
}
else {
this.pure(token);
}
}
/**
* A number standing alone: a year, a date, or a time of day. One a zone
* correction follows is an hour, whatever its digits, and no signed number
* may follow a ±HHMM correction there (one may follow ±H:M). Otherwise,
* after a date with no year and before any relative item, it is the year,
* when a time of day came first or it has three digits or more; five digits
* or more are [YY]YYMMDD and fewer are HH or HHMM.
*/
pure(token) {
const reading = this.reading;
const digits = token.text.length;
const value = token.value;
const date = reading.date;
if (this.signedZone()) {
const colon = this.char(':', 1) && this.plain(2);
this.setClock(value, 0, 0, 0);
this.setZone(this.zoneNumber(this.take()));
if (!colon && this.signed())
throw new NotADate();
}
else if (date !== null &&
date[0] === null &&
!reading.relative &&
(reading.clock !== null || digits > 2)) {
reading.date = [yearOf(token), date[1], date[2]];
}
else if (digits > 4) {
let year = Math.floor(value / 10000);
if (digits === 6)
year += year < 69 ? 2000 : 1900;
this.setDate(year, Math.floor(value / 100) % 100, value % 100);
}
else if (digits <= 2) {
this.setClock(value, 0, 0, 0);
}
else {
this.setClock(Math.floor(value / 100), value % 100, 0, 0);
}
}
// H:M[:S[.frac]], then a meridian or a zone correction.
clock(hour, meridian) {
this.take();
const minute = this.whole().value;
let second = 0;
let nanos = 0;
if (this.char(':')) {
this.take();
const token = this.take();
if (token.kind !== 'number' || token.sign !== '')
throw new NotADate();
second = token.value;
nanos = token.nanos ?? 0;
}
if (meridian && this.kind() === 'meridian') {
this.setClock(this.meridian(hour.value, this.value(this.take())), minute, second, nanos);
return;
}
this.setClock(hour.value, minute, second, nanos);
if (this.signed())
this.setZone(this.zoneNumber(this.take()));
}
// M/D, M/D/Y or, four digits leading, Y/M/D.
slashed(first) {
this.take();
const second = this.whole();
let third = null;
if (this.char('/')) {
this.take();
third = this.whole();
}
if (first.text.length >= 4) {
if (third === null)
throw new NotADate();
this.setDate(first.value, second.value, third.value);
}
else {
this.setDate(third === null ? null : yearOf(third), first.value, second.value);
}
}
// Y-M-D, read as the year and two `-` signed numbers.
iso(year) {
const month = this.take();
const day = this.take();
this.setDate(yearOf(year), -month.value, -day.value);
this.isoEnd = this.at;
}
// D [-]MONTH [Y]: a number after the month is its year.
dayMonth(day) {
const month = this.value(this.take());
let year = null;
if (this.plain() || this.minus())
year = yearOf(this.take());
this.setDate(year, month, day.value);
}
// MONTH D[, Y] or MONTH -D -Y.
month(month) {
if (this.plain()) {
const day = this.take().value;
let year = null;
if (this.char(',')) {
this.take();
year = yearOf(this.whole());
}
this.setDate(year, month, day);
}
else if (this.minus() && this.minus(1)) {
const day = this.take();
const year = this.take();
this.setDate(yearOf(year), month, -day.value);
}
else {
throw new NotADate();
}
}
// N.F SECONDS [ago|hence]: only seconds take a fraction.
fraction(token) {
if (this.kind() !== 'seconds' || this.value(this.take()) !== 1)
throw new NotADate();
let times = token.sign === '-' ? -1 : 1;
if (this.kind() === 'suffix')
times *= this.value(this.take());
this.tally('seconds', 1, BigInt(times) * BigInt(token.text));
this.reading.nanos += times * (token.nanos ?? 0);
}
// count UNIT [ago|hence], the unit next.
relative(count) {
const [kind, size] = unit(this.take());
this.add(kind, size, count);
}
add(kind, size, count, suffix = true) {
let times = count;
if (suffix && this.kind() === 'suffix')
times *= BigInt(this.value(this.take()));
this.tally(kind, size, times);
}
// Add `count` units of `size` to the relative `kind`, each unit counted on
// its own, a week's and a fortnight's as days.
tally(kind, size, count) {
let [step, times] = [BigInt(size), count];
if (kind === 'days')
[step, times] = [1n, count * step];
const reading = this.reading;
const key = `${kind} ${String(step)}`;
const total = (reading.counts.get(key) ?? 0n) + times;
if (times < -INT64 || times >= INT64 || total < -INT64 || total >= INT64)
throw new NotADate();
reading.counts.set(key, total);
if (kind === 'months')
reading.months += step * times;
else if (kind === 'days')
reading.days += step * times;
else
reading.seconds += step * times;
reading.relative = true;
}
// Whether a signed number comes next that is no relative item's.
signedZone() {
return this.signed() && !this.unit(1);
}
// A zone correction in seconds east: ±H and ±HH are hours, a longer number
// hours and minutes as ±HHMM reads, its leading zeros aside, and ±H:M takes
// any count of minutes.
zoneNumber(token) {
if (token.nanos !== null)
throw new NotADate();
const size = Math.abs(token.value);
let minutes;
if (this.char(':') && this.plain(1)) {
this.take();
minutes = size * 60 + this.take().value;
}
else if (token.text.length <= 2) {
minutes = size * 60;
}
else {
minutes = Math.floor(size / 100) * 60 + (size % 100);
}
if (minutes > ZONE_LIMIT)
throw new NotADate();
return (token.sign === '-' ? -minutes : minutes) * 60;
}
meridian(hour, add) {
if (hour < 1 || hour > 12)
throw new NotADate();
return (hour % 12) + add;
}
setDate(year, month, day) {
if (this.reading.date !== null)
throw new NotADate();
this.reading.date = [year, month, day];
}
setClock(hour, minute, second, nanos) {
if (this.reading.clock !== null)
throw new NotADate();
this.reading.clock = [hour, minute, second, nanos];
}
setZone(offset) {
if (this.reading.zoned)
throw new NotADate();
this.reading.zoned = true;
this.reading.zone = offset;
}
setWeekday(ordinal, day) {
if (this.reading.weekday !== null)
throw new NotADate();
this.reading.weekday = [ordinal, day];
}
}
/**
* Days from 1970-01-01 to a proleptic Gregorian date. A month past 12 or below
* 1 carries into the year, and a day past the month's end into the months
* after, as a calendar shift needs.
*/
function civilDays(year, month, day) {
const y = year + Math.floor((month - 1) / 12);
const m = ((((month - 1) % 12) + 12) % 12) + 1;
// Counted from March, so February's leap day ends the year.
const shifted = y - (m <= 2 ? 1 : 0);
const march = m <= 2 ? m + 9 : m - 3;
const days = 365 * shifted + Math.floor(shifted / 4) - Math.floor(shifted / 100) + Math.floor(shifted / 400);
return days + Math.floor((153 * march + 2) / 5) + day - 1 - 719468;
}
// The calendar fields of wall-clock seconds read as UTC.
function wallFields(wall) {
const shown = new Date(wall * 1000);
return [
shown.getUTCFullYear(),
shown.getUTCMonth() + 1,
shown.getUTCDate(),
shown.getUTCHours(),
shown.getUTCMinutes(),
shown.getUTCSeconds(),
];
}
/** The UTC offsets a zone shows. Mirrors Python's _Clock. */
class Clock {
zone;
constructor(zone) {
this.zone = zone;
}
// Clamped a few days inside what mirage holds, so a far moment reads as the
// nearest one it does.
at(instant) {
const kept = Math.min(Math.max(instant, FIRST_SECOND + 2 * DAY), LAST_SECOND - 2 * DAY);
return new Date((Number.isFinite(kept) ? kept : 0) * 1000);
}
offset(instant) {
return this.zone.parts(this.at(instant)).offsetSec;
}
abbreviation(instant) {
return this.zone.parts(this.at(instant)).abbrev.toUpperCase();
}
/**
* Whether an instant is in daylight time: ahead of the lesser of the
* offsets its year shows on January 1 and July 1, or behind the offsets of
* both the January 1 or July 1 before it and the one after, as Ramadan's
* hour back in Casablanca is.
*/
isdst(instant) {
const year = wallFields(Math.min(Math.max(instant, FIRST_SECOND), LAST_SECOND))[0];
const winter = this.offset(civilDays(year, 1, 1) * DAY);
const july = civilDays(year, 7, 1) * DAY;
const summer = this.offset(july);
const shown = this.offset(instant);
if (shown > Math.min(winter, summer))
return true;
const [before, after] = instant < july ? [winter, summer] : [summer, this.offset(civilDays(year + 1, 1, 1) * DAY)];
return shown < before && shown < after;
}
// The abbreviations the zone shows now and 90, 180 and 270 days on, the
// instants date reads its names at, each with whether it names daylight
// time, null for one both seasons use.
names(now) {
const found = new Map();
for (let step = 0; step < 4; step++) {
const instant = Math.min(now + step * 90 * DAY, LAST_SECOND);
const name = this.abbreviation(instant);
const dst = this.isdst(instant);
const known = found.get(name);
found.set(name, known === undefined || known === dst ? dst : null);
}
return found;
}
// When a POSIX rule changes the clock in `wall`'s year; none for a zone from
// tzdata.
ruleChanges(wall) {
const year = wallFields(Math.min(Math.max(wall, FIRST_SECOND), LAST_SECOND))[0];
return posixTransitions(this.zone, year) ?? [];
}
/**
* The instants this zone's clock reads `wall` at: one, two in an hour
* repeated when DST ends, none in one skipped when it starts.
*/
readings(wall) {
const offsets = new Set([this.offset(wall - DAY), this.offset(wall + DAY)]);
// A POSIX rule's daylight time can be shorter than a day.
for (const change of this.ruleChanges(wall))
offsets.add(this.offset(change));
return [...offsets]
.filter((offset) => this.offset(wall - offset) === offset)
.map((offset) => wall - offset)
.sort((a, b) => a - b);
}
/**
* The instant this zone's clock reads `wall` at. The search starts under
* `guess`, so a reading the clock shows twice takes the side of the change
* whose offset `guess` is. One it never shows is read under the offset
* before the change.
*/
place(wall, guess) {
let instant = wall - guess;
for (let step = 0; step < 2; step++) {
const shown = this.offset(instant);
if (this.offset(wall - shown) === shown)
return wall - shown;
instant = wall - shown;
}
return wall - this.offset(wall - DAY);
}
/**
* The instant a wall clock the expression names reads at, null when the
* clock never reads it (or not in the daylight state `dst` asks for).
*/
settle(wall, guess, dst) {
const shown = this.readings(wall).filter((t) => dst === null || this.isdst(t) === dst);
if (shown.length === 2) {
const placed = this.place(wall, guess);
return shown.includes(placed) ? placed : (shown[1] ?? null);
}
return shown[0] ?? null;
}
/**
* The instant a moved wall clock reads at, kept in daylight state `dst`: a
* reading in another state, or none, is read under the offset of the
* nearest time in that state.
*/
keepState(wall, dst, guess) {
const shown = this.readings(wall).filter((t) => this.isdst(t) === dst);
if (shown.length === 2)
return this.place(wall, guess);
const only = shown[0];
if (only !== undefined)
return only;
// A POSIX rule's daylight time can be shorter than any step.
for (const change of this.ruleChanges(wall)) {
if (this.isdst(change) === dst)
return wall - this.offset(change);
}
for (let step = 1; step < 14; step++) {
for (const instant of [wall - step * 30 * DAY, wall + step * 30 * DAY]) {
if (this.isdst(instant) === dst)
return wall - this.offset(instant);
}
}
return this.place(wall, guess);
}
}
// The remainder of `n` by 7, from 0 to 6 even when `n` is negative.
function mod7(n) {
return ((n % 7) + 7) % 7;
}
/**
* Days from a weekday to the `ordinal`-th `day` from it. Ordinal 0 is that
* day this week, today included; a positive one counts occurrences after
* today; -1 is the last one before it.
*/
function weekdayShift(base, ordinal, day) {
const ahead = mod7(day - base);
if (ordinal > 0)
return (ahead || 7) + 7 * (ordinal - 1);
if (ordinal === 0)
return ahead;
return -(mod7(base - day) || 7) + 7 * (ordinal + 1);
}
/**
* Place a reading in time, as [epoch seconds, nanoseconds]. A field left out
* comes from now on the zone's clock, and the time of day is midnight unless
* only relative items (and a zone) were given. The wall clock named must
* exist on the zone's clock, a skipped hour refused and a repeated one read on
* now's side, unless a zone given reads it. A
* weekday moves the date, then days, months and years move it on the
* calendar, read from the side of any change the base was on; the zone and
* the hours, minutes and seconds then move the instant. A shift keeps the
* daylight state a name of the zone gives, and a shift of now's own clock
* now's, as date's does. `guess` is the offset a repeated wall clock is first read
* under: now's, in the zone the command runs in even when `TZ="..."` names
* another. Mirrors Python's _resolve.
*/
function resolve(reading, clock, now, nowNanos, guess) {
if (reading.epoch !== null)
return reading.epoch;
// A total past what a double holds exactly lands past the years mirage
// holds unless another unit's cancels it, which mirage refuses rather than
// round; Python's integers keep it exact.
const [months, shift, seconds] = [reading.months, reading.days, reading.seconds].map((total) => {
if (total > EXACT || total < -EXACT)
throw new NotADate();
return Number(total);
});
const here = clock.offset(now);
let [year, month, day, hour, minute, second] = wallFields(now + here);
let nanos = nowNanos;
const fromNow = reading.relative && reading.date === null && reading.clock === null && reading.weekday === null;
if (reading.date !== null) {
const [given, m, d] = reading.date;
month = m;
day = d;
if (given !== null)
year = given;
}
if (reading.clock !== null) {
;
[hour, minute, second, nanos] = reading.clock;
}
else if (!fromNow) {
hour = 0;
minute = 0;
second = 0;
nanos = 0;
}
if (month < 1 || month > 12)
return null;
const monthDays = civilDays(year, month + 1, 1) - civilDays(year, month, 1);
if (day < 1 || day > monthDays)
return null;
if (hour > 23 || minute > 59 || second > 59)
return null;
let days = civilDays(year, month, day);
const within = hour * 3600 + minute * 60 + second;
const state = reading.dst ?? clock.isdst(now);
let instant;
if (reading.zone !== null) {
// A zone given reads the wall clock as its own, whether or not the
// zone's clock shows it.
instant = days * DAY + within - reading.zone;
}
else if (fromNow && reading.dst === null) {
instant = now;
}
else {
// A shift of now's wall clock starts in the daylight state the zone name
// asks for.
const settled = clock.settle(days * DAY + within, guess, reading.dst);
if (settled === null)
return null;
instant = settled;
}
let moving = shift !== 0 || months !== 0;
if (reading.weekday !== null && reading.date === null) {
days += weekdayShift(mod7(days + 4), ...reading.weekday);
[year, month, day] = wallFields(days * DAY);
moving = true;
}
if (moving) {
const wall = (civilDays(year, month + months, day) + shift) * DAY + within;
// A shifted wall clock past what a double holds exactly is refused too,
// rather than rounded.
if (!Number.isSafeInteger(wall))
throw new NotADate();
// A name both seasons use places the shift where it lands.
instant =
reading.dst !== null || (fromNow && !reading.named)
? clock.keepState(wall, state, guess)
: clock.place(wall, clock.offset(instant));
if (reading.zone !== null)
instant += clock.offset(instant) - reading.zone;
}
const total = nanos + reading.nanos;
const carry = Math.floor(total / NANOS);
return [instant + seconds + carry, total - carry * NANOS];
}
/**
* A leading `TZ="..."` and the rest, or no zone and the text. Inside the
* quotes a backslash escapes a backslash or a quote, and nothing else.
*/
function splitZone(text) {
let start = 0;
while (start < text.length && BLANKS.includes(text.charAt(start)))
start++;
const lead = text.slice(start);
if (!lead.startsWith('TZ="'))
return [null, text];
let value = '';
let at = 4;
while (at < lead.length) {
let c = lead.charAt(at);
if (c === '"')
return [value, lead.slice(at + 1)];
if (c === '\\') {
const escaped = lead.charAt(at + 1);
if (escaped !== '\\' && escaped !== '"')
throw new NotADate();
c = escaped;
at++;
}
value += c;
at++;
}
throw new NotADate();
}
/**
* Parse a free-form date expression as `date -d` reads it, or null for date's
* `invalid date`. The grammar is coreutils 9.7's as measured on
* debian:stable-slim: calendar dates (`2026-01-15`, `1/15/26`, `15 Jan 2026`,
* `Jan 15, 2026`, `20260115`), times of day (`10:30:45.5`, `9pm`) with zone
* corrections (`+05:30`), zone names (`UTC`, `EST DST`, `UTC+3`, military
* letters), days of the week (`next monday`), relative items (`2 days ago`,
* `tomorrow`), `@epoch` and a leading `TZ="zone"`, with parenthesized
* comments ignored. Mirage also refuses a moment outside years 1-9999, which
* date shows. Mirrors Python's parse_date_expr.
*/
export function parseDateExpr(text, zone, now) {
try {
const [inner, rest] = splitZone(text);
const ms = (now ?? systemDate()).getTime();
const current = Math.floor(ms / 1000);
const currentNanos = (ms - current * 1000) * 1_000_000;
const outer = new Clock(zone);
const clock = inner === null ? outer : new Clock(resolveTz(inner));
const reading = new Parser(lex(rest), clock.names(current)).parse();
const placed = resolve(reading, clock, current, currentNanos, outer.offset(current));
if (placed === null)
return null;
const [instant, nanos] = placed;
if (!(instant >= FIRST_SECOND && instant <= LAST_SECOND))
return null;
const shown = zone.parts(new Date(instant * 1000)).year;
if (shown < 1 || shown > 9999)
return null;
return new Date(instant * 1000 + Math.floor(nanos / 1_000_000));
}
catch (err) {
if (err instanceof NotADate || err instanceof RangeError)
return null;
throw err;
}
}
const POSIX_TIME_RE = /^([0-9]{8}|[0-9]{10}|[0-9]{12})(?:\.([0-9]{2}))?$/;
// As GNU date reads it, measured on coreutils 9.7: no year is this year, a
// two-digit one is 2000-2068 up to 68 and 1969-1999 from 69, and `.ss` takes
// exactly two digits. A field out of range (`1301000024`,
// `01012500`) is not a date, nor is a wall clock the zone skips; second 60 is
// the next minute's first, as mktime reads a leap second. One divergence: GNU
// shows year 0 and year 10000, where mirage holds what Python's datetime
// holds, so a year 0 operand, a UTC moment outside years 1-9999 and the leap
// second after 9999-12-31 23:59:59 are not a date either. Mirrors the Python
// parse_posix_time.
export function parsePosixTime(text, zone, now) {
const m = POSIX_TIME_RE.exec(text);
if (m === null)
return null;
const digits = m[1] ?? '';
const pair = (at) => Number(digits.slice(at, at + 2));
const month = pair(0) - 1;
const day = pair(2);
const hour = pair(4);
const minute = pair(6);
const tail = digits.slice(8);
let year;
if (tail === '')
year = zone.parts(now ?? systemDate()).year;
else if (tail.length === 2)
year = Number(tail) + (Number(tail) <= 68 ? 2000 : 1900);
else
year = Number(tail);
const second = m[2] !== undefined ? Number(m[2]) : 0;
if (year < 1 ||
month < 0 ||
month > 11 ||
day < 1 ||
day > daysInMonth(year, month + 1) ||
hour > 23 ||
minute > 59 ||
second > 60)
return null;
const leap = second === 60;
const placed = localize(zone, {
year,
month,
day,
hour,
minute,
second: leap ? 59 : second,
ms: 0,
});
if (placed === null)
return null;
const first = placed.getTime() / 1000;
if (first < FIRST_SECOND || first + (leap ? 1 : 0) > LAST_SECOND)
return null;
if (!leap)
return placed;
if (year === 9999 && month === 11 && day === 31 && hour === 23 && minute === 59)
return null;
return new Date(placed.getTime() + 1000);
}
//# sourceMappingURL=dates.js.map