UNPKG

cron-explain-ja

Version:

cron式と日本語を相互変換するライブラリ / CLI

2,513 lines 79.9 kB
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
//#region src/cron/fields.ts
const MONTH_NAMES = {
	JAN: 1,
	FEB: 2,
	MAR: 3,
	APR: 4,
	MAY: 5,
	JUN: 6,
	JUL: 7,
	AUG: 8,
	SEP: 9,
	OCT: 10,
	NOV: 11,
	DEC: 12
};
const DOW_NAMES = {
	SUN: 0,
	MON: 1,
	TUE: 2,
	WED: 3,
	THU: 4,
	FRI: 5,
	SAT: 6
};
const SECOND_SPEC = {
	field: "second",
	label: "秒",
	min: 0,
	max: 59,
	inputMax: 59
};
const MINUTE_SPEC = {
	field: "minute",
	label: "分",
	min: 0,
	max: 59,
	inputMax: 59
};
const HOUR_SPEC = {
	field: "hour",
	label: "時",
	min: 0,
	max: 23,
	inputMax: 23
};
const DOM_SPEC = {
	field: "dayOfMonth",
	label: "日",
	min: 1,
	max: 31,
	inputMax: 31
};
const MONTH_SPEC = {
	field: "month",
	label: "月",
	min: 1,
	max: 12,
	inputMax: 12,
	names: MONTH_NAMES
};
const DOW_SPEC = {
	field: "dayOfWeek",
	label: "曜日",
	min: 0,
	max: 6,
	inputMax: 7,
	names: DOW_NAMES
};
/** 5 フィールド式の並び */
const FIELD_SPECS = [
	MINUTE_SPEC,
	HOUR_SPEC,
	DOM_SPEC,
	MONTH_SPEC,
	DOW_SPEC
];
/** 6 フィールド(秒付き)式の並び */
const FIELD_SPECS_WITH_SECONDS = [SECOND_SPEC, ...FIELD_SPECS];
const MACROS = {
	"@yearly": "0 0 1 1 *",
	"@annually": "0 0 1 1 *",
	"@monthly": "0 0 1 * *",
	"@weekly": "0 0 * * 0",
	"@daily": "0 0 * * *",
	"@midnight": "0 0 * * *",
	"@hourly": "0 * * * *"
};
/**
* crontab では有効だが、実行タイミングが日時で決まらないため説明できない指定。
* 値はエラーメッセージに使う理由。
*/
const UNSCHEDULABLE_MACROS = { "@reboot": "起動時に一度だけ実行される指定で、日時を持たないため説明できません" };
const DOW_LABELS = [
	"日曜日",
	"月曜日",
	"火曜日",
	"水曜日",
	"木曜日",
	"金曜日",
	"土曜日"
];
//#endregion
//#region src/errors.ts
/**
* cron 式の構文エラー。
*/
var CronSyntaxError = class extends Error {
	field;
	position;
	constructor(message, options = {}) {
		super(message);
		this.name = "CronSyntaxError";
		if (options.field !== void 0) this.field = options.field;
		if (options.position !== void 0) this.position = options.position;
	}
};
/**
* `parse({ strict: true })` で解釈が曖昧だったときに投げられる。
*/
var ParseAmbiguityError = class extends Error {
	result;
	constructor(message, result) {
		super(message);
		this.name = "ParseAmbiguityError";
		this.result = result;
	}
};
/**
* タイムゾーン名を解釈できなかったときに投げられる。
*/
var CronTimeZoneError = class extends Error {
	/** 与えられたゾーン名 */
	timeZone;
	constructor(message, timeZone) {
		super(message);
		this.name = "CronTimeZoneError";
		this.timeZone = timeZone;
	}
};
//#endregion
//#region src/cron/parser.ts
/** カンマ区切りで位置情報を保ったまま分割する */
function splitParts(text, offset, separator) {
	const parts = [];
	let start = 0;
	for (let i = 0; i <= text.length; i++) if (i === text.length || text[i] === separator) {
		parts.push({
			text: text.slice(start, i),
			offset: offset + start
		});
		start = i + 1;
	}
	return parts;
}
/** 曜日の 7 は日曜(0)と同じ値を指す */
function normalizeWeekday(value, spec) {
	return spec.field === "dayOfWeek" && value === 7 ? 0 : value;
}
/** 書かれたままの値を読む。曜日の 7 は 0 に畳まない */
function parseNumber(text, spec, position) {
	const upper = text.toUpperCase();
	const named = spec.names?.[upper];
	if (named !== void 0) return named;
	if (!/^\d{1,2}$/.test(text)) throw new CronSyntaxError(`${spec.label} フィールドの値 '${text}' を解釈できません`, {
		field: spec.field,
		position
	});
	const value = Number(text);
	if (value < spec.min || value > spec.inputMax) throw new CronSyntaxError(`${spec.label} フィールドの値 ${value} は範囲外です (${spec.min}-${spec.inputMax})`, {
		field: spec.field,
		position
	});
	return value;
}
/** 値を読んで、曜日の 7 を 0 に正規化する */
function parseValue(text, spec, position) {
	return normalizeWeekday(parseNumber(text, spec, position), spec);
}
function parseAtom(text, spec, position) {
	if (text === "") throw new CronSyntaxError(`${spec.label} フィールドが空です`, {
		field: spec.field,
		position
	});
	if (text === "*") return { kind: "any" };
	if (text === "?") {
		if (spec.field !== "dayOfMonth" && spec.field !== "dayOfWeek") throw new CronSyntaxError(`'?' は日・曜日フィールドでのみ使用できます`, {
			field: spec.field,
			position
		});
		return { kind: "noSpecific" };
	}
	if (spec.field === "dayOfMonth") {
		if (text === "L") return { kind: "last" };
		const lastOffset = /^L-(\d{1,2})$/.exec(text);
		if (lastOffset?.[1] !== void 0) {
			const offset = Number(lastOffset[1]);
			if (offset > 30) throw new CronSyntaxError(`'L-${offset}' のオフセットは 0-30 の範囲で指定してください`, {
				field: spec.field,
				position
			});
			return {
				kind: "last",
				offset
			};
		}
		const nearest = /^(\d{1,2})W$/i.exec(text);
		if (nearest?.[1] !== void 0) return {
			kind: "nearestWeekday",
			day: parseValue(nearest[1], spec, position)
		};
	}
	if (spec.field === "dayOfWeek") {
		const hash = text.indexOf("#");
		if (hash >= 0) {
			const weekday = parseValue(text.slice(0, hash), spec, position);
			const nthText = text.slice(hash + 1);
			if (!/^[1-5]$/.test(nthText)) throw new CronSyntaxError(`'#' の後は 1-5 で指定してください`, {
				field: spec.field,
				position: position + hash + 1
			});
			return {
				kind: "nth",
				weekday,
				nth: Number(nthText)
			};
		}
		if (/^.+L$/i.test(text)) return {
			kind: "nth",
			weekday: parseValue(text.slice(0, -1), spec, position),
			nth: -1
		};
		if (text.toUpperCase() === "L") throw new CronSyntaxError(`曜日フィールドの 'L' は '5L' のように曜日と組み合わせてください`, {
			field: spec.field,
			position
		});
	}
	const dash = text.indexOf("-");
	if (dash > 0) {
		const from = parseNumber(text.slice(0, dash), spec, position);
		const to = parseNumber(text.slice(dash + 1), spec, position + dash + 1);
		if (spec.field === "dayOfWeek" && from === spec.min && to === spec.inputMax) return {
			kind: "range",
			from: spec.min,
			to: spec.max
		};
		return {
			kind: "range",
			from: normalizeWeekday(from, spec),
			to: normalizeWeekday(to, spec)
		};
	}
	return {
		kind: "value",
		value: parseValue(text, spec, position)
	};
}
function parseItem(text, spec, position) {
	const slash = text.indexOf("/");
	if (slash < 0) return parseAtom(text, spec, position);
	const stepText = text.slice(slash + 1);
	if (!/^\d{1,2}$/.test(stepText)) throw new CronSyntaxError(`${spec.label} フィールドの刻み幅 '${stepText}' を解釈できません`, {
		field: spec.field,
		position: position + slash + 1
	});
	const step = Number(stepText);
	if (step < 1) throw new CronSyntaxError(`${spec.label} フィールドの刻み幅は 1 以上で指定してください`, {
		field: spec.field,
		position: position + slash + 1
	});
	const baseText = text.slice(0, slash);
	const base = parseAtom(baseText === "" ? "*" : baseText, spec, position);
	if (base.kind === "value") return {
		kind: "step",
		base: parseAtom(`${baseText}-${spec.inputMax}`, spec, position),
		step
	};
	if (base.kind !== "any" && base.kind !== "range") throw new CronSyntaxError(`${spec.label} フィールドの刻みは '*' か範囲にのみ指定できます`, {
		field: spec.field,
		position
	});
	return {
		kind: "step",
		base,
		step
	};
}
function parseField(text, spec, offset) {
	const items = splitParts(text, offset, ",").map((part) => parseItem(part.text, spec, part.offset));
	const first = items[0];
	if (first === void 0) throw new CronSyntaxError(`${spec.label} フィールドが空です`, {
		field: spec.field,
		position: offset
	});
	if (items.length === 1) return first;
	return {
		kind: "list",
		items
	};
}
function collectExtensions(ast, into) {
	switch (ast.kind) {
		case "last":
			into.add("L");
			break;
		case "nth":
			into.add(ast.nth === -1 ? "L" : "#");
			break;
		case "nearestWeekday":
			into.add("W");
			break;
		case "noSpecific":
			into.add("?");
			break;
		case "list":
			for (const item of ast.items) collectExtensions(item, into);
			break;
		case "step": collectExtensions(ast.base, into);
	}
}
/**
* cron 式を構文解析する。不正な式は {@link CronSyntaxError} を投げる。
*/
function parseExpression(expression, options = {}) {
	if (typeof expression !== "string") throw new CronSyntaxError("cron 式は文字列で指定してください");
	const trimmed = expression.trim();
	if (trimmed === "") throw new CronSyntaxError("cron 式が空です");
	let source = trimmed;
	let macro;
	if (source.startsWith("@")) {
		const token = /^\S+/.exec(source)?.[0] ?? source;
		const lower = token.toLowerCase();
		const expanded = MACROS[lower];
		if (expanded === void 0) {
			const reason = UNSCHEDULABLE_MACROS[lower];
			if (reason !== void 0) throw new CronSyntaxError(`'${token}' は${reason}`, { position: 0 });
			throw new CronSyntaxError(`マクロ '${token}' には対応していません`, { position: 0 });
		}
		macro = token;
		source = expanded;
	}
	const specs = options.seconds === true ? FIELD_SPECS_WITH_SECONDS : FIELD_SPECS;
	const fields = [];
	const re = /\S+/g;
	let match = re.exec(source);
	while (match !== null) {
		fields.push({
			text: match[0],
			offset: match.index
		});
		match = re.exec(source);
	}
	if (fields.length !== specs.length) {
		const hint = fields.length === 6 && options.seconds !== true ? "。秒付きの式は seconds オプションを有効にしてください" : "";
		throw new CronSyntaxError(`フィールド数が不正です(${specs.length} 個必要ですが ${fields.length} 個でした)${hint}`, { position: 0 });
	}
	const parsed = specs.map((spec, index) => {
		const part = fields[index];
		/* c8 ignore next -- fields.length === specs.length を確認済み */
		if (part === void 0) throw new CronSyntaxError("フィールド数が不正です");
		return {
			spec,
			part,
			ast: parseField(part.text, spec, part.offset)
		};
	});
	const byField = new Map(parsed.map((entry) => [entry.spec.field, entry]));
	const get = (field) => {
		const entry = byField.get(field);
		/* c8 ignore next -- specs は必ず全フィールドを含む */
		if (entry === void 0) throw new CronSyntaxError("フィールドが不足しています");
		return entry;
	};
	const minute = get("minute");
	const hour = get("hour");
	const dayOfMonth = get("dayOfMonth");
	const month = get("month");
	const dayOfWeek = get("dayOfWeek");
	const seconds = options.seconds === true ? get("second") : void 0;
	const ast = {
		minute: minute.ast,
		hour: hour.ast,
		dayOfMonth: dayOfMonth.ast,
		month: month.ast,
		dayOfWeek: dayOfWeek.ast
	};
	if (seconds !== void 0) ast.seconds = seconds.ast;
	const extensions = /* @__PURE__ */ new Set();
	for (const entry of parsed) collectExtensions(entry.ast, extensions);
	const result = {
		ast,
		raw: {
			minute: minute.part.text,
			hour: hour.part.text,
			dayOfMonth: dayOfMonth.part.text,
			month: month.part.text,
			dayOfWeek: dayOfWeek.part.text
		},
		extensions: [...extensions]
	};
	if (seconds !== void 0) result.raw.seconds = seconds.part.text;
	if (macro !== void 0) result.macro = macro;
	return result;
}
//#endregion
//#region src/cron/values.ts
/** 値に展開できない拡張構文(L / # / W)を含むか。`?` は制約なしなので含めない */
function hasExtension(ast) {
	switch (ast.kind) {
		case "last":
		case "nth":
		case "nearestWeekday": return true;
		case "list": return ast.items.some(hasExtension);
		case "step": return hasExtension(ast.base);
		default: return false;
	}
}
/** `*` あるいは `?`(=制約なし)か */
function isAny(ast) {
	if (ast.kind === "any" || ast.kind === "noSpecific") return true;
	if (ast.kind === "step") return ast.step === 1 && isAny(ast.base);
	return false;
}
/** 表記は違っても、そのフィールドの全値を含むか(`0-6` など) */
function coversAll(ast, spec) {
	if (isAny(ast)) return true;
	if (hasExtension(ast)) return false;
	return expandField(ast, spec).length === spec.max - spec.min + 1;
}
/**
* フィールドの全域に対する刻みなら、その刻み幅を返す。
* `0-59/5` のように範囲が全域を覆う場合を含み、`*` を base にしたものと同じ意味になる。
*/
function fullRangeStep(ast, spec) {
	if (ast.kind !== "step") return void 0;
	const base = ast.base;
	if (!(isAny(base) || base.kind === "range" && base.from === spec.min && base.to === spec.max)) return void 0;
	if (ast.step > spec.max - spec.min) return void 0;
	return ast.step;
}
/**
* 「AからBまでNごと」と説明できる刻みなら、その範囲と刻み幅を返す。
* 幅を超える刻みは起点で 1 回動くだけなので、範囲としては説明しない。
*/
function rangeStep(ast, spec) {
	if (ast.kind !== "step" || ast.base.kind !== "range") return void 0;
	const { from, to } = ast.base;
	const span = spec.max - spec.min + 1;
	const width = from <= to ? to - from : span - from + to;
	if (ast.step > width) return void 0;
	return {
		from,
		to,
		step: ast.step
	};
}
function rangeValues(from, to, spec, step) {
	const values = [];
	const span = spec.max - spec.min + 1;
	const length = from <= to ? to - from + 1 : span - from + to + 1;
	for (let i = 0; i < length; i += step) values.push(spec.min + (from - spec.min + i) % span);
	return values;
}
/**
* フィールドの構文木を実際の値へ展開する。
* 拡張構文(L / # / W)は静的に展開できないため空配列を返す。
*/
function expandField(ast, spec) {
	const values = collect$1(ast, spec);
	return [...new Set(values)].sort((a, b) => a - b);
}
function collect$1(ast, spec) {
	switch (ast.kind) {
		case "any":
		case "noSpecific": return rangeValues(spec.min, spec.max, spec, 1);
		case "value": return [ast.value];
		case "range": return rangeValues(ast.from, ast.to, spec, 1);
		case "step": {
			const base = ast.base;
			if (base.kind === "range") return rangeValues(base.from, base.to, spec, ast.step);
			return rangeValues(spec.min, spec.max, spec, ast.step);
		}
		case "list": return ast.items.flatMap((item) => collect$1(item, spec));
		default: return [];
	}
}
/** 構文木を正規化された cron 表記へ戻す */
function formatField(ast) {
	switch (ast.kind) {
		case "any": return "*";
		case "noSpecific": return "?";
		case "value": return String(ast.value);
		case "range": return `${ast.from}-${ast.to}`;
		case "step": return `${formatField(ast.base)}/${ast.step}`;
		case "list": return ast.items.map(formatField).join(",");
		case "last": return ast.offset === void 0 ? "L" : `L-${ast.offset}`;
		case "nth": return ast.nth === -1 ? `${ast.weekday}L` : `${ast.weekday}#${ast.nth}`;
		case "nearestWeekday": return `${ast.day}W`;
	}
}
/** 連続した値の並びを範囲に畳む([1,2,3,5] → [[1,3],[5,5]]) */
function toRanges(values) {
	const ranges = [];
	for (const value of values) {
		const last = ranges[ranges.length - 1];
		if (last !== void 0 && value === last[1] + 1) last[1] = value;
		else ranges.push([value, value]);
	}
	return ranges;
}
/** 構文木を正規化された cron 式へ戻す */
function formatExpression(ast) {
	const fields = [
		ast.minute,
		ast.hour,
		ast.dayOfMonth,
		ast.month,
		ast.dayOfWeek
	].map(formatField);
	if (ast.seconds !== void 0) fields.unshift(formatField(ast.seconds));
	return fields.join(" ");
}
//#endregion
//#region src/cron/next.ts
/** 瞬間 → UTC の壁時計 */
function partsOf(date) {
	return {
		year: date.getUTCFullYear(),
		month: date.getUTCMonth() + 1,
		day: date.getUTCDate(),
		hour: date.getUTCHours(),
		minute: date.getUTCMinutes(),
		second: date.getUTCSeconds(),
		dayOfWeek: date.getUTCDay()
	};
}
/** UTC の壁時計 → 瞬間。範囲外の値(13 月・32 日・24 時など)は繰り上がる */
function dateOf(year, month, day, hour, minute, second) {
	return new Date(Date.UTC(year, month - 1, day, hour, minute, second, 0));
}
/** sorted の中で value 以上の最小値。無ければ null */
function atLeast(sorted, value) {
	for (const candidate of sorted) if (candidate >= value) return candidate;
	return null;
}
/** 5 年先まで探索する */
const SEARCH_YEARS = 5;
/** 無限ループを避けるための保険 */
const MAX_ITERATIONS = 2e6;
/**
* cron 式の次回実行日時を求める。
*
* cron 式は UTC のサーバーで動くものとして解釈する。返るのは絶対時刻なので、
* どのタイムゾーンで表示するかは呼び出し側の裁量。
*
* `L` / `#` / `W` を含む式は v1 では計算対象外で、空配列を返す。
*/
function next(expression, options = {}) {
	const parsed = parseExpression(expression, options.seconds === true ? { seconds: true } : {});
	if (parsed.extensions.some((extension) => extension !== "?")) return [];
	const count = options.count ?? 3;
	if (count <= 0) return [];
	const from = options.from ?? /* @__PURE__ */ new Date();
	if (Number.isNaN(from.getTime())) return [];
	const ast = parsed.ast;
	const seconds = ast.seconds === void 0 ? [0] : expandField(ast.seconds, SECOND_SPEC);
	const minutes = expandField(ast.minute, MINUTE_SPEC);
	const hours = expandField(ast.hour, HOUR_SPEC);
	const months = expandField(ast.month, MONTH_SPEC);
	const days = expandField(ast.dayOfMonth, DOM_SPEC);
	const weekdays = expandField(ast.dayOfWeek, DOW_SPEC);
	if ([
		seconds,
		minutes,
		hours,
		months
	].some((values) => values.length === 0)) return [];
	const domRestricted = !isAny(ast.dayOfMonth);
	const dowRestricted = !isAny(ast.dayOfWeek);
	const daySet = new Set(days);
	const weekdaySet = new Set(weekdays);
	const monthSet = new Set(months);
	const dayMatches = (parts) => {
		if (domRestricted && dowRestricted) return daySet.has(parts.day) || weekdaySet.has(parts.dayOfWeek);
		if (domRestricted) return daySet.has(parts.day);
		if (dowRestricted) return weekdaySet.has(parts.dayOfWeek);
		return true;
	};
	const limitYear = partsOf(from).year + SEARCH_YEARS;
	const initial = partsOf(new Date(from.getTime() + 1e3));
	let cursor = dateOf(initial.year, initial.month, initial.day, initial.hour, initial.minute, initial.second);
	const results = [];
	let iterations = 0;
	while (results.length < count && iterations < MAX_ITERATIONS) {
		iterations += 1;
		const parts = partsOf(cursor);
		if (parts.year > limitYear) break;
		if (!monthSet.has(parts.month)) {
			cursor = dateOf(parts.year, parts.month + 1, 1, 0, 0, 0);
			continue;
		}
		if (!dayMatches(parts)) {
			cursor = dateOf(parts.year, parts.month, parts.day + 1, 0, 0, 0);
			continue;
		}
		const hour = atLeast(hours, parts.hour);
		if (hour === null) {
			cursor = dateOf(parts.year, parts.month, parts.day + 1, 0, 0, 0);
			continue;
		}
		if (hour !== parts.hour) {
			cursor = dateOf(parts.year, parts.month, parts.day, hour, 0, 0);
			continue;
		}
		const minute = atLeast(minutes, parts.minute);
		if (minute === null) {
			cursor = dateOf(parts.year, parts.month, parts.day, parts.hour + 1, 0, 0);
			continue;
		}
		if (minute !== parts.minute) {
			cursor = dateOf(parts.year, parts.month, parts.day, parts.hour, minute, 0);
			continue;
		}
		const second = atLeast(seconds, parts.second);
		if (second === null) {
			cursor = dateOf(parts.year, parts.month, parts.day, parts.hour, parts.minute + 1, 0);
			continue;
		}
		if (second !== parts.second) {
			cursor = dateOf(parts.year, parts.month, parts.day, parts.hour, parts.minute, second);
			continue;
		}
		results.push(new Date(cursor.getTime()));
		cursor = new Date(cursor.getTime() + 1e3);
	}
	return results;
}
//#endregion
//#region src/cron/zone.ts
/**
* 自然言語(日本語)側の既定のタイムゾーン。
*
* cron 式は常に UTC のサーバーで動くものとして扱い、日本語はこのゾーンの壁時計として読む。
*/
const DEFAULT_TIME_ZONE = "Asia/Tokyo";
/** Intl.DateTimeFormat の生成は重いのでゾーンごとに使い回す */
const formatters = /* @__PURE__ */ new Map();
function formatter(timeZone) {
	const cached = formatters.get(timeZone);
	if (cached !== void 0) return cached;
	const created = new Intl.DateTimeFormat("en-US", {
		timeZone,
		hourCycle: "h23",
		year: "numeric",
		month: "2-digit",
		day: "2-digit",
		hour: "2-digit",
		minute: "2-digit",
		second: "2-digit"
	});
	formatters.set(timeZone, created);
	return created;
}
/**
* `'local'` を実行環境のゾーン名に、未指定を {@link DEFAULT_TIME_ZONE} に解決し、
* IANA の正規名に揃えて返す。
*
* @throws {CronTimeZoneError} 実行環境が知らないゾーン名の場合
*/
function resolveTimeZone(tz) {
	if (tz === void 0) return DEFAULT_TIME_ZONE;
	const requested = tz === "local" ? new Intl.DateTimeFormat().resolvedOptions().timeZone : tz;
	try {
		return new Intl.DateTimeFormat("en-US", { timeZone: requested }).resolvedOptions().timeZone;
	} catch {
		throw new CronTimeZoneError(`タイムゾーン '${tz}' を解釈できません`, tz);
	}
}
/** 壁時計を UTC とみなしたミリ秒 */
function asUtc(wall) {
	return Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second);
}
/** その瞬間の壁時計と、そのときのゾーンオフセット(分) */
function wallClockWithOffset(timeZone, date) {
	const found = {};
	for (const part of formatter(timeZone).formatToParts(date)) if (part.type !== "literal") found[part.type] = part.value;
	const year = Number(found.year);
	const month = Number(found.month);
	const day = Number(found.day);
	const wall = {
		year,
		month,
		day,
		hour: Number(found.hour) % 24,
		minute: Number(found.minute),
		second: Number(found.second),
		dayOfWeek: new Date(Date.UTC(year, month - 1, day)).getUTCDay()
	};
	return {
		wall,
		offset: (asUtc(wall) - Math.floor(date.getTime() / 1e3) * 1e3) / 6e4
	};
}
/** その瞬間のゾーンオフセット(分)。UTC より東が正 */
function offsetMinutes(timeZone, date) {
	return wallClockWithOffset(timeZone, date).offset;
}
const fixedOffsets = /* @__PURE__ */ new Map();
/**
* 変わらないゾーンオフセット(分)を返す。
*
* cron 式の書き換えは固定のオフセットでしか行えないため、オフセットが動くゾーンは
* ここで弾く。書き換えた式は crontab に貼られたあと何年も動き続けるので、
* 今年だけでなく**翌年まで**を 1 か月ごとにサンプルして、すべて同じであることを確かめる
* (夏時間を来年から始める国を今年のぶんだけ見て通すと、半年ずれた式を黙って出すことになる)。
*
* @throws {CronTimeZoneError} 期間内でオフセットが変わる場合
*/
function fixedOffsetMinutes(timeZone, reference = /* @__PURE__ */ new Date()) {
	const year = reference.getUTCFullYear();
	const key = `${timeZone}@${year}`;
	const cached = fixedOffsets.get(key);
	if (cached !== void 0) return cached;
	const offsets = /* @__PURE__ */ new Set();
	for (let month = 0; month < 24; month += 1) offsets.add(offsetMinutes(timeZone, new Date(Date.UTC(year, month, 15, 12))));
	const [offset] = [...offsets];
	if (offsets.size > 1 || offset === void 0) throw new CronTimeZoneError(`${timeZone} は ${year}-${year + 1} 年にオフセットが変わる(夏時間など)ため、cron 式を固定のオフセットに書き換えられません`, timeZone);
	fixedOffsets.set(key, offset);
	return offset;
}
//#endregion
//#region src/cron/shift.ts
const MINUTES_PER_DAY = 1440;
/** 日をまたぐ書き換えが月の長さに左右されないのは 1-28 日の範囲だけ */
const SAFE_DAY_MIN = 1;
const SAFE_DAY_MAX = 28;
function mod(value, size) {
	return (value % size + size) % size;
}
function sorted(values) {
	return [...new Set(values)].sort((a, b) => a - b);
}
function sameValues(left, right) {
	return left.length === right.length && left.every((value, index) => value === right[index]);
}
/** 「+9:00」「-4:30」 */
function formatOffset(minutes) {
	const absolute = Math.abs(minutes);
	const hour = Math.floor(absolute / 60);
	const minute = absolute % 60;
	return `${minutes < 0 ? "-" : "+"}${hour}:${String(minute).padStart(2, "0")}`;
}
/**
* 値の並びから、なるべく自然な構文木を組み立てる。
*
* 連番は範囲に、等間隔は刻みに畳む。説明文の質がここで決まる
* (`1,3,5,…,23` を並べ立てず `1-23/2` にする)。
*/
function fromValues(values, spec) {
	const first = values[0];
	const last = values[values.length - 1];
	/* c8 ignore next -- 呼び出し側が空配列を渡さない */
	if (first === void 0 || last === void 0) return { kind: "any" };
	if (values.length === spec.max - spec.min + 1) return { kind: "any" };
	if (values.length === 1) return {
		kind: "value",
		value: first
	};
	const step = values[1] - first;
	if (values.length > 2 && step > 1 && values.every((value, index) => index === 0 || value - values[index - 1] === step)) return {
		kind: "step",
		base: first === spec.min && last + step > spec.max ? { kind: "any" } : {
			kind: "range",
			from: first,
			to: last
		},
		step
	};
	const items = toRanges(values).map(([from, to]) => from === to ? {
		kind: "value",
		value: from
	} : {
		kind: "range",
		from,
		to
	});
	return items.length === 1 ? items[0] : {
		kind: "list",
		items
	};
}
/**
* cron 式(UTC) と `timeZone` の壁時計の間で、フィールドをずらす。
*
* cron 式は「フィールドごとに独立した値の集合」しか表せないので、ずらした結果が
* その形に収まらない場合は書き換えられない。収まらないまま近い式を返すと、
* 半年ずれた予定を黙って出すことになるため {@link CronTimeZoneError} で失敗させる。
*
* @throws {CronTimeZoneError} ゾーンのオフセットが動く、または結果が cron 式で表せない場合
*/
function shiftAst(ast, timeZone, direction) {
	if (timeZone === "UTC") return ast;
	const zoneOffset = fixedOffsetMinutes(timeZone);
	const deltaMinutes = direction === "toLocal" ? zoneOffset : -zoneOffset;
	if (deltaMinutes === 0) return ast;
	const offset = formatOffset(zoneOffset);
	const fail = (reason) => {
		throw new CronTimeZoneError(`${timeZone}(時差 ${offset})では${reason}`, timeZone);
	};
	const minutes = expandField(ast.minute, MINUTE_SPEC);
	const hours = expandField(ast.hour, HOUR_SPEC);
	if (minutes.length === 0 || hours.length === 0) return fail("分・時のフィールドを展開できないため、cron 式に書き換えられません");
	const moved = /* @__PURE__ */ new Set();
	const carries = /* @__PURE__ */ new Set();
	for (const hour of hours) for (const minute of minutes) {
		const value = hour * 60 + minute + deltaMinutes;
		moved.add(mod(value, MINUTES_PER_DAY));
		carries.add(Math.floor(value / MINUTES_PER_DAY));
	}
	const newMinutes = sorted([...moved].map((value) => value % 60));
	const newHours = sorted([...moved].map((value) => Math.floor(value / 60)));
	if (newMinutes.length * newHours.length !== moved.size) return fail("分と時の組み合わせが崩れるため、cron 式に書き換えられません(分の繰り上がりが時刻によって変わります)");
	const domRestricted = !isAny(ast.dayOfMonth);
	const dowRestricted = !isAny(ast.dayOfWeek);
	const monthRestricted = !coversAll(ast.month, MONTH_SPEC);
	const everyDay = !domRestricted && !dowRestricted && !monthRestricted;
	const [onlyCarry] = [...carries];
	const dayShift = carries.size === 1 && onlyCarry !== void 0 ? onlyCarry : null;
	if (dayShift === null && !everyDay) return fail("日付をまたぐ時刻とまたがない時刻が混ざるため、cron 式に書き換えられません");
	let dayOfMonth = ast.dayOfMonth;
	let dayOfWeek = ast.dayOfWeek;
	if (dayShift !== null && dayShift !== 0 && !everyDay) {
		if (hasExtension(ast.dayOfMonth) || hasExtension(ast.dayOfWeek)) return fail("日付が 1 日ずれるため、L / # / W を含む式は書き換えられません");
		if (monthRestricted && (!domRestricted || dowRestricted)) return fail("日付が 1 日ずれて月をまたぐため、cron 式に書き換えられません");
		if (domRestricted) {
			const days = expandField(ast.dayOfMonth, DOM_SPEC);
			const shiftedDays = days.map((day) => day + dayShift);
			if (!days.every((day, index) => day <= SAFE_DAY_MAX && shiftedDays[index] >= SAFE_DAY_MIN && shiftedDays[index] <= SAFE_DAY_MAX)) return fail("日付が 1 日ずれて月をまたぐ可能性があるため、cron 式に書き換えられません(1-28 日の範囲に収まる指定であれば書き換えられます)");
			dayOfMonth = fromValues(shiftedDays, DOM_SPEC);
		}
		if (dowRestricted) dayOfWeek = fromValues(sorted(expandField(ast.dayOfWeek, DOW_SPEC).map((day) => mod(day + dayShift, 7))), DOW_SPEC);
	}
	return {
		...ast,
		minute: sameValues(newMinutes, minutes) ? ast.minute : fromValues(newMinutes, MINUTE_SPEC),
		hour: sameValues(newHours, hours) ? ast.hour : fromValues(newHours, HOUR_SPEC),
		dayOfMonth,
		dayOfWeek
	};
}
/**
* cron 式の文字列を {@link shiftAst} と同じ規則でずらす。
*
* @throws {CronSyntaxError} 式が不正な場合
* @throws {CronTimeZoneError} ゾーンのオフセットが動く、または結果が cron 式で表せない場合
*/
function shiftExpression(expression, timeZone, direction, options = {}) {
	if (timeZone === "UTC") return expression;
	const { ast } = parseExpression(expression, options);
	return formatExpression(shiftAst(ast, timeZone, direction));
}
//#endregion
//#region src/cron/validate.ts
const DAYS_IN_MONTH = [
	31,
	29,
	31,
	30,
	31,
	30,
	31,
	31,
	30,
	31,
	30,
	31
];
const MONTH_LABELS = [
	"1月",
	"2月",
	"3月",
	"4月",
	"5月",
	"6月",
	"7月",
	"8月",
	"9月",
	"10月",
	"11月",
	"12月"
];
/**
* cron 式を検証する。構文エラーは throw せず {@link ValidationResult} として返す。
*/
function validate(expression, options = {}) {
	const errors = [];
	const warnings = [];
	let parsed;
	try {
		parsed = parseExpression(expression, options);
	} catch (error) {
		if (error instanceof CronSyntaxError) {
			const entry = {
				field: error.field ?? "expression",
				message: error.message
			};
			if (error.position !== void 0) entry.position = error.position;
			errors.push(entry);
			return {
				valid: false,
				errors,
				warnings
			};
		}
		/* c8 ignore next 2 -- parseExpression は CronSyntaxError しか投げない */
		throw error;
	}
	const { ast, extensions } = parsed;
	if (extensions.includes("L") || extensions.includes("#") || extensions.includes("W")) {
		const used = extensions.filter((extension) => extension !== "?").join("', '");
		warnings.push(`'${used}' は Quartz 拡張です。標準の cron(Vixie cron)では動作しません`);
		warnings.push("拡張構文を含む式では次回実行日時(next)を計算できません");
	}
	if (!isAny(ast.dayOfMonth) && !isAny(ast.dayOfWeek)) warnings.push("標準の cron では日と曜日を同時に指定すると OR 条件になり、どちらかに一致する日に実行されます");
	if (!isAny(ast.month) && !isAny(ast.dayOfMonth) && !hasExtension(ast.dayOfMonth) && !hasExtension(ast.month)) {
		const months = expandField(ast.month, MONTH_SPEC);
		const days = expandField(ast.dayOfMonth, DOM_SPEC);
		const impossible = [];
		let anyPossible = false;
		for (const month of months) {
			const limit = DAYS_IN_MONTH[month - 1] ?? 31;
			for (const day of days) if (day <= limit) anyPossible = true;
			else impossible.push(`${MONTH_LABELS[month - 1] ?? `${month}月`}${day}日`);
		}
		if (!anyPossible && impossible.length > 0) warnings.push(`${impossible.join("、")}は存在しないため、このジョブは実行されません`);
		else if (impossible.length > 0) warnings.push(`${impossible.join("、")}は存在しないため、その日は実行されません`);
		if (months.includes(2) && days.includes(29)) warnings.push("2月29日はうるう年にのみ実行されます");
	}
	return {
		valid: true,
		errors,
		warnings
	};
}
//#endregion
//#region src/explain/time.ts
function pad2(value) {
	return value < 10 ? `0${value}` : String(value);
}
/**
* 「午前9時」「15時」のように時だけを表す。
*/
function formatHour(hour, style) {
	if (style.hour === "24h") return `${hour}時`;
	if (hour < 12) return `午前${hour}時`;
	return `午後${hour - 12}時`;
}
/**
* 「午前9時」「午後6時30分」「正午」のように時刻を表す。
*/
function formatTime(hour, minute, style) {
	if (style.style === "casual" && style.hour === "12h" && hour === 12 && minute === 0) return "正午";
	const base = formatHour(hour, style);
	if (style.style === "formal") return `${base}${pad2(minute)}分`;
	return minute === 0 ? base : `${base}${minute}分`;
}
//#endregion
//#region src/explain/field.ts
/** 「AとB」「A、B、C」 */
function joinJa(items) {
	if (items.length === 0) return "";
	if (items.length === 1) return items[0] ?? "";
	if (items.length === 2) return `${items[0]}と${items[1]}`;
	return items.join("、");
}
/**
* 値の並びを、連続部分を範囲に畳みながら日本語にする。
*/
function describeValues(values, point, range) {
	const texts = [];
	for (const [from, to] of toRanges(values)) if (to - from >= 2) texts.push(range(from, to));
	else for (let value = from; value <= to; value++) texts.push(point(value));
	return joinJa(texts);
}
function describeMinuteValues(ast) {
	return describeValues(expandField(ast, MINUTE_SPEC), (value) => `${value}分`, (from, to) => `${from}分から${to}分まで`);
}
/**
* 分フィールドを、文の組み立てに使える形に分解する。
* 文単位(`compose.ts`)とフィールド単位({@link describeMinuteField})で
* 同じ規則を使うため、判定はここだけに置く。
*/
function minutePart(ast) {
	if (coversAll(ast, MINUTE_SPEC)) return { kind: "any" };
	const step = fullRangeStep(ast, MINUTE_SPEC);
	if (step !== void 0) return {
		kind: "step",
		text: `${step}分ごと`
	};
	const ranged = rangeStep(ast, MINUTE_SPEC);
	if (ranged !== void 0) return {
		kind: "rangeStep",
		...ranged
	};
	const values = expandField(ast, MINUTE_SPEC);
	const first = values[0];
	if (values.length === 1 && first !== void 0) return {
		kind: "single",
		value: first
	};
	return {
		kind: "values",
		text: describeMinuteValues(ast)
	};
}
/** 前に何も置かずに単独で読める分の表現 */
function minuteBare(part) {
	switch (part.kind) {
		case "any": return "毎分";
		case "step": return part.text;
		case "rangeStep": return `${part.from}分から${part.to}分まで${part.step}分ごと`;
		case "single": return `${part.value}分`;
		case "values": return part.text;
	}
}
/**
* 時の節に続けるときの、時の中での分の表現。
* 刻みは時をまたがないので、上限が 59 分なら「59分まで」は何も足さない。
* 時の範囲の「まで」と重なって読みにくくするだけなので落とす。
*/
function minuteWithinHour(part) {
	if (part.kind !== "rangeStep") return minuteBare(part);
	const { from, to, step } = part;
	if (to === MINUTE_SPEC.max) return `${from}分から${step}分ごと`;
	return `${from}分から${to}分まで${step}分ごと`;
}
/** 分フィールドの説明。全域は「毎分」、ステップ指定は「N分ごと」 */
function describeMinuteField(ast) {
	return minuteBare(minutePart(ast));
}
function describeSecondField(ast) {
	if (coversAll(ast, SECOND_SPEC)) return "毎秒";
	const step = fullRangeStep(ast, SECOND_SPEC);
	if (step !== void 0) return `${step}秒ごと`;
	return describeValues(expandField(ast, SECOND_SPEC), (value) => `${value}秒`, (from, to) => `${from}秒から${to}秒まで`);
}
function describeHourValues(ast, style) {
	return describeValues(expandField(ast, HOUR_SPEC), (value) => formatHour(value, style), (from, to) => `${formatHour(from, style)}から${formatHour(to, style)}まで`);
}
function describeHourField(ast, style) {
	if (coversAll(ast, HOUR_SPEC)) return "毎時";
	const step = fullRangeStep(ast, HOUR_SPEC);
	if (step !== void 0) return `${step}時間ごと`;
	const ranged = rangeStep(ast, HOUR_SPEC);
	if (ranged !== void 0) return `${formatHour(ranged.from, style)}から${formatHour(ranged.to, style)}まで${ranged.step}時間ごと`;
	return describeHourValues(ast, style);
}
function describeMonthField(ast) {
	if (coversAll(ast, MONTH_SPEC)) return "毎月";
	const step = fullRangeStep(ast, MONTH_SPEC);
	if (step !== void 0) return `${step}か月ごと`;
	return describeValues(expandField(ast, MONTH_SPEC), (value) => `${value}月`, (from, to) => `${from}月から${to}月まで`);
}
function describeDayOfMonthField(ast) {
	if (coversAll(ast, DOM_SPEC)) return "毎日";
	if (ast.kind === "last") return ast.offset === void 0 ? "月末" : `月末の${ast.offset}日前`;
	if (ast.kind === "nearestWeekday") return `${ast.day}日に最も近い平日`;
	const step = fullRangeStep(ast, DOM_SPEC);
	if (step !== void 0) return `${step}日ごと`;
	if (ast.kind === "list" && hasExtension(ast)) return joinJa(ast.items.map(describeDayOfMonthField));
	return describeValues(expandField(ast, DOM_SPEC), (value) => `${value}日`, (from, to) => `${from}日から${to}日まで`);
}
function dowLabel(value) {
	return DOW_LABELS[value] ?? `${value}`;
}
function describeDayOfWeekField(ast, options) {
	if (ast.kind === "any" || ast.kind === "noSpecific") return "毎日";
	if (ast.kind === "nth") {
		const label = dowLabel(ast.weekday);
		return ast.nth === -1 ? `最終${label}` : `第${ast.nth}${label}`;
	}
	if (ast.kind === "list" && hasExtension(ast)) return joinJa(ast.items.map((item) => describeDayOfWeekField(item, {
		...options,
		weekly: false
	})));
	const values = expandField(ast, DOW_SPEC);
	if (values.length === 7) return "毎日";
	if (options.collapse) {
		const key = values.join(",");
		if (key === "1,2,3,4,5") return "平日";
		if (key === "0,6") return "週末";
	}
	const text = describeValues(values, dowLabel, (from, to) => `${dowLabel(from)}から${dowLabel(to)}まで`);
	return options.weekly ? `毎週${text}` : text;
}
//#endregion
//#region src/explain/compose.ts
/** 「毎分」「15分ごと」「毎時0分」のように、時を限定しない文での分の表現 */
function minuteAlone(part) {
	switch (part.kind) {
		case "any":
		case "step":
		case "rangeStep": return minuteBare(part);
		case "single": return `毎時${part.value}分`;
		case "values": return `毎時${part.text}`;
	}
}
/**
* 時の節に続けるときの分の表現。
* 「AからBまでNごと」を自己完結した形のまま置くと、時の範囲の「まで」と重なって
* 「午前9時から午後5時まで5分から59分まで15分ごと」のように読めなくなる。
* 「毎時」で分の節の始まりを示し、時の中での位置として組み立てる。
*/
function minuteAfterHours(part) {
	if (part.kind === "rangeStep") return `毎時${minuteWithinHour(part)}`;
	return minuteAlone(part);
}
/**
* 時刻部分を組み立てる。
*/
function describeTime(ast, options) {
	const minute = minutePart(ast.minute);
	const hour = ast.hour;
	let result;
	const hourStep = fullRangeStep(hour, HOUR_SPEC);
	const hourRange = rangeStep(hour, HOUR_SPEC);
	if (coversAll(hour, HOUR_SPEC)) result = {
		text: minuteAlone(minute),
		frequency: true
	};
	else if (hourStep !== void 0) result = {
		text: `${hourStep}時間ごと(${minuteAfterHours(minute)})`,
		frequency: true
	};
	else if (hourRange !== void 0) result = {
		text: `${formatHour(hourRange.from, options)}から${formatHour(hourRange.to, options)}まで${`${hourRange.step}時間ごと(${minuteAfterHours(minute)})`}`,
		frequency: false
	};
	else {
		const values = expandField(hour, HOUR_SPEC);
		const allPoints = toRanges(values).every(([from, to]) => to - from < 2);
		if (allPoints && minute.kind === "single") result = {
			text: joinJa(values.map((value) => formatTime(value, minute.value, options))),
			frequency: false
		};
		else if (allPoints) result = {
			text: `${joinJa(values.map((value) => `${formatHour(value, options)}台`))}の${minuteWithinHour(minute)}`,
			frequency: false
		};
		else result = {
			text: `${describeHourValues(hour, options)}${minuteAfterHours(minute)}`,
			frequency: false
		};
	}
	const seconds = ast.seconds;
	if (seconds === void 0) return result;
	if (seconds.kind === "value" && seconds.value === 0) return result;
	if (coversAll(ast.hour, HOUR_SPEC) && coversAll(ast.minute, MINUTE_SPEC)) {
		if (coversAll(seconds, SECOND_SPEC)) return {
			text: "毎秒",
			frequency: true
		};
		const secondStep = fullRangeStep(seconds, SECOND_SPEC);
		if (secondStep !== void 0) return {
			text: `${secondStep}秒ごと`,
			frequency: true
		};
	}
	return {
		text: `${result.text}の${describeSecondField(seconds)}`,
		frequency: result.frequency
	};
}
/**
* 日付部分を組み立てる。
*/
function describeDate(ast, options) {
	const domAll = coversAll(ast.dayOfMonth, DOM_SPEC);
	const monthAny = coversAll(ast.month, MONTH_SPEC);
	const dowAll = coversAll(ast.dayOfWeek, DOW_SPEC);
	const orCoversEveryDay = !isAny(ast.dayOfMonth) && !isAny(ast.dayOfWeek) && (domAll || dowAll);
	const domAny = domAll || orCoversEveryDay;
	const dowAny = dowAll || orCoversEveryDay;
	if (domAny && monthAny && dowAny) return {
		text: "毎日",
		joinWithNo: false,
		everyDay: true
	};
	const monthText = describeMonthField(ast.month);
	if (domAny && !dowAny) {
		const dow = describeDayOfWeekField(ast.dayOfWeek, {
			weekly: true,
			collapse: options.collapseWeekdays
		});
		return {
			text: monthAny ? dow : `${monthText}の${dow}`,
			joinWithNo: true,
			everyDay: false
		};
	}
	if (!domAny && dowAny) {
		const dom = describeDayOfMonthField(ast.dayOfMonth);
		if (monthAny) return {
			text: `毎月${dom}`,
			joinWithNo: true,
			everyDay: false
		};
		if (ast.month.kind === "value" && ast.dayOfMonth.kind === "value") return {
			text: `毎年${monthText}${dom}`,
			joinWithNo: true,
			everyDay: false
		};
		return {
			text: `${monthText}の${dom}`,
			joinWithNo: true,
			everyDay: false
		};
	}
	if (!domAny && !dowAny) {
		const dom = describeDayOfMonthField(ast.dayOfMonth);
		const dow = describeDayOfWeekField(ast.dayOfWeek, {
			weekly: false,
			collapse: options.collapseWeekdays
		});
		return {
			text: `${monthAny ? `毎月${dom}` : ast.month.kind === "value" && ast.dayOfMonth.kind === "value" ? `毎年${monthText}${dom}` : `${monthText}の${dom}`}および${dow}`,
			joinWithNo: true,
			everyDay: false
		};
	}
	return {
		text: `${monthText}の毎日`,
		joinWithNo: false,
		everyDay: false
	};
}
/**
* 日付部分と時刻部分を 1 文に組み立てる。
*/
function compose(ast, options) {
	const time = describeTime(ast, options);
	const date = describeDate(ast, options);
	if (date.everyDay && time.frequency) return time.text;
	return `${date.text}${date.joinWithNo ? "の" : ""}${time.text}`;
}
//#endregion
//#region src/explain/index.ts
function resolve(options) {
	return {
		style: options.style ?? "casual",
		hour: options.hour ?? "12h",
		collapseWeekdays: options.collapseWeekdays ?? true
	};
}
function parserOptions(options) {
	return options.seconds === true ? { seconds: true } : {};
}
/**
* cron 式を 1 文の日本語に変換する。
*
* 式は UTC のサーバーで動くものとして読み、`options.tz`(既定 `'Asia/Tokyo'`)の
* 壁時計に直してから日本語にする。
*
* ```ts
* explain('0 4 * * 1-5'); // '平日の午後1時'(UTC 04:00 = JST 13:00)
* explain('0 9 * * 1-5', { tz: 'UTC' }); // '平日の午前9時'(変換しない)
* ```
*
* @throws {CronSyntaxError} 式が不正な場合
* @throws {CronTimeZoneError} `tz` を解釈できない、または cron 式に書き換えられない場合
*/
function explain(expression, options = {}) {
	const parsed = parseExpression(expression, parserOptions(options));
	const timeZone = resolveTimeZone(options.tz);
	const text = compose(shiftAst(parsed.ast, timeZone, "toLocal"), resolve(options));
	return options.showTimeZone === true ? `${text}(${timeZone})` : text;
}
function kindOf(ast) {
	if (hasExtension(ast) || ast.kind === "noSpecific") return "extension";
	switch (ast.kind) {
		case "any": return "any";
		case "value": return "value";
		case "range": return "range";
		case "step": return "step";
		case "list": return "list";
		/* c8 ignore next 2 -- 拡張構文は上で処理済み */
		default: return "extension";
	}
}
function explainField(raw, ast, spec, text) {
	return {
		raw,
		kind: kindOf(ast),
		values: hasExtension(ast) ? [] : expandField(ast, spec),
		text
	};
}
/**
* cron 式をフィールド別の内訳・注意書き・次回実行日時つきで説明する。
*
* `fields` は `options.tz` の壁時計に直したあとの値を説明する(`localExpression` と対応)。
* `expression` は入力(UTC)を正規化したもの、`next` は UTC として解釈した絶対時刻。
*
* @throws {CronSyntaxError} 式が不正な場合
* @throws {CronTimeZoneError} `tz` を解釈できない、または cron 式に書き換えられない場合
*/
function explainDetailed(expression, options = {}) {
	const parsed = parseExpression(expression, parserOptions(options));
	const composeOptions = resolve(options);
	const timeZone = resolveTimeZone(options.tz);
	const ast = shiftAst(parsed.ast, timeZone, "toLocal");
	const normalized = formatExpression(parsed.ast);
	const localized = ast === parsed.ast ? normalized : formatExpression(ast);
	const raw = (field, before, after) => before === after ? parsed.raw[field] ?? formatField(after) : formatField(after);
	const dowOptions = {
		weekly: false,
		collapse: composeOptions.collapseWeekdays
	};
	const fields = {
		minute: explainField(raw("minute", parsed.ast.minute, ast.minute), ast.minute, MINUTE_SPEC, describeMinuteField(ast.minute)),
		hour: explainField(raw("hour", parsed.ast.hour, ast.hour), ast.hour, HOUR_SPEC, describeHourField(ast.hour, composeOptions)),
		dayOfMonth: explainField(raw("dayOfMonth", parsed.ast.dayOfMonth, ast.dayOfMonth), ast.dayOfMonth, DOM_SPEC, describeDayOfMonthField(ast.dayOfMonth)),
		month: explainField(raw("month", parsed.ast.month, ast.month), ast.month, MONTH_SPEC, describeMonthField(ast.month)),
		dayOfWeek: explainField(raw("dayOfWeek", parsed.ast.dayOfWeek, ast.dayOfWeek), ast.dayOfWeek, DOW_SPEC, describeDayOfWeekField(ast.dayOfWeek, dowOptions))
	};
	if (ast.seconds !== void 0) fields.second = explainField(raw("seconds", parsed.ast.seconds ?? ast.seconds, ast.seconds), ast.seconds, SECOND_SPEC, describeSecondField(ast.seconds));
	const composed = compose(ast, composeOptions);
	const text = options.showTimeZone === true ? `${composed}(${timeZone})` : composed;
	const { warnings } = validate(expression, parserOptions(options));
	return {
		text,
		expression: normalized,
		localExpression: localized,
		tz: timeZone,
		fields,
		extensions: parsed.extensions,
		notes: warnings,
		next: next(expression, {
			...parserOptions(options),
			count: 3
		})
	};
}
//#endregion
//#region src/parse/dictionary.ts
const DOW = {
	日曜日: 0,
	日曜: 0,
	月曜日: 1,
	月曜: 1,
	火曜日: 2,
	火曜: 2,
	水曜日: 3,
	水曜: 3,
	木曜日: 4,
	木曜: 4,
	金曜日: 5,
	金曜: 5,
	土曜日: 6,
	土曜: 6
};
const DOW_SET = {
	平日: [
		1,
		2,
		3,
		4,
		5
	],
	週末: [0, 6],
	土日: [0, 6],
	休日: [0, 6]
};
/**
* 曜日で近似した語と、そのずれの説明。
*
* 「休日」は祝日・振替休日も指すが、cron は曜日しか持たないので土日に寄せるほかない。
* 黙って寄せると祝日ぶんの取りこぼしに気づけないので、note と減点を付ける(§2.3.5)。
* 減点の量は `fill` が持つ。
*/
const DOW_SET_APPROX = { 休日: "「休日」は祝日も指しますが、cron では曜日しか指定できないため、週末(土日)として解釈しました。祝日には実行されません" };
const TIME_OF_DAY = {
	早朝: {
		default: 6,
		range: [4, 7]
	},
	朝: {
		default: 9,
		range: [6, 10]
	},
	午前: {
		default: null,
		range: [0, 11]
	},
	昼: {
		default: 12,
		range: [11, 13]
	},
	正午: {
		default: 12,
		range: [12, 12]
	},
	午後: {
		default: null,
		range: [12, 23]
	},
	夕方: {
		default: 18,
		range: [16, 19]
	},
	夜: {
		default: 21,
		range: [19, 23]
	},
	晩: {
		default: 21,
		range: [19, 23]
	},
	深夜: {
		default: 2,
		range: [0, 4]
	},
	夜中: {
		default: 2,
		range: [0, 4]
	}
};
const FREQ = {
	毎分: "minute",
	毎時: "hour",
	毎日: "day",
	毎週: "week",
	毎月: "month",
	毎年: "year"
};
const DOM_SPECIAL = {
	月末: "L",
	月初: 1
};
const NTH = {
	第1: 1,
	第2: 2,
	第3: 3,
	第4: 4,
	第5: 5,
	最終: -1,
	最後: -1
};
//#endregion
//#region src/parse/fill.ts
const ANY = { kind: "any" };
const FREQ_LABELS = {
	minute: "毎分",
	hour: "毎時",
	day: "毎日",
	week: "毎週",
	month: "毎月",
	year: "毎年"
};
const INTERVAL_LABELS = {
	minute: "分",
	hour: "時",
	day: "日",
	week: "週",
	month: "月"
};
/** 間隔の単位と、その起点になる値の並び */
const INTERVAL_LIST = {
	minute: "minute",
	day: "dom",
	month: "month"
};
function emptyList() {
	return {
		items: [],
		pendingRange: false,
		consumed: false
	};
}
function pushValue(list, value) {
	const last = list.items[list.items.length - 1];
	if (list.pendingRange && last !== void 0) {
		last.to = value;
		list.pendingRange = false;
		return;
	}
	list.items.push({
		from: value,
		to: value
	});
}
/** 値の並びを、連続部分を範囲に畳んだ構文木にする */
function valuesToAst(values) {
	const sorted = [...new Set(values)].sort((a, b) => a - b);
	const first = sorted[0];
	if (first === void 0) return ANY;
	if (sorted.length === 1) return {
		kind: "value",
		value: first
	};
	const items = [];
	let from = first;
	let previous = first;
	const flush = () => {
		if (previous - from >= 2) items.push({
			kind: "range",
			from,
			to: previous
		});
		else for (let value = from; value <= previous; value++) items.push({
			kind: "value",
			value
		});
	};
	for (const value of sorted.slice(1)) {
		if (value === previous + 1) {
			previous = value;
			continue;
		}
		flush();
		from = value;
		previous = value;
	}
	flush();
	const only = items[0];
	/* c8 ignore next -- items は必ず 1 個以上になる */
	if (only === void 0) return ANY;
	return items.length === 1 ? only : {
		kind: "list",
		items
	};
}
/**
* 値の並びを構文木にする。
* 「AからBまで」と書かれた範囲は範囲のまま残す(`valuesToAst` は 3 個以上の連なりしか
* 範囲に畳まないため、`28,31` のように意味が変わってしまう)。
*/
function spansToAst(spans) {
	if (spans.every((span) => span.from === span.to)) return valuesToAst(spans.map((span) => span.from));
	const items = spans.map((span) => span.from === span.to ? {
		kind: "value",
		value: span.from
	} : {
		kind: "range",
		from: span.from,
		to: span.to
	});
	const only = items[0];
	/* c8 ignore next -- 範囲を含むなら 1 個以上ある */
	if (only === void 0) return ANY;
	return items.length === 1 ? only : {
		kind: "list",
		items
	};
}
function listToAst(list) {
	return spansToAst(list.items);
}
/**
* 「AからBまでNごと」「AからNごと」の起点を取り出す。
* 範囲として書かれていなければ null を返し、値の並びは値のまま残す。
*/
function consumeBase(list, spec) {
	const span = list.items[0];
	if (span === void 0 || list.items.length !== 1) return null;
	if (!list.pendingRange && span.from === span.to) return null;
	list.consumed = true;
	return {
		kind: "range",
		from: span.from,
		to: list.pendingRange ? spec.max : span.to
	};
}
function pushTime(list, atom) {
	const last = list.items[list.items.length - 1];
	if (list.pendingRange && last !== void 0) {
		last.to = atom;
		list.pendingRange = false;
		return;
	}
	list.items.push({
		from: atom,
		to: null
	});
}
/** 刻みの構文木。起点が全域を覆うなら省く(`1-31/3` は「3日ごと」と同じ集合) */
function stepAst(base, step, spec) {
	if (base === null) return {
		kind: "step",
		base: ANY,
		step
	};
	if (base.kind === "range" && base.from === spec.min && base.to === spec.max) return {
		kind: "step",
		base: ANY,
		step
	};
	return {
		kind: "step",
		base,
		step
	};
}
/**
* 時から時間帯語の範囲までの距離(時間)。時計は巡回するので 23 時と 0 時は 1 時間差。
*/
function gapToRange(hour, [low, high]) {
	if (hour >= low && hour <= high) return 0;
	return Math.min((low - hour + 24) % 24, (hour - high + 24) % 24);
}
/**
* 時間帯語と並んだ時を読む。
*
* 12 時までの数字は 12 時間制の書き方なので、そのままの時と 12 時間ずらした時のうち、
* 語の指す時間帯に近い方を採る(「夜6時」→ 18時、「午後3時」→ 15時)。
* 13 時以降は 24 時間制としか読めないので、そのまま採る(「朝22時」→ 22時)。
*
* 範囲に入っていなくても近い方を採るのは、境界のすぐ外側で読みが裏返らないようにするため。
* 「夜」は 19 時からだが、「夜6時」を午前 6 時と読むのは行き過ぎで、離れたぶんは
* {@link fill} が note と減点で伝える(§2.3.5)。
*
* 12 時のもう一方の読みは 0 時なので、「午前12時」「夜12時」はどちらも 0 時になる。
*/
function readHour(word, hour) {
	const range = TIME_OF_DAY[word].range;
	const literal = {
		hour,
		gap: gapToRange(hour, range)
	};
	if (hour > 12) return literal;
	const shifted = (hour + 12) % 24;
	const alternative = {
		hour: shifted,
		gap: gapToRange(shifted, range)
	};
	return alternative.gap < literal.gap ? alternative : literal;
}
function hourCandidates(from, to) {
	const candidates = [];
	for (let hour = from; hour <= to; hour++) candidates.push({
		value: hour,
		label: formatHour(hour, {
			style: "casual",
			hour: "12h"
		})
	});
	return candidates;
}
function collect(tokens) {
	const state = {
		times: {
			items: [],
			pendingRange: false
		},
		hourSpan: false,
		lists: {
			minute: emptyList(),
			dom: emptyList(),
			dow: emptyList(),
			month: emptyList()
		},
		intervals: {},
		duplicateIntervals: [],
		lastAssign: {},
		nths: [],
		domSpecial: null,
		freqs: [],
		unknown: 0,
		meaningful: 0,
		todNotes: [],
		todPenalties: [],
		approxNotes: []
	};
	let pendingWord = null;
	/** 範囲の始点に掛かっていた時間帯語。終端に語が無ければこれを引き継ぐ */
	let spanWord = null;
	let pendingNth = null;
	/** 直前に読んだ値がどのスロットのものか(「から」の係り先) */
	let last = null;
	/**
	* 時刻の並びに 1 つ足す。範囲の始点なら、掛かっていた語を終端が引き継げるよう覚える
	* (終端に語が無い「夜6時から9時まで」を 18-21 と読むため)。
	*/
	const addTime = (atom, word) => {
		if (!state.times.pendingRange) spanWord = word;
		pushTime(state.times, atom);
	};
	/** 時刻に結び付かなかった時間帯語を、書かれた位置のまま並びに置く */
	const flushWord = () => {
		if (pendingWord !== null) {
			addTime({
				kind: "word",
				word: pendingWord
			}, pendingWord);
			pendingWord = null;
		}
	};
	/** 値を並びに足し、「から」の係り先として返す */
	const addValues = (name, values) => {
		for (const value of values) pushValue(state.lists[name], value);
		state.lastAssign[name] = "values";
		return name;
	};
	for (const token of tokens) {
		if (token.type !== "UNKNOWN" && token.type !== "SEP") state.meaningful += 1;
		switch (token.type) {
			case "AMPM":
			case "TIME_OF_DAY":
				flushWord();
				pendingWord = token.value;
				last = "time";
				break;
			case "TIME": {
				const time = token.value;
				let hour = time.hour;
				const inherited = state.times.pendingRange ? spanWord : null;
				const word = pendingWord ?? inherited;
				if (word !== null) {
					const reading = readHour(word, hour);
					hour = reading.hour;
					if (reading.gap > 0 && pendingWord !== null) {
						const [low, high] = TIME_OF_DAY[pendingWord].range;
						state.todNotes.push(`「${pendingWord}」は通常 ${low}時から${high}時ですが、${hour}時と解釈しました`);
						state.todPenalties.push({
							reason: "時間帯の語と時刻のずれ",
							amount: Math.min(.3, reading.gap * .1)
						});
					}
				}
				addTime({
					kind: "time",
					hour,
					minute: time.minute
				}, pendingWord);
				pendingWord = null;
				last = "time";
				break;
			}
			case "MINUTE":
				flushWord();
				last = addValues("minute", [token.value]);
				break;
			case "INTERVAL": {
				flushWord();
				const interval = token.value;
				if (state.intervals[interval.unit] !== void 0) state.duplicateIntervals.push(interval.unit);
				state.intervals[interval.unit] = interval.n;
				const name = INTERVAL_LIST[interval.unit];
				if (name !== void 0) state.lastAssign[name] = "interval";
				if (interval.unit === "week") state.freqs.push("week");
				if (interval.unit === "month") state.freqs.push("month");
				break;
			}
			case "RANGE_FROM":
				if (last === "time") {
					flushWord();
					if (state.times.items.length > 0) state.times.pendingRange = true;
				} else if (last !== null && state.lists[last].items.length > 0) state.lists[last].pendingRange = true;
				break;
			case "RANGE_TO": break;
			case "NTH":
				flushWord();
				pendingNth = token.value;
				break;
			case "DOW": {
				flushWord();
				const weekday = token.value;
				if (pendingNth !== null) {
					state.nths.push({
						weekday,
						nth: pendingNth
					});
					pendingNth = null;
				} else last = addValues("dow", [weekday]);
				break;
			}
			case "DOW_SET": {
				flushWord();
				const approx = DOW_SET_APPROX[token.raw];
				if (approx !== void 0) state.approxNotes.push(approx);
				last = addValues("dow", token.value);
				break;
			}
			case "DOM":
				flushWord();
				last = addValues("dom", [token.value]);
				break;
			case "DOM_SPECIAL": {
				flushWord();
				const value = token.value;
				if (value === "L") state.domSpecial = "L";
				else last = addValues("dom", [value]);
				break;
			}
			case "MONTH":
				flushWord();
				last = addValues("month", [token.value]);
				break;
			case "FREQ":
				flushWord();
				state.freqs.push(token.value);
				break;
			case "AND":
				flushWord();
				break;
			case "HOUR_SPAN":
				state.hourSpan = true;
				break;
			default: if (token.type === "UNKNOWN") state.unknown += 1;
		}
	}
	flushWord();
	return state;
}
/**
* トークン列から cron の各フィールドを埋める。
*/
function fill(tokens, options = {}) {
	const requested = options.defaultHour ?? 9;
	const defaultHour = Number.isInteger(requested) && requested >= 0 && requested <= 23 ? requested : 9;
	const state = collect(tokens);
	const slots = {
		minute: ANY,
		hour: ANY,
		dom: ANY,
		month: ANY,
		dow: ANY,
		extensions: /* @__PURE__ */ new Set(),
		penalties: [],
		ambiguities: [],
		notes: [...state.todNotes],
		empty: state.meaningful === 0
	};
	if (slots.empty) return slots;
	const penalize = (reason, amount) => {
		slots.penalties.push({
			reason,
			amount
		});
	};
	/** 同じフィールドに 2 通りの指定が並んだとき(§2.3.5 の「2 回代入」) */
	const noteConflict = (label) => {
		slots.notes.push(`「${label}」の指定が 2 通りあるため、後に書かれた方を採用しました`);
		penalize("同じフィールドへの二重指定", .2);
	};
	for (const penalty of state.todPenalties) slots.penalties.push(penalty);
	for (const unit of state.duplicateIntervals) noteConflict(INTERVAL_LABELS[unit]);
	for (const note of state.approxNotes) {
		slots.notes.push(note);
		penalize("曜日で近似した語", .2);
	}
	/** 時間帯の語を時に落とす。幅のある語は曖昧さとして報告する */
	const resolveWord = (word) => {
		const spec = TIME_OF_DAY[word];
		const override = options.timeOfDay?.[word];
		if (spec.range[0] !== spec.range[1] && override === void 0) {
			slots.ambiguities.push({
				field: "hour",
				question: `「${word}」は何時ですか?`,
				candidates: hourCandidates(spec.range[0], spec.range[1])
			});
			penalize("時刻を表す語が曖昧", .3);
		}
		return override ?? spec.default ?? defaultHour;
	};
	const resolveAtom = (atom) => atom.kind === "time" ? {
		hour: atom.hour,
		minute: atom.minute
	} : {
		hour: resolveWord(atom.word),
		minute: 0
	};
	const spans = state.times.items.map((span) => ({
		from: resolveAtom(span.from),
		to: span.to === null ? null : resolveAtom(span.to)
	}));
	const minutes = state.lists.minute;
	const minuteStep = state.intervals.minute;
	const hourStep = state.intervals.hour;
	const hasTime = spans.length > 0;
	const hasRange = spans.some((span) => span.to !== null);
	const spanMinutes = [...new Set(spans.map((span) => span.from.minute))];
	/** 時刻の並びを時フィールドにする。書かれた範囲は範囲のまま残す */
	const hourList = () => spansToAst(spans.map((span) => ({
		from: span.from.hour,
		to: (span.to ?? span.from).hour
	})));
	/**
	* 時の刻み。「AからBまでN時間ごと」は範囲ごとに掛かるので、書かれた範囲それぞれに適用する。
	* 範囲が 1 つも無ければ起点を持たない全域からの刻み。
	*/
	const hourStepAst = (step) => {
		if (!hasRange) return {
			kind: "step",
			base: ANY,
			step
		};
		const items = spans.map((span) => span.to === null ? {
			kind: "value",
			value: span.from.hour
		} : stepAst({
			kind: "range",
			from: span.from.hour,
			to: span.to.hour
		}, step, HOUR_SPEC));
		const only = items[0];
		/* c8 ignore next -- hasRange が真なら 1 個以上ある */
		if (only === void 0) return {
			kind: "step",
			base: ANY,
			step
		};
		return items.length === 1 ? only : {
			kind: "list",
			items
		};
	};
	/** 「N時まで」が N 時台を含むことを断る。終端ごとに曖昧なので範囲の数だけ付ける */
	const noteHourRanges = () => {
		for (const span of spans) {
			const to = span.to;
			if (to === null) continue;
			slots.notes.push(`「${to.hour}時まで」を ${to.hour}時台まで(${span.from.hour}-${to.hour})と解釈しました。${to.hour}:00 で終える場合は ${span.from.hour}-${to.hour - 1} を指定してください`);
			penalize("時刻範囲の終端が曖昧", .1);
		}
	};
	if (state.freqs.includes("minute")) {
		slots.minute = ANY;
		if (hourStep !== void 0) slots.hour = hourStepAst(hourStep);
		else if (hasTime) slots.hour = hourList();
		noteHourRanges();
	} else if (minuteStep !== void 0 || hourStep !== void 0) {
		if (minuteStep !== void 0) slots.minute = stepAst(consumeBase(minutes, MINUTE_SPEC), minuteStep, MINUTE_SPEC);
		if (hourStep !== void 0) {
			slots.hour = hourStepAst(hourStep);
			if (minuteStep === void 0) {
				slots.minute = minutes.items.length > 0 ? listToAst(minutes) : {
					kind: "value",
					value: spanMinutes[0] ?? 0
				};
				minutes.consumed = true;
			}
		} else if (hasRange) slots.hour = hourList();
		noteHourRanges();
		if (!hasRange && hasTime) {
			if (minuteStep !== void 0) slots.hour = hourList();
			if (!(state.hourSpan && minuteStep !== void 0 && hourStep === void 0)) {
				slots.ambiguities.push({
					field: "hour",
					question: "「〜ごと」と時刻の両方が指定されています。どちらの意味ですか?",
					candidates: [{
						value: "interval",
						label: "指定した間隔で繰り返す"
					}, {
						value: "at",
						label: "指定した時刻にだけ実行する"
					}]
				});
				penalize("間隔と時刻の併用", .2);
			}
		}
		if (minutes.items.length > 0 && !minutes.consumed) {
			noteConflict(INTERVAL_LABELS.minute);
			if (state.lastAssign.minute === "values") slots.minute = listToAst(minutes);
		}
	} else if (hasTime) {
		const writtenMinutes = minutes.items.length > 0;
		if (writtenMinutes && spanMinutes.some((minute) => minute !== 0)) noteConflict(INTERVAL_LABELS.minute);
		slots.hour = hourList();
		noteHourRanges();
		const only = spanMinutes[0];
		if (writtenMinutes) slots.minute = listToAst(minutes);
		else if (spanMinutes.length === 1 && only !== void 0) slots.minute = {
			kind: "value",
			value: only
		};
		else {
			slots.minute = valuesToAst(spanMinutes);
			slots.notes.push("複数の時刻が指定されているため、時と分のすべての組み合わせで実行されます");
			penalize("時と分の組み合わせが曖昧", .1);
		}
	} else if (state.freqs.includes("hour")) {
		slots.hour = ANY;
		slots.minute = minutes.items.length > 0 ? listToAst(minutes) : {
			kind: "value",
			value: 0
		};
	} else if (minutes.items.length > 0) {
		slots.hour = ANY;
		slots.minute = listToAst(minutes);
	} else {
		slots.hour = {
			kind: "value",
			value: defaultHour
		};
		slots.minute = {
			kind: "value",
			value: 0
		};
		const freqLabel = state.freqs[0] === void 0 ? void 0 : FREQ_LABELS[state.freqs[0]];
		slots.ambiguities.push({
			field: "hour",
			question: freqLabel === void 0 ? "何時に実行しますか?" : `「${freqLabel}」は何時ですか?`,
			candidates: hourCandidates(0, 23)
		});
		penalize("時刻が指定されていない", .4);
	}
	const doms = state.lists.dom;
	const dows = state.lists.dow;
	const months = state.lists.month;
	const dayStep = state.intervals.day;
	const monthStep = state.intervals.month;
	if (state.nths.length > 0) {
		const items = state.nths.map((entry) => ({
			kind: "nth",
			weekday: entry.weekday,
			nth: entry.nth
		}));
		const first = items[0];
		/* c8 ignore next -- nths.length > 0 なら必ず取れる */
		slots.dow = items.length === 1 && first !== void 0 ? first : {
			kind: "list",
			items
		};
		for (const entry of state.nths) slots.extensions.add(entry.nth === -1 ? "L" : "#");
	} else if (dows.items.length > 0) slots.dow = listToAst(dows);
	if (state.domSpecial === "L") {
		slots.dom = { kind: "last" };
		slots.extensions.add("L");
		if (dayStep !== void 0) noteConflict(INTERVAL_LABELS.day);
	} else if (dayStep !== void 0) {
		slots.dom = stepAst(consumeBase(doms, DOM_SPEC), dayStep, DOM_SPEC);
		if (doms.items.length > 0 && !doms.consumed) {
			noteConflict(INTERVAL_LABELS.day);
			if (state.lastAssign.dom === "values") slots.dom = listToAst(doms);
		}
	} else if (doms.items.length > 0) slots.dom = listToAst(doms);
	if (monthStep !== void 0) {
		slots.month = stepAst(consumeBase(months, MONTH_SPEC), monthStep, MONTH_SPEC);
		if (months.items.length > 0 && !months.consumed) {
			noteConflict(INTERVAL_LABELS.month);
			if (state.lastAssign.month === "values") slots.month = listToAst(months);
		}
	} else if (months.items.length > 0) slots.month = listToAst(months);
	const weekStep = state.intervals.week;
	if (weekStep !== void 0) {
		slots.notes.push(`cron には週単位の刻みが無いため「${weekStep}週間ごと」は表現できません。毎週として解釈しました`);
		penalize("週単位の刻み", .3);
	}
	const domSpecified = slots.dom.kind !== "any";
	const dowSpecified = slots.dow.kind !== "any";
	for (const freq of state.freqs) {
		if (freq === "day" && (domSpecified || dowSpecified)) {
			slots.notes.push("「毎日」と日付・曜日の指定が同時にあるため、日付・曜日の指定を優先しました");
			penalize("頻度語と日付指定の不一致", .1);
		}
		if (freq === "week" && !dowSpecified) {
			slots.dow = {
				kind: "value",
				value: 1
			};
			slots.ambiguities.push({
				field: "dayOfWeek",
				question: "「毎週」は何曜日ですか?",
				candidates: [
					{
						value: 0,
						label: "日曜日"
					},
					{
						value: 1,
						label: "月曜日"
					},
					{
						value: 2,
						label: "火曜日"
					},
					{
						value: 3,
						label: "水曜日"
					},
					{
						value: 4,
						label: "木曜日"
					},
					{
						value: 5,
						label: "金曜日"
					},
					{
						value: 6,
						label: "土曜日"
					}
				]
			});
			penalize("「毎週」の曜日が未指定", .3);
		}
		if (freq === "month" && !domSpecified && !dowSpecified && !state.freqs.includes("day")) {
			slots.dom = {
				kind: "value",
				value: 1
			};
			slots.ambiguities.push({
				field: "dayOfMonth",
				question: "「毎月」は何日ですか?",
				candidates: [
					{
						value: 1,
						label: "1日"
					},
					{
						value: 15,
						label: "15日"
					},
					{
						value: "L",
						label: "月末"
					}
				]
			});
			penalize("「毎月」の日が未指定", .3);
		}
		if (freq === "year" && slots.month.kind === "any") {
			slots.month = {
				kind: "value",
				value: 1
			};
			if (slots.dom.kind === "any") slots.dom = {
				kind: "value",
				value: 1
			};
			slots.ambiguities.push({
				field: "month",
				question: "「毎年」は何月ですか?",
				candidates: Array.from({ length: 12 }, (_, index) => ({
					value: index + 1,
					label: `${index + 1}月`
				}))
			});
			penalize("「毎年」の月が未指定", .3);
		}
	}
	if (slots.dom.kind !== "any" && slots.dow.kind !== "any") {
		slots.notes.push("標準の cron では日と曜日を同時に指定すると OR 条件になり、どちらかに一致する日に実行されます");
		penalize("日と曜日の同時指定", .2);
	}
	const hasDateToken = dows.items.length > 0 || state.nths.length > 0 || doms.items.length > 0 || state.domSpecial !== null || months.items.length > 0;
	const hasInterval = Object.keys(state.intervals).length > 0;
	if (state.freqs.length === 0 && !hasDateToken && !hasInterval) {
		slots.notes.push("頻度が明示されていないため、毎日として解釈しました");
		penalize("頻度語が無い", .2);
	}
	if (slots.extensions.size > 0 && options.allowExtensions !== true) {
		const used = [...slots.extensions].join("', '");
		slots.notes.push(`'${used}' は Quartz 拡張です。標準の cron では動作しません(allowExtensions で抑制できます)`);
		penalize("拡張構文の使用", .1);
	}
	if (state.unknown > 0) penalize("解釈できない語", Math.min(.3, state.unknown * .1));
	return slots;
}
/** スロットを cron 式にする */
function emit(slots) {
	return [
		slots.minute,
		slots.hour,
		slots.dom,
		slots.month,
		slots.dow
	].map(formatField).join(" ");
}
//#endregion
//#region src/parse/normalize.ts
const KANJI_DIGITS = {
	〇: 0,
	零: 0,
	一: 1,
	二: 2,
	三: 3,
	四: 4,
	五: 5,
	六: 6,
	七: 7,
	八: 8,
	九: 9
};
const KANJI_RUN = /[〇零一二三四五六七八九十]+/g;
function convertRun(run) {
	if (!run.includes("十")) {
		let digits = "";
		for (const char of run) {
			const digit = KANJI_DIGITS[char];
			/* c8 ignore next -- KANJI_RUN が一致した文字は必ず辞書にある */
			if (digit === void 0) return run;
			digits += String(digit);
		}
		return digits;
	}
	let total = 0;
	let current = 0;
	for (const char of run) if (char === "十") {
		total += (current === 0 ? 1 : current) * 10;
		current = 0;
	} else {
		const digit = KANJI_DIGITS[char];
		/* c8 ignore next -- KANJI_RUN が一致した文字は必ず辞書にある */
		if (digit === void 0) return run;
		current = digit;
	}
	return String(total + current);
}
/** 漢数字を算用数字に変換する(「半」はそのまま残す) */
function kanjiToArabic(text) {
	return text.replace(KANJI_RUN, (run) => convertRun(run));
}
/** 文末の動詞句・丁寧表現を落とす */
const TAIL_PATTERNS = [
	/(に|で)?(実行|起動|開始|動作)(する|して|し|す)?(たい|ください|下さい|ます|ましょう|よう)?$/,
	/(して)?(ください|下さい|ほしい|欲しい|たい)$/,
	/(です|ます|だ|である)(か)?$/,
	/[。!!??、,\s]+$/
];
function stripTail(text) {
	let result = text;
	for (let i = 0; i < 5; i++) {
		let changed = false;
		for (const pattern of TAIL_PATTERNS) {
			const next = result.replace(pattern, "");
			if (next !== result && next.length > 0) {
				result = next;
				changed = true;
			}
		}
		if (!changed) break;
	}
	return result;
}
/**
* 解析前の前処理。NFKC 正規化 → 漢数字変換 → 文末表現の除去。
*/
function normalize(text) {
	return stripTail(kanjiToArabic(text.normalize("NFKC")).trim()).trim();
}
//#endregion
//#region src/parse/tokenize.ts
function int(text) {
	return Number(text ?? "0");
}
/** 範囲外の数値はトークンにしない(後続のルール、最終的には UNKNOWN に回す) */
function inRange(value, min, max) {
	return Number.isInteger(value) && value >= min && value <= max;
}
/**
* 長い表現から順に試すため、並び順に意味がある。
*
* 「毎」で終わる間隔(「15分毎」)は、後ろに頻度語の単位が続く場合だけ採らない。
* 「1月毎日」を「1か月ごと」+「日」と切ってしまうのを避けるため。
*/
const RULES = [
	[/^(\d{1,2})時間(?:ごと|おき|毎(?![分時日週月年]))に?/, (m) => {
		const n = int(m[1]);
		if (!inRange(n, 1, 23)) return null;
		return {
			type: "INTERVAL",
			value: {
				unit: "hour",
				n
			}
		};
	}],
	[/^(\d{1,2})分(?:ごと|おき|毎(?![分時日週月年]))に?/, (m) => {
		const n = int(m[1]);
		if (!inRange(n, 1, 59)) return null;
		return {
			type: "INTERVAL",
			value: {
				unit: "minute",
				n
			}
		};
	}],
	[/^(\d{1,2})日(?:ごと|おき|毎(?![分時日週月年]))に?/, (m) => {
		const n = int(m[1]);
		if (!inRange(n, 1, 31)) return null;
		return {
			type: "INTERVAL",
			value: {
				unit: "day",
				n
			}
		};
	}],
	[/^(\d{1,2})[かカヵケヶ箇]?月(?:ごと|おき|毎(?![分時日週月年]))に?/, (m) => {
		const n = int(m[1]);
		if (!inRange(n, 1, 12)) return null;
		return {
			type: "INTERVAL",
			value: {
				unit: "month",
				n
			}
		};
	}],
	[/^(\d{1,2})週間?(?:ごと|おき|毎(?![分時日週月年]))に?/, (m) => {
		const n = int(m[1]);
		if (!inRange(n, 1, 52)) return null;
		return {
			type: "INTERVAL",
			value: {
				unit: "week",
				n
			}
		};
	}],
	[/^(\d{1,2}):(\d{2})/, (m) => {
		const hour = int(m[1]);
		const minute = int(m[2]);
		if (!inRange(hour, 0, 23) || !inRange(minute, 0, 59)) return null;
		return {
			type: "TIME",
			value: {
				hour,
				minute
			}
		};
	}],
	[/^(\d{1,2})時(半|(\d{1,2})分)?/, (m) => {
		const hour = int(m[1]);
		const minute = m[2] === "半" ? 30 : int(m[3]);
		if (!inRange(hour, 0, 23) || !inRange(minute, 0, 59)) return null;
		return {
			type: "TIME",
			value: {
				hour,
				minute
			}
		};
	}],
	[/^(\d{1,2})分/, (m) => {
		const value = int(m[1]);
		return inRange(value, 0, 59) ? {
			type: "MINUTE",
			value
		} : null;
	}],
	[/^(第[1-5]|最終|最後)(週|の)?/, (m) => {
		const nth = NTH[m[1] ?? ""];
		return nth === void 0 ? null : {
			type: "NTH",
			value: nth
		};
	}],
	[/^(\d{1,2})月/, (m) => {
		const value = int(m[1]);
		return inRange(value, 1, 12) ? {
			type: "MONTH",
			value
		} : null;
	}],
	[/^(\d{1,2})日/, (m) => {
		const value = int(m[1]);
		return inRange(value, 1, 31) ? {
			type: "DOM",
			value
		} : null;
	}],
	[/^(平日|週末|土日|休日)/, (m) => {
		const values = DOW_SET[m[1] ?? ""];
		return values === void 0 ? null : {
			type: "DOW_SET",
			value: [...values]
		};
	}],
	[/^(日|月|火|水|木|金|土)曜日?/, (m) => {
		const value = DOW[`${m[1]}曜`];
		return value === void 0 ? null : {
			type: "DOW",
			value
		};
	}],
	[/^毎?(月末|月初)/, (m) => {
		const value = DOM_SPECIAL[m[1] ?? ""];
		return value === void 0 ? null : {
			type: "DOM_SPECIAL",
			value
		};
	}],
	[/^(早朝|朝|正午|昼|夕方|夜中|深夜|夜|晩|午前|午後)/, (m) => {
		const word = m[1];
		if (word === "午前" || word === "午後") return {
			type: "AMPM",
			value: word
		};
		return {
			type: "TIME_OF_DAY",
			value: word
		};
	}],
	[/^毎(分|時|日|週|月|年)/, (m) => {
		const unit = FREQ[`毎${m[1]}`];
		return unit === void 0 ? null : {
			type: "FREQ",
			value: unit
		};
	}],
	[/^毎(?=早朝|朝|正午|昼|夕方|夜中|深夜|夜|晩)/, () => ({
		type: "FREQ",
		value: "day"
	})],
	[/^(から|以降)/, () => ({ type: "RANGE_FROM" })],
	[/^(まで|以前)/, () => ({ type: "RANGE_TO" })],
	[/^(および|かつ|と|、|,)/, () => ({ type: "AND" })],
	[/^台/, () => ({ type: "HOUR_SPAN" })],
	[/^(の|に|は|が|を|\s|。)+/, () => ({ type: "SEP" })]
];
/**
* 正規化済みの日本語をトークン列に分解する。
*/
function tokenize(text) {
	const tokens = [];
	let position = 0;
	while (position < text.length) {
		const rest = text.slice(position);
		let matched = false;
		for (const [pattern, build] of RULES) {
			const match = pattern.exec(rest);
			if (match === null || match[0] === "") continue;
			const token = build(match);
			/* c8 ignore next -- 辞書引きに失敗した場合のみ。通常は起きない */
			if (token === null) continue;
			tokens.push({
				...token,
				raw: match[0],
				position
			});
			position += match[0].length;
			matched = true;
			break;
		}
		if (!matched) {
			tokens.push({
				type: "UNKNOWN",
				raw: text[position] ?? "",
				position
			});
			position += 1;
		}
	}
	return tokens;
}
//#endregion
//#region src/parse/index.ts
function round2(value) {
	return Math.round(value * 100) / 100;
}
/**
* 日本語の予定表現を cron 式に変換する。
*
* 日本語は `options.tz`(既定 `'Asia/Tokyo'`)の壁時計として読み、UTC のサーバーで
* 動かすための cron 式を返す。
*
* ```ts
* parse('毎日午後1時').expression; // '0 4 * * *'(JST 13:00 = UTC 04:00)
* parse('平日の朝9時', { tz: 'UTC' }).expression; // '0 9 * * 1-5'(変換しない)
* ```
*
* @throws {ParseAmbiguityError} `strict: true` かつ解釈が曖昧な場合
* @throws {CronTimeZoneError} `tz` を解釈できない、または UTC の cron 式に書き換えられない場合
*/
function parse(text, options = {}) {
	const timeZone = resolveTimeZone(options.tz);
	const tokens = tokenize(normalize(text));
	const slots = fill(tokens, options);
	if (slots.empty) {
		const result = {
			expression: null,
			localExpression: null,
			tz: timeZone,
			confidence: 0,
			ambiguities: [],
			notes: ["時間表現が見つかりませんでした"],
			tokens
		};
		if (options.strict === true) throw new ParseAmbiguityError("時間表現が見つかりませんでした", result);
		return result;
	}
	const total = slots.penalties.reduce((sum, penalty) => sum + penalty.amount, 0);
	const local = emit(slots);
	const result = {
		expression: shiftExpression(local, timeZone, "toServer"),
		localExpression: local,
		tz: timeZone,
		confidence: round2(Math.max(0, 1 - total)),
		ambiguities: slots.ambiguities,
		notes: slots.notes,
		tokens
	};
	if (options.strict === true && result.ambiguities.length > 0) {
		const first = result.ambiguities[0];
		throw new ParseAmbiguityError(`解釈が曖昧です: ${first?.question ?? "入力を特定できません"}`, result);
	}
	return result;
}
//#endregion
exports.CronSyntaxError = CronSyntaxError;
exports.CronTimeZoneError = CronTimeZoneError;
exports.ParseAmbiguityError = ParseAmbiguityError;
exports.explain = explain;
exports.explainDetailed = explainDetailed;
exports.next = next;
exports.parse = parse;
exports.validate = validate;

//# sourceMappingURL=index.cjs.map