blob: d1bd7cb96eb99034fdeca915bc1282bc4f5e6dc0 [file] [log] [blame]
// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// A formatter and parser for [DateTime] in a fixed format [String] pattern.
///
/// For example, calling
/// `FixedDateTimeCodec('YYYYMMDDhhmmss').decodeToLocal('19960425050322')` has
/// the same result as calling `DateTime(1996, 4, 25, 5, 3, 22)`.
///
/// The allowed characters are
/// * `Y` a digit used in the time scale component “calendar year”
/// * `M` a digit used in the time scale component “calendar month”
/// * `D` a digit used in the time scale component “calendar day”
/// * `E` a digit used in the time scale component “decade”
/// * `C` a digit used in the time scale component “century”
/// * `h` a digit used in the time scale component “clock hour”
/// * `m` a digit used in the time scale component “clock minute”
/// * `s` a digit used in the time scale component “clock second”
/// as specified in the ISO 8601 standard.
///
/// Non-allowed characters in the format [pattern] are ignored when decoding a
/// string, in this case `YYYY kiwi MM` is the same format string as
/// `YYYY------MM`. When encoding a datetime, the non-format characters are in
/// the output verbatim.
///
/// Note: this class differs from [DateFormat] in that here, the characters are
/// treated literally, i.e. the format string `YYY` matching `996` would result in
/// the same as calling `DateTime(996)`. [DateFormat] on the other hand uses the
/// specification in https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table,
/// the format string (or "skeleton") `YYY` specifies only the padding, so
/// `1996` would be a valid match. This limits it's use to format strings
/// containing delimiters, as the parser would not know how many digits to take
/// otherwise.
class FixedDateTimeFormatter {
static final _validFormatCharacters = 'YMDEChms'.codeUnits;
static final yearCode = 'Y'.codeUnitAt(0);
static final monthCode = 'M'.codeUnitAt(0);
static final dayCode = 'D'.codeUnitAt(0);
static final decadeCode = 'E'.codeUnitAt(0);
static final centuryCode = 'C'.codeUnitAt(0);
static final hourCode = 'h'.codeUnitAt(0);
static final minuteCode = 'm'.codeUnitAt(0);
static final secondCode = 's'.codeUnitAt(0);
final String pattern;
final _blocks = _ParsedFormatBlocks();
/// Creates a new [FixedDateTimeFormatter] with the provided [pattern].
///
/// The [pattern] interprets the characters mentioned in
/// [FixedDateTimeFormatter] to represent fields of a `DateTime` value. Other
/// characters are not special.
///
/// There must at most be one sequence of each special character to ensure a
/// single source of truth when constructing the [DateTime], so a pattern of
/// `"CCCC-MM-DD, CC"` is invalid, because it has two separate `C` sequences.
FixedDateTimeFormatter(this.pattern) {
int? current;
var start = 0;
var characters = pattern.codeUnits;
for (var i = 0; i < characters.length; i++) {
var char = characters[i];
if (current != char) {
_blocks.saveBlock(current, start, i);
if (_validFormatCharacters.contains(char)) {
var hasSeenBefore = _blocks.formatCharacters.indexOf(char);
if (hasSeenBefore > -1) {
throw FormatException(
"Pattern contains more than one '$char' block.\n"
"Previous occurrence at index ${_blocks.starts[hasSeenBefore]}",
pattern,
i);
} else {
start = i;
current = char;
}
} else {
current = null;
}
}
}
_blocks.saveBlock(current, start, pattern.length);
}
/// Convert [DateTime] to a [String] exactly as specified by the [pattern].
String encode(DateTime datetime) {
var buffer = StringBuffer();
for (var i = 0; i < _blocks.length; i++) {
var start = _blocks.starts[i];
var end = _blocks.ends[i];
var length = end - start;
var previousEnd = i > 0 ? _blocks.ends[i - 1] : 0;
if (previousEnd < start) {
buffer.write(pattern.substring(previousEnd, start));
}
var number = _extractNumFromDateTime(
_blocks.formatCharacters[i],
datetime,
).toString();
if (number.length > length) {
number = number.substring(number.length - length);
} else if (number.length < length) {
number = number.padLeft(length, '0');
}
buffer.write(number);
}
if (_blocks.length > 0) {
var lastEnd = _blocks.ends.last;
if (lastEnd < pattern.length) {
buffer.write(pattern.substring(lastEnd, pattern.length));
}
}
return buffer.toString();
}
int _extractNumFromDateTime(int? formatChar, DateTime dt) {
if (formatChar == yearCode) {
return dt.year;
} else if (formatChar == centuryCode) {
return (dt.year / 100).floor();
} else if (formatChar == decadeCode) {
return (dt.year / 10).floor();
} else if (formatChar == monthCode) {
return dt.month;
} else if (formatChar == dayCode) {
return dt.day;
} else if (formatChar == hourCode) {
return dt.hour;
} else if (formatChar == minuteCode) {
return dt.minute;
} else if (formatChar == secondCode) {
return dt.second;
}
throw AssertionError("Unreachable, the key is checked in the constructor");
}
/// Parse a string [formattedDateTime] to a local [DateTime] as specified in the
/// [pattern], substituting missing values with a default. Throws an exception
/// on failure to parse.
DateTime decode(String formattedDateTime, {bool isUtc = false}) {
return _decode(formattedDateTime, isUtc, true);
}
/// Same as [decode], but will not throw on parsing erros, instead using
/// the default value as if the format char was not present in the [pattern].
DateTime tryDecode(String formattedDateTime, {bool isUtc = false}) {
return _decode(formattedDateTime, isUtc, false);
}
DateTime _decode(
String formattedDateTime,
bool isUtc,
bool throwOnError,
) {
var characters = formattedDateTime.codeUnits;
var year = 0;
var century = 0;
var decade = 0;
var month = 1;
var day = 1;
var hour = 0;
var minute = 0;
var second = 0;
for (int i = 0; i < _blocks.length; i++) {
var char = _blocks.formatCharacters[i];
var num = _extractNumFromString(characters, i, throwOnError);
if (num != null) {
if (char == yearCode) {
year = num;
} else if (char == centuryCode) {
century = num;
} else if (char == decadeCode) {
decade = num;
} else if (char == monthCode) {
month = num;
} else if (char == dayCode) {
day = num;
} else if (char == hourCode) {
hour = num;
} else if (char == minuteCode) {
minute = num;
} else if (char == secondCode) {
second = num;
}
}
}
var totalYear = year + 100 * century + 10 * decade;
if (isUtc) {
return DateTime.utc(totalYear, month, day, hour, minute, second, 0, 0);
} else {
return DateTime(totalYear, month, day, hour, minute, second, 0, 0);
}
}
int? _extractNumFromString(
List<int> characters, int index, bool throwOnError) {
var parsed =
tryParse(characters, _blocks.starts[index], _blocks.ends[index]);
if (parsed == null && throwOnError) {
throw FormatException(
'${String.fromCharCodes(characters)} should only contain digits');
}
return parsed;
}
static final zeroCode = '0'.codeUnitAt(0);
int? tryParse(List<int> characters, int start, int end) {
int result = 0;
for (var i = start; i < end; i++) {
var character = characters[i];
if (character >= zeroCode && character < zeroCode + 10) {
result = result * 10 + (character - zeroCode);
} else {
return null;
}
}
return result;
}
}
class _ParsedFormatBlocks {
final formatCharacters = <int>[];
final starts = <int>[];
final ends = <int>[];
_ParsedFormatBlocks();
int get length => formatCharacters.length;
void saveBlock(int? char, int start, int end) {
if (char != null) {
formatCharacters.add(char);
starts.add(start);
ends.add(end);
}
}
}