luxon
Version:
Immutable date wrapper
336 lines (286 loc) • 10.5 kB
JavaScript
import {
untruncateYear,
signedOffset,
parseInteger,
parseMillis,
isUndefined,
parseFloating,
} from "./util.js";
import * as English from "./english.js";
import FixedOffsetZone from "../zones/fixedOffsetZone.js";
import IANAZone from "../zones/IANAZone.js";
/*
* This file handles parsing for well-specified formats. Here's how it works:
* Two things go into parsing: a regex to match with and an extractor to take apart the groups in the match.
* An extractor is just a function that takes a regex match array and returns a { year: ..., month: ... } object
* parse() does the work of executing the regex and applying the extractor. It takes multiple regex/extractor pairs to try in sequence.
* Extractors can take a "cursor" representing the offset in the match to look at. This makes it easy to combine extractors.
* combineExtractors() does the work of combining them, keeping track of the cursor through multiple extractions.
* Some extractions are super dumb and simpleParse and fromStrings help DRY them.
*/
const ianaRegex = /[A-Za-z_+-]{1,256}(?::?\/[A-Za-z0-9_+-]{1,256}(?:\/[A-Za-z0-9_+-]{1,256})?)?/;
function combineRegexes(...regexes) {
const full = regexes.reduce((f, r) => f + r.source, "");
return RegExp(`^${full}$`);
}
function combineExtractors(...extractors) {
return (m) =>
extractors
.reduce(
([mergedVals, mergedZone, cursor], ex) => {
const [val, zone, next] = ex(m, cursor);
return [{ ...mergedVals, ...val }, zone || mergedZone, next];
},
[{}, null, 1]
)
.slice(0, 2);
}
function parse(s, ...patterns) {
if (s == null) {
return [null, null];
}
for (const [regex, extractor] of patterns) {
const m = regex.exec(s);
if (m) {
return extractor(m);
}
}
return [null, null];
}
function simpleParse(...keys) {
return (match, cursor) => {
const ret = {};
let i;
for (i = 0; i < keys.length; i++) {
ret[keys[i]] = parseInteger(match[cursor + i]);
}
return [ret, null, cursor + i];
};
}
// ISO and SQL parsing
const offsetRegex = /(?:(Z)|([+-]\d\d)(?::?(\d\d))?)/;
const isoExtendedZone = `(?:${offsetRegex.source}?(?:\\[(${ianaRegex.source})\\])?)?`;
const isoTimeBaseRegex = /(\d\d)(?::?(\d\d)(?::?(\d\d)(?:[.,](\d{1,30}))?)?)?/;
const isoTimeRegex = RegExp(`${isoTimeBaseRegex.source}${isoExtendedZone}`);
const isoTimeExtensionRegex = RegExp(`(?:T${isoTimeRegex.source})?`);
const isoYmdRegex = /([+-]\d{6}|\d{4})(?:-?(\d\d)(?:-?(\d\d))?)?/;
const isoWeekRegex = /(\d{4})-?W(\d\d)(?:-?(\d))?/;
const isoOrdinalRegex = /(\d{4})-?(\d{3})/;
const extractISOWeekData = simpleParse("weekYear", "weekNumber", "weekDay");
const extractISOOrdinalData = simpleParse("year", "ordinal");
const sqlYmdRegex = /(\d{4})-(\d\d)-(\d\d)/; // dumbed-down version of the ISO one
const sqlTimeRegex = RegExp(
`${isoTimeBaseRegex.source} ?(?:${offsetRegex.source}|(${ianaRegex.source}))?`
);
const sqlTimeExtensionRegex = RegExp(`(?: ${sqlTimeRegex.source})?`);
function int(match, pos, fallback) {
const m = match[pos];
return isUndefined(m) ? fallback : parseInteger(m);
}
function extractISOYmd(match, cursor) {
const item = {
year: int(match, cursor),
month: int(match, cursor + 1, 1),
day: int(match, cursor + 2, 1),
};
return [item, null, cursor + 3];
}
function extractISOTime(match, cursor) {
const item = {
hours: int(match, cursor, 0),
minutes: int(match, cursor + 1, 0),
seconds: int(match, cursor + 2, 0),
milliseconds: parseMillis(match[cursor + 3]),
};
return [item, null, cursor + 4];
}
function extractISOOffset(match, cursor) {
const local = !match[cursor] && !match[cursor + 1],
fullOffset = signedOffset(match[cursor + 1], match[cursor + 2]),
zone = local ? null : FixedOffsetZone.instance(fullOffset);
return [{}, zone, cursor + 3];
}
function extractIANAZone(match, cursor) {
const zone = match[cursor] ? IANAZone.create(match[cursor]) : null;
return [{}, zone, cursor + 1];
}
// ISO time parsing
const isoTimeOnly = RegExp(`^T?${isoTimeBaseRegex.source}$`);
// ISO duration parsing
const isoDuration =
/^-?P(?:(?:(-?\d{1,20}(?:\.\d{1,20})?)Y)?(?:(-?\d{1,20}(?:\.\d{1,20})?)M)?(?:(-?\d{1,20}(?:\.\d{1,20})?)W)?(?:(-?\d{1,20}(?:\.\d{1,20})?)D)?(?:T(?:(-?\d{1,20}(?:\.\d{1,20})?)H)?(?:(-?\d{1,20}(?:\.\d{1,20})?)M)?(?:(-?\d{1,20})(?:[.,](-?\d{1,20}))?S)?)?)$/;
function extractISODuration(match) {
const [s, yearStr, monthStr, weekStr, dayStr, hourStr, minuteStr, secondStr, millisecondsStr] =
match;
const hasNegativePrefix = s[0] === "-";
const negativeSeconds = secondStr && secondStr[0] === "-";
const maybeNegate = (num, force = false) =>
num !== undefined && (force || (num && hasNegativePrefix)) ? -num : num;
return [
{
years: maybeNegate(parseFloating(yearStr)),
months: maybeNegate(parseFloating(monthStr)),
weeks: maybeNegate(parseFloating(weekStr)),
days: maybeNegate(parseFloating(dayStr)),
hours: maybeNegate(parseFloating(hourStr)),
minutes: maybeNegate(parseFloating(minuteStr)),
seconds: maybeNegate(parseFloating(secondStr), secondStr === "-0"),
milliseconds: maybeNegate(parseMillis(millisecondsStr), negativeSeconds),
},
];
}
// These are a little braindead. EDT *should* tell us that we're in, say, America/New_York
// and not just that we're in -240 *right now*. But since I don't think these are used that often
// I'm just going to ignore that
const obsOffsets = {
GMT: 0,
EDT: -4 * 60,
EST: -5 * 60,
CDT: -5 * 60,
CST: -6 * 60,
MDT: -6 * 60,
MST: -7 * 60,
PDT: -7 * 60,
PST: -8 * 60,
};
function fromStrings(weekdayStr, yearStr, monthStr, dayStr, hourStr, minuteStr, secondStr) {
const result = {
year: yearStr.length === 2 ? untruncateYear(parseInteger(yearStr)) : parseInteger(yearStr),
month: English.monthsShort.indexOf(monthStr) + 1,
day: parseInteger(dayStr),
hour: parseInteger(hourStr),
minute: parseInteger(minuteStr),
};
if (secondStr) result.second = parseInteger(secondStr);
if (weekdayStr) {
result.weekday =
weekdayStr.length > 3
? English.weekdaysLong.indexOf(weekdayStr) + 1
: English.weekdaysShort.indexOf(weekdayStr) + 1;
}
return result;
}
// RFC 2822/5322
const rfc2822 =
/^(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s)?(\d{1,2})\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s(\d{2,4})\s(\d\d):(\d\d)(?::(\d\d))?\s(?:(UT|GMT|[ECMP][SD]T)|([Zz])|(?:([+-]\d\d)(\d\d)))$/;
function extractRFC2822(match) {
const [
,
weekdayStr,
dayStr,
monthStr,
yearStr,
hourStr,
minuteStr,
secondStr,
obsOffset,
milOffset,
offHourStr,
offMinuteStr,
] = match,
result = fromStrings(weekdayStr, yearStr, monthStr, dayStr, hourStr, minuteStr, secondStr);
let offset;
if (obsOffset) {
offset = obsOffsets[obsOffset];
} else if (milOffset) {
offset = 0;
} else {
offset = signedOffset(offHourStr, offMinuteStr);
}
return [result, new FixedOffsetZone(offset)];
}
function preprocessRFC2822(s) {
// Remove comments and folding whitespace and replace multiple-spaces with a single space
return s
.replace(/\([^()]*\)|[\n\t]/g, " ")
.replace(/(\s\s+)/g, " ")
.trim();
}
// http date
const rfc1123 =
/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d\d) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d\d):(\d\d):(\d\d) GMT$/,
rfc850 =
/^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d\d)-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d\d) (\d\d):(\d\d):(\d\d) GMT$/,
ascii =
/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( \d|\d\d) (\d\d):(\d\d):(\d\d) (\d{4})$/;
function extractRFC1123Or850(match) {
const [, weekdayStr, dayStr, monthStr, yearStr, hourStr, minuteStr, secondStr] = match,
result = fromStrings(weekdayStr, yearStr, monthStr, dayStr, hourStr, minuteStr, secondStr);
return [result, FixedOffsetZone.utcInstance];
}
function extractASCII(match) {
const [, weekdayStr, monthStr, dayStr, hourStr, minuteStr, secondStr, yearStr] = match,
result = fromStrings(weekdayStr, yearStr, monthStr, dayStr, hourStr, minuteStr, secondStr);
return [result, FixedOffsetZone.utcInstance];
}
const isoYmdWithTimeExtensionRegex = combineRegexes(isoYmdRegex, isoTimeExtensionRegex);
const isoWeekWithTimeExtensionRegex = combineRegexes(isoWeekRegex, isoTimeExtensionRegex);
const isoOrdinalWithTimeExtensionRegex = combineRegexes(isoOrdinalRegex, isoTimeExtensionRegex);
const isoTimeCombinedRegex = combineRegexes(isoTimeRegex);
const extractISOYmdTimeAndOffset = combineExtractors(
extractISOYmd,
extractISOTime,
extractISOOffset,
extractIANAZone
);
const extractISOWeekTimeAndOffset = combineExtractors(
extractISOWeekData,
extractISOTime,
extractISOOffset,
extractIANAZone
);
const extractISOOrdinalDateAndTime = combineExtractors(
extractISOOrdinalData,
extractISOTime,
extractISOOffset,
extractIANAZone
);
const extractISOTimeAndOffset = combineExtractors(
extractISOTime,
extractISOOffset,
extractIANAZone
);
/*
* @private
*/
export function parseISODate(s) {
return parse(
s,
[isoYmdWithTimeExtensionRegex, extractISOYmdTimeAndOffset],
[isoWeekWithTimeExtensionRegex, extractISOWeekTimeAndOffset],
[isoOrdinalWithTimeExtensionRegex, extractISOOrdinalDateAndTime],
[isoTimeCombinedRegex, extractISOTimeAndOffset]
);
}
export function parseRFC2822Date(s) {
return parse(preprocessRFC2822(s), [rfc2822, extractRFC2822]);
}
export function parseHTTPDate(s) {
return parse(
s,
[rfc1123, extractRFC1123Or850],
[rfc850, extractRFC1123Or850],
[ascii, extractASCII]
);
}
export function parseISODuration(s) {
return parse(s, [isoDuration, extractISODuration]);
}
const extractISOTimeOnly = combineExtractors(extractISOTime);
export function parseISOTimeOnly(s) {
return parse(s, [isoTimeOnly, extractISOTimeOnly]);
}
const sqlYmdWithTimeExtensionRegex = combineRegexes(sqlYmdRegex, sqlTimeExtensionRegex);
const sqlTimeCombinedRegex = combineRegexes(sqlTimeRegex);
const extractISOTimeOffsetAndIANAZone = combineExtractors(
extractISOTime,
extractISOOffset,
extractIANAZone
);
export function parseSQL(s) {
return parse(
s,
[sqlYmdWithTimeExtensionRegex, extractISOYmdTimeAndOffset],
[sqlTimeCombinedRegex, extractISOTimeOffsetAndIANAZone]
);
}