// Whether log4j's CronExpression (org.apache.logging.log4j.core.util, log4j // 2.25.4, a copy of Quartz's parser) takes a cron expression, and if not, its // ParseException's message: what a product validating a schedule with it // reports. A port of its buildExpression, storeExpressionVals, checkNext and // addToSet, down to the Java exceptions their string handling throws, checked // against the real class on a few thousand expressions // (test/fixtures/java-verdicts.json keeps one per message). // The fields, in the expression's order. const Field = { Second: 0, Minute: 1, Hour: 2, DayOfMonth: 3, Month: 4, DayOfWeek: 5, Year: 6, } as const; type Field = number; const ALL_SPEC = 99; const NO_SPEC = 98; // A month or day name: JAN, MON. const NAME_LENGTH = 3; // The most days an L-n offset may take, and the most an nth day (#n) may be. const MAX_LAST_DAY_OFFSET = 30; const MAX_NTH_DAY = 5; const FIRST_YEAR = 1970; // The last year a range runs to: Calendar.getInstance().get(YEAR) + 100. const YEARS_AHEAD = 100; const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; const DAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; // Each field's values, what a range past its end wraps by, addToSet's // message for a value outside them, and the largest increment it takes. interface FieldRange { first: number; last: number; wrap: number; message?: string; increment?: { max: number; label: number }; } const MINUTES: FieldRange = { first: 0, last: 59, wrap: 60, message: 'Minute and Second values must be between 0 and 59', increment: { max: 59, label: 60 }, }; const RANGES: Record = { [Field.Second]: MINUTES, [Field.Minute]: MINUTES, [Field.Hour]: { first: 0, last: 23, wrap: 24, message: 'Hour values must be between 0 and 23', increment: { max: 23, label: 24 }, }, [Field.DayOfMonth]: { first: 1, last: 31, wrap: 31, message: 'Day of month values must be between 1 and 31', increment: { max: 31, label: 31 }, }, [Field.Month]: { first: 1, last: 12, wrap: 12, message: 'Month values must be between 1 and 12', increment: { max: 12, label: 12 }, }, [Field.DayOfWeek]: { first: 1, last: 7, wrap: 7, message: 'Day-of-Week values must be between 1 and 7', increment: { max: 7, label: 7 }, }, // No range check, and a range can't wrap. [Field.Year]: { first: FIRST_YEAR, last: Number.POSITIVE_INFINITY, wrap: 0 }, }; const lastOf = (type: Field) => type === Field.Year ? new Date().getFullYear() + YEARS_AHEAD : RANGES[type].last; // java.text.ParseException: the message the product shows. class ParseError extends Error {} // Any other Java exception, as its toString() prints it. class JavaError extends Error {} // String.charAt, String.substring and Integer.parseInt as Java throws them. function charAt(s: string, i: number): string { if (i < 0 || i >= s.length) { throw new JavaError( `java.lang.StringIndexOutOfBoundsException: String index out of range: ${i}`, ); } return s[i]; } function substring(s: string, begin: number, end = s.length): string { if (begin < 0 || begin > end || end > s.length) { throw new JavaError( `java.lang.StringIndexOutOfBoundsException: begin ${begin}, end ${end}, length ${s.length}`, ); } return s.slice(begin, end); } const INT_MAX = 2_147_483_647; function parseInt(s: string): number { const fail = () => new JavaError(`java.lang.NumberFormatException: For input string: "${s}"`); if (!/^[+-]?\d+$/.test(s)) throw fail(); const value = Number(s); if (value > INT_MAX || value < -INT_MAX - 1) throw fail(); return value; } const isDigit = (c: string) => c >= '0' && c <= '9'; const isBlank = (c: string | undefined) => c === ' ' || c === '\t'; function checkIncrementRange(incr: number, type: Field): void { const limit = RANGES[type].increment; if (limit && incr > limit.max) throw new ParseError(`Increment > ${limit.label} : ${incr}`); } function nthDay(s: string, i: number): void { let nth: number; try { nth = parseInt(substring(s, i)); } catch { throw new ParseError("A numeric value between 1 and 5 must follow the '#' option"); } if (nth < 1 || nth > MAX_NTH_DAY) { throw new ParseError("A numeric value between 1 and 5 must follow the '#' option"); } } class Parser { private readonly sets: Set[] = Array.from( { length: Field.Year + 1 }, () => new Set(), ); private lastdayOfMonth = false; private readonly expression: string; constructor(expression: string) { this.expression = expression; } build(): void { let type = Field.Second; const tokens = this.expression.split(/[ \t]+/).filter((token) => token !== ''); for (const token of tokens) { if (type > Field.Year) break; // Java's trim(): every character up to U+0020, and only those. // eslint-disable-next-line no-control-regex const expr = token.replace(/^[\u0000-\u0020]+|[\u0000-\u0020]+$/g, ''); this.checkCombinations(expr, type); for (const value of expr.split(',').filter((part) => part !== '')) this.store(value, type); type += 1; } if (type <= Field.DayOfWeek) throw new ParseError('Unexpected end of expression.'); if (type <= Field.Year) this.store('*', Field.Year); const dayOfMonthSpec = !this.sets[Field.DayOfMonth].has(NO_SPEC); const dayOfWeekSpec = !this.sets[Field.DayOfWeek].has(NO_SPEC); if ((!dayOfMonthSpec || dayOfWeekSpec) && (!dayOfWeekSpec || dayOfMonthSpec)) { throw new ParseError( 'Support for specifying both a day-of-week AND a day-of-month parameter is not implemented.', ); } } private checkCombinations(expr: string, type: Field): void { const listsL = expr.includes('L') && expr.length > 1 && expr.includes(','); if (type === Field.DayOfMonth && listsL) { throw new ParseError( "Support for specifying 'L' and 'LW' with other days of the month is not implemented", ); } if (type === Field.DayOfWeek && listsL) { throw new ParseError( "Support for specifying 'L' with other days of the week is not implemented", ); } const nth = expr.indexOf('#'); if (type === Field.DayOfWeek && nth !== -1 && expr.indexOf('#', nth + 1) !== -1) { throw new ParseError('Support for specifying multiple "nth" days is not implemented.'); } } // storeExpressionVals. private store(s: string, type: Field): void { let i = 0; while (isBlank(s[i])) i += 1; if (i >= s.length) return; const c = charAt(s, i); if (c >= 'A' && c <= 'Z' && s !== 'L' && s !== 'LW' && !/^L-[0-9]*[W]?$/.test(s)) { this.storeName(s, i, type); } else if (c === '?') { this.storeNoSpec(s, i + 1, type); } else if (c === '*' || c === '/') { this.storeAll(s, i, type); } else if (c === 'L') { this.storeLast(s, i + 1, type); } else if (isDigit(c)) { this.storeNumber(s, i, type); } else { throw new ParseError(`Unexpected character: ${c}`); } } // A month or day name, a range of them, or a day's nth (MON#2). private storeName(s: string, start: number, type: Field): void { let i = start; let sub = substring(s, i, i + NAME_LENGTH); const names = type === Field.Month ? MONTHS : type === Field.DayOfWeek ? DAYS : undefined; if (!names) throw new ParseError(`Illegal characters for this position: '${sub}'`); const invalid = (name: string) => new ParseError(`Invalid ${type === Field.Month ? 'Month' : 'Day-of-Week'} value: '${name}'`); const sval = names.indexOf(sub) + 1; if (sval <= 0) throw invalid(sub); let eval_ = -1; if (s.length > i + NAME_LENGTH) { const c = charAt(s, i + NAME_LENGTH); if (c === '-') { i += NAME_LENGTH + 1; sub = substring(s, i, i + NAME_LENGTH); eval_ = names.indexOf(sub) + 1; if (eval_ <= 0) throw invalid(sub); } else if (type === Field.DayOfWeek && c === '#') { nthDay(s, i + NAME_LENGTH + 1); } } this.add(sval, eval_, eval_ === -1 ? 0 : 1, type); } private storeNoSpec(s: string, i: number, type: Field): void { if (i + 1 < s.length && s[i] !== ' ' && s[i + 1] !== '\t') { throw new ParseError(`Illegal character after '?': ${s[i]}`); } if (type !== Field.DayOfWeek && type !== Field.DayOfMonth) { throw new ParseError("'?' can only be specfied for Day-of-Month or Day-of-Week."); } if (type === Field.DayOfWeek && !this.lastdayOfMonth) { const dayOfMonth = this.sets[Field.DayOfMonth]; // TreeSet.last() on an empty set. if (dayOfMonth.size === 0) throw new JavaError('java.util.NoSuchElementException'); if (Math.max(...dayOfMonth) === NO_SPEC) { throw new ParseError("'?' can only be specfied for Day-of-Month -OR- Day-of-Week."); } } this.add(NO_SPEC, -1, 0, type); } // *, */n or /n. private storeAll(s: string, start: number, type: Field): void { let i = start; if (s[i] === '*' && i + 1 >= s.length) { this.add(ALL_SPEC, -1, 0, type); return; } if (s[i] === '/' && (i + 1 >= s.length || isBlank(s[i + 1]))) { throw new ParseError("'/' must be followed by an integer."); } if (s[i] === '*') i += 1; let incr = 1; if (charAt(s, i) === '/') { i += 1; if (i >= s.length) throw new ParseError('Unexpected end of string.'); incr = parseInt(substring(s, i)); checkIncrementRange(incr, type); } this.add(ALL_SPEC, -1, incr, type); } // L, L-n, LW in the day of month; L alone (Saturday) in the day of week. private storeLast(s: string, start: number, type: Field): void { if (type === Field.DayOfMonth) this.lastdayOfMonth = true; const saturday = RANGES[Field.DayOfWeek].last; if (type === Field.DayOfWeek) this.add(saturday, saturday, 0, type); if (type === Field.DayOfMonth && s.length > start && charAt(s, start) === '-') { const offset = this.value(0, s, start + 1); if (offset.value > MAX_LAST_DAY_OFFSET) throw new ParseError('Offset from last day must be <= 30'); } } // A number, and what follows it (checkNext). private storeNumber(s: string, start: number, type: Field): void { let val = Number(s[start]); let i = start + 1; if (i >= s.length) { this.add(val, -1, -1, type); return; } if (isDigit(charAt(s, i))) { const value = this.value(val, s, i); val = value.value; i = value.pos; } if (i >= s.length) { this.add(val, -1, -1, type); return; } const c = charAt(s, i); if (c === 'L') this.lastDay(val, type, i); else if (c === 'W') this.weekday(val, type, i); else if (c === '#') { if (type !== Field.DayOfWeek) throw new ParseError(`'#' option is not valid here. (pos=${i})`); nthDay(s, i + 1); this.sets[type].add(val); } else if (c === '-') { this.range(i + 1, s, val, type); } else if (c === '/') { this.add(val, -1, this.step(i + 1, s, true), type); } else { this.add(val, -1, 0, type); } } // nL: the day of week's last in the month. private lastDay(val: number, type: Field, pos: number): void { if (type !== Field.DayOfWeek) throw new ParseError(`'L' option is not valid here. (pos=${pos})`); if (val < 1 || val > RANGES[Field.DayOfWeek].last) { throw new ParseError('Day-of-Week values must be between 1 and 7'); } this.sets[type].add(val); } // nW: the weekday nearest the day of month. private weekday(val: number, type: Field, pos: number): void { if (type !== Field.DayOfMonth) throw new ParseError(`'W' option is not valid here. (pos=${pos})`); if (val > RANGES[Field.DayOfMonth].last) { throw new ParseError( "The 'W' option does not make sense with values larger than 31 (max number of days in a month)", ); } this.sets[type].add(val); } // n-m, n-m/k. private range(start: number, s: string, val: number, type: Field): void { let i = start; let end = parseInt(charAt(s, i)); i += 1; if (i >= s.length) { this.add(val, end, 1, type); return; } if (isDigit(charAt(s, i))) { const value = this.value(end, s, i); end = value.value; i = value.pos; } const step = i < s.length && s[i] === '/' ? this.step(i + 1, s, false) : 1; this.add(val, end, step, type); } // The step after a /: one digit, or more. checkNext refuses any other // character after a number's, not after a range's. private step(start: number, s: string, strict: boolean): number { const v = parseInt(charAt(s, start)); const i = start + 1; if (i >= s.length) return v; const c = charAt(s, i); if (isDigit(c)) return this.value(v, s, i).value; if (strict) throw new ParseError(`Unexpected character '${c}' after '/'`); return v; } // getValue: the digits from i on, after v's. private value(v: number, s: string, start: number): { value: number; pos: number } { let i = start; let c = charAt(s, i); let digits = String(v); while (isDigit(c)) { digits += c; i += 1; if (i >= s.length) break; c = charAt(s, i); } return { pos: i < s.length ? i : i + 1, value: parseInt(digits) }; } // addToSet. private add(val: number, end: number, incr: number, type: Field): void { const set = this.sets[type]; const { first, wrap, message } = RANGES[type]; const last = lastOf(type); const days = type === Field.DayOfMonth || type === Field.DayOfWeek; const spec = val === ALL_SPEC || (val === NO_SPEC && days); if (message && !spec && (val < first || val > last || end > last)) throw new ParseError(message); if ((incr === 0 || incr === -1) && val !== ALL_SPEC) { set.add(val === -1 ? NO_SPEC : val); return; } let step = incr; if (val === ALL_SPEC && step <= 0) { step = 1; set.add(ALL_SPEC); } const startAt = val === -1 || val === ALL_SPEC ? first : val; let stopAt = end === -1 ? last : end; let max = -1; if (stopAt < startAt) { if (type === Field.Year) { throw new JavaError( 'java.lang.IllegalArgumentException: Start year must be less than stop year', ); } max = wrap; stopAt += max; } // Nothing reads the years taken, and a range of them may be millions // long: the first stands for them. if (type === Field.Year) { set.add(startAt); return; } const wrapsToMax = days || type === Field.Month; for (let at = startAt; at <= stopAt; at += step) { const wrapped = max === -1 ? at : at % max; set.add(wrapped === 0 && max !== -1 && wrapsToMax ? max : wrapped); } } } // The message new CronExpression(expression) throws with, or undefined when // it takes it. It upper-cases the expression first; anything but its own // ParseException is reported as "Illegal cron expression format ()". export function cronExpressionError(expression: string): string | undefined { try { new Parser(expression.toUpperCase()).build(); return undefined; } catch (error) { if (error instanceof ParseError) return error.message; if (error instanceof JavaError) return `Illegal cron expression format (${error.message})`; throw error; } }