// // SwiftDate // Parse, validate, manipulate, and display dates, time and timezones in Swift // // Created by Daniele Margutti // - Web: https://www.danielemargutti.com // - Twitter: https://twitter.com/danielemargutti // - Mail: hello@danielemargutti.com // // Copyright © 2019 Daniele Margutti. Licensed under MIT License. // // swiftlint:disable file_length import Foundation /// This defines all possible errors you can encounter parsing ISO8601 string /// /// - eof: end of file /// - notDigit: expected digit, value cannot be parsed as int /// - notDouble: expected double digit, value cannot be parsed as double /// - invalid: invalid state reached. Something in the format is not correct public enum ISO8601ParserError: Error { case eof case notDigit case notDouble case invalid } fileprivate extension Int { /// Return `true` if current year is a leap year, `false` otherwise var isLeapYear: Bool { return ((self % 4) == 0) && (((self % 100) != 0) || ((self % 400) == 0)) } } // MARK: - Internal Extension for UnicodeScalar type internal extension UnicodeScalar { /// return `true` if current character is a digit (arabic), `false` otherwise var isDigit: Bool { return "0"..."9" ~= self } /// return `true` if current character is a space var isSpace: Bool { return CharacterSet.whitespaces.contains(self) } } /// This is the ISO8601 Parser class: it evaluates automatically the format of the ISO8601 date /// and attempt to parse it in a valid `Date` object. /// Resulting date also includes Time Zone settings and a property which allows you to inspect /// single date components. /// /// This work is inspired to the original ISO8601DateFormatter class written in ObjC by /// Peter Hosey (available here https://bitbucket.org/boredzo/iso-8601-parser-unparser). /// I've made a Swift porting and fixed some issues when parsing several ISO8601 date variants. // swiftlint:disable type_body_length public class ISOParser: StringToDateTransformable { /// Internal structure internal enum Weekday: Int { case monday = 0 case tuesday = 1 case wednesday = 2 case thursday = 3 } public struct Options { /// Time separator character. By default is `:`. var time_separator: ISOParser.ISOChar = ":" /// Strict parsing. By default is `false`. var strict: Bool = false public init(strict: Bool = false) { self.strict = strict } } /// Some typealias to make the code cleaner public typealias ISOString = String.UnicodeScalarView public typealias ISOIndex = String.UnicodeScalarView.Index public typealias ISOChar = UnicodeScalar public typealias ISOParsedDate = (date: Date?, timezone: TimeZone?) /// This represent the internal parser status representation public struct ParsedDate { /// Type of date parsed /// /// - monthAndDate: month and date style /// - week: date with week number /// - dateOnly: date only // swiftlint:disable nesting public enum DateStyle { case monthAndDate case week case dateOnly } /// Parsed year value var year: Int = 0 /// Parsed month or week number var month_or_week: Int = 0 /// Parsed day value var day: Int = 0 /// Parsed hour value var hour: Int = 0 /// Parsed minutes value var minute: TimeInterval = 0.0 /// Parsed seconds value var seconds: TimeInterval = 0.0 /// Parsed nanoseconds value var nanoseconds: TimeInterval = 0.0 /// Parsed weekday number (1=monday, 7=sunday) /// If `nil` source string has not specs about weekday. var weekday: Int? /// Timezone parsed hour value var tz_hour: Int = 0 /// Timezone parsed minute value var tz_minute: Int = 0 /// Type of parsed date var type: DateStyle = .monthAndDate /// Parsed timezone object var timezone: TimeZone? } /// Source generation calendar. private var srcCalendar = Calendars.gregorian.toCalendar() /// Source raw parsed values private var date = ParsedDate() /// Source string represented as unicode scalars private var string: ISOString /// Current position of the parser in source string. /// Initially is equal to `string.startIndex` private var cIdx: ISOIndex /// Just a shortcut to the last index in source string private var eIdx: ISOIndex /// Lenght of the string private var length: Int /// Number of hyphens characters found before any value /// Consequential "-" are used to define implicit values in dates. private var hyphens: Int = 0 /// Private date components used for default values private var now_cmps: DateComponents /// Configuration used for parser private var options: ISOParser.Options /// Date components parsed private(set) var date_components: DateComponents? /// Parsed date private(set) var parsedDate: Date? /// Parsed timezone private(set) var parsedTimeZone: TimeZone? /// Date adjusted at parsed timezone private var dateInTimezone: Date? { get { srcCalendar.timeZone = date.timezone ?? TimeZone(identifier: "UTC")! return srcCalendar.date(from: date_components!) } } /// Initialize a new parser with a source ISO8601 string to parse /// Parsing is done during initialization; any exception is reported /// before allocating. /// /// - Parameters: /// - src: source ISO8601 string /// - config: configuration used for parsing /// - Throws: throw an `ISO8601Error` if parsing operation fails public init?(_ src: String, options: ISOParser.Options? = nil) { let src_trimmed = src.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) guard src_trimmed.count > 0 else { return nil } string = src_trimmed.unicodeScalars length = src_trimmed.count cIdx = string.startIndex eIdx = string.endIndex self.options = (options ?? ISOParser.Options()) self.now_cmps = srcCalendar.dateComponents([.year, .month, .day], from: Date()) var idx = cIdx while idx < eIdx { if string[idx] == "-" { hyphens += 1 } else { break } idx = string.index(after: idx) } do { try parse() } catch { return nil } } // MARK: - Internal Parser /// Private parsing function /// /// - Throws: throw an `ISO8601Error` if parsing operation fails @discardableResult private func parse() throws -> ISOParsedDate { // PARSE DATE if current() == "T" { // There is no date here, only a time. // Set the date to now; then we'll parse the time. next() guard current()?.isDigit ?? false else { throw ISO8601ParserError.invalid } date.year = now_cmps.year! date.month_or_week = now_cmps.month! date.day = now_cmps.day! } else { moveUntil(is: "-") let is_time_only = (string.contains("T") == false && string.contains(":") && !string.contains("-")) if is_time_only == false { var (num_digits, segment) = try read_int() switch num_digits { case 0: try parse_digits_0(num_digits, &segment) case 8: try parse_digits_8(num_digits, &segment) case 6: try parse_digits_6(num_digits, &segment) case 4: try parse_digits_4(num_digits, &segment) case 5: try parse_digits_5(num_digits, &segment) case 1: try parse_digits_1(num_digits, &segment) case 2: try parse_digits_2(num_digits, &segment) case 7: try parse_digits_7(num_digits, &segment) //YYYY DDD (ordinal date) case 3: try parse_digits_3(num_digits, &segment) //--DDD (ordinal date, implicit year) default: throw ISO8601ParserError.invalid } } else { date.year = now_cmps.year! date.month_or_week = now_cmps.month! date.day = now_cmps.day! } } var hasTime = false if current()?.isSpace ?? false || current() == "T" { hasTime = true next() } // PARSE TIME if current()?.isDigit ?? false == true { let time_sep = options.time_separator let hasTimeSeparator = string.contains(time_sep) date.hour = try read_int(2).value if hasTimeSeparator == false && hasTime { date.minute = TimeInterval(try read_int(2).value) } else if current() == time_sep { next() if time_sep == "," || time_sep == "." { //We can't do fractional minutes when '.' is the segment separator. //Only allow whole minutes and whole seconds. date.minute = TimeInterval(try read_int(2).value) if current() == time_sep { next() date.seconds = TimeInterval(try read_int(2).value) } } else { //Allow a fractional minute. //If we don't get a fraction, look for a seconds segment. //Otherwise, the fraction of a minute is the seconds. date.minute = try read_double().value if current() != ":" { var int_part: Double = 0.0 var frac_part: Double = 0.0 frac_part = modf(date.minute, &int_part) date.minute = int_part date.seconds = frac_part if date.seconds > Double.ulpOfOne { // Convert fraction (e.g. .5) into seconds (e.g. 30). date.seconds *= 60 } else if current() == time_sep { next() // date.seconds = try read_double().value let value = try modf(read_double().value) date.nanoseconds = TimeInterval(round(value.1 * 1000) * 1_000_000) date.seconds = TimeInterval(value.0) } } else { // fractional minutes next() let value = try modf(read_double().value) date.nanoseconds = TimeInterval(round(value.1 * 1000) * 1_000_000) date.seconds = TimeInterval(value.0) } } } if options.strict == false { if cIdx != eIdx && current()?.isSpace ?? false == true { next() } } if cIdx != eIdx { switch current() { case "Z": date.timezone = TimeZone(abbreviation: "UTC") case "+", "-": let is_negative = current() == "-" next() if current()?.isDigit ?? false == true { //Read hour offset. date.tz_hour = try read_int(2).value if is_negative == true { date.tz_hour = -date.tz_hour } // Optional separator if current() == time_sep { next() } if current()?.isDigit ?? false { // Read minute offset date.tz_minute = try read_int(2).value if is_negative == true { date.tz_minute = -date.tz_minute } } let timezone_offset = (date.tz_hour * 3600) + (date.tz_minute * 60) date.timezone = TimeZone(secondsFromGMT: timezone_offset) } default: break } } } date_components = DateComponents() date_components!.year = date.year date_components!.day = date.day date_components!.hour = date.hour date_components!.minute = Int(date.minute) date_components!.second = Int(date.seconds) date_components!.nanosecond = Int(date.nanoseconds) switch date.type { case .monthAndDate: date_components!.month = date.month_or_week case .week: //Adapted from . //This works by converting the week date into an ordinal date, then letting the next case handle it. let prevYear = date.year - 1 let YY = prevYear % 100 let prevC = prevYear - YY let prevG = YY + YY / 4 let isLeapYear = (((prevC / 100) % 4) * 5) let jan1Weekday = ((isLeapYear + prevG) % 7) var day = ((8 - jan1Weekday) + (7 * (jan1Weekday > Weekday.thursday.rawValue ? 1 : 0))) day += (date.day - 1) + (7 * (date.month_or_week - 2)) if let weekday = date.weekday { //date_components!.weekday = weekday date_components!.day = day + weekday } else { date_components!.day = day } case .dateOnly: //An "ordinal date". break } //cfg.calendar.timeZone = date.timezone ?? TimeZone(identifier: "UTC")! //parsedDate = cfg.calendar.date(from: date_components!) let tz = date.timezone ?? TimeZone(identifier: "UTC")! parsedTimeZone = tz srcCalendar.timeZone = tz parsedDate = srcCalendar.date(from: date_components!) return (parsedDate, parsedTimeZone) } private func parse_digits_3(_ num_digits: Int, _ segment: inout Int) throws { //Technically, the standard only allows one hyphen. But it says that two hyphens is the logical implementation, and one was dropped for brevity. So I have chosen to allow the missing hyphen. if hyphens < 1 || (hyphens > 2 && options.strict == false) { throw ISO8601ParserError.invalid } date.day = segment date.year = now_cmps.year! date.type = .dateOnly if options.strict == true && (date.day > (365 + (date.year.isLeapYear ? 1 : 0))) { throw ISO8601ParserError.invalid } } private func parse_digits_7(_ num_digits: Int, _ segment: inout Int) throws { guard hyphens == 0 else { throw ISO8601ParserError.invalid } date.day = segment % 1000 date.year = segment / 1000 date.type = .dateOnly if options.strict == true && (date.day > (365 + (date.year.isLeapYear ? 1 : 0))) { throw ISO8601ParserError.invalid } } private func parse_digits_2(_ num_digits: Int, _ segment: inout Int) throws { func parse_hyphens_3(_ num_digits: Int, _ segment: inout Int) throws { date.year = now_cmps.year! date.month_or_week = now_cmps.month! date.day = segment } func parse_hyphens_2(_ num_digits: Int, _ segment: inout Int) throws { date.year = now_cmps.year! date.month_or_week = segment if current() == "-" { next() date.day = try read_int(2).value } else { date.day = 1 } } func parse_hyphens_1(_ num_digits: Int, _ segment: inout Int) throws { let current_year = now_cmps.year! let current_century = (current_year % 100) date.year = segment + (current_year - current_century) if num_digits == 1 { // implied decade date.year += current_century - (current_year % 10) } if current() == "-" { next() if current() == "W" { next() date.type = .week } date.month_or_week = try read_int(2).value if current() == "-" { next() if date.type == .week { // weekday number let weekday = try read_int().value if weekday > 7 { throw ISO8601ParserError.invalid } date.weekday = weekday } else { date.day = try read_int().value if date.day == 0 { date.day = 1 } if date.month_or_week == 0 { date.month_or_week = 1 } } } else { date.day = 1 } } else { date.month_or_week = 1 date.day = 1 } } func parse_hyphens_0(_ num_digits: Int, _ segment: inout Int) throws { if current() == "-" { // Implicit century date.year = now_cmps.year! date.year -= (date.year % 100) date.year += segment next() if current() == "W" { try parseWeekAndDay() } else if current()?.isDigit ?? false == false { try centuryOnly(&segment) } else { // Get month and/or date. let (v_count, v_seg) = try read_int() switch v_count { case 4: // YY-MMDD date.day = v_seg % 100 date.month_or_week = v_seg / 100 case 1: // YY-M; YY-M-DD (extension) if options.strict == true { throw ISO8601ParserError.invalid } case 2: // YY-MM; YY-MM-DD date.month_or_week = v_seg if current() == "-" { next() if current()?.isDigit ?? false == true { date.day = try read_int(2).value } else { date.day = 1 } } else { date.day = 1 } case 3: // Ordinal date date.day = v_seg date.type = .dateOnly default: break } } } else if current() == "W" { date.year = now_cmps.year! date.year -= (date.year % 100) date.year += segment try parseWeekAndDay() } else { try centuryOnly(&segment) } } switch hyphens { case 0: try parse_hyphens_0(num_digits, &segment) case 1: try parse_hyphens_1(num_digits, &segment) //-YY; -YY-MM (implicit century) case 2: try parse_hyphens_2(num_digits, &segment) //--MM; --MM-DD case 3: try parse_hyphens_3(num_digits, &segment) //---DD default: throw ISO8601ParserError.invalid } } private func parse_digits_1(_ num_digits: Int, _ segment: inout Int) throws { if options.strict == true { // Two digits only - never just one. guard hyphens == 1 else { throw ISO8601ParserError.invalid } if current() == "-" { next() } next() guard current() == "W" else { throw ISO8601ParserError.invalid } date.year = now_cmps.year! date.year -= (date.year % 10) date.year += segment } else { try parse_digits_2(num_digits, &segment) } } private func parse_digits_5(_ num_digits: Int, _ segment: inout Int) throws { guard hyphens == 0 else { throw ISO8601ParserError.invalid } // YYDDD date.year = now_cmps.year! date.year -= (date.year % 100) date.year += segment / 1000 date.day = segment % 1000 date.type = .dateOnly } private func parse_digits_4(_ num_digits: Int, _ segment: inout Int) throws { func parse_hyphens_0(_ num_digits: Int, _ segment: inout Int) throws { date.year = segment if current() == "-" { next() } if current()?.isDigit ?? false == false { if current() == "W" { try parseWeekAndDay() } else { date.month_or_week = 1 date.day = 1 } } else { let (v_num, v_seg) = try read_int() switch v_num { case 4: // MMDD date.day = v_seg % 100 date.month_or_week = v_seg / 100 case 2: // MM date.month_or_week = v_seg if current() == "-" { next() } if current()?.isDigit ?? false == false { date.day = 1 } else { date.day = try read_int().value } case 3: // DDD date.day = v_seg % 1000 date.type = .dateOnly if options.strict == true && (date.day > 365 + (date.year.isLeapYear ? 1 : 0)) { throw ISO8601ParserError.invalid } default: throw ISO8601ParserError.invalid } } } func parse_hyphens_1(_ num_digits: Int, _ segment: inout Int) throws { date.month_or_week = segment % 100 date.year = segment / 100 if current() == "-" { next() } if current()?.isDigit ?? false == false { date.day = 1 } else { date.day = try read_int().value } } func parse_hyphens_2(_ num_digits: Int, _ segment: inout Int) throws { date.day = segment % 100 date.month_or_week = segment / 100 date.year = now_cmps.year! } switch hyphens { case 0: try parse_hyphens_0(num_digits, &segment) // YYYY case 1: try parse_hyphens_1(num_digits, &segment) // YYMM case 2: try parse_hyphens_2(num_digits, &segment) // MMDD default: throw ISO8601ParserError.invalid } } private func parse_digits_6(_ num_digits: Int, _ segment: inout Int) throws { // YYMMDD (implicit century) guard hyphens == 0 else { throw ISO8601ParserError.invalid } date.day = segment % 100 segment /= 100 date.month_or_week = segment % 100 date.year = now_cmps.year! date.year -= (date.year % 100) date.year += (segment / 100) } private func parse_digits_8(_ num_digits: Int, _ segment: inout Int) throws { // YYYY MM DD guard hyphens == 0 else { throw ISO8601ParserError.invalid } date.day = segment % 100 segment /= 100 date.month_or_week = segment % 100 date.year = segment / 100 } private func parse_digits_0(_ num_digits: Int, _ segment: inout Int) throws { guard current() == "W" else { throw ISO8601ParserError.invalid } if seek(1) == "-" && isDigit(seek(2)) && ((hyphens == 1 || hyphens == 2) && options.strict == false) { date.year = now_cmps.year! date.month_or_week = 1 next(2) try parseDayAfterWeek() } else if hyphens == 1 { date.year = now_cmps.year! if current() == "W" { next() date.month_or_week = try read_int(2).value date.type = .week try parseWeekday() } else { try parseDayAfterWeek() } } else { throw ISO8601ParserError.invalid } } private func parseWeekday() throws { if current() == "-" { next() } let weekday = try read_int().value if weekday > 7 { throw ISO8601ParserError.invalid } date.type = .week date.weekday = weekday } private func parseWeekAndDay() throws { next() if current()?.isDigit ?? false == false { //Not really a week-based date; just a year followed by '-W'. guard options.strict == false else { throw ISO8601ParserError.invalid } date.month_or_week = 1 date.day = 1 } else { date.month_or_week = try read_int(2).value try parseWeekday() } } private func parseDayAfterWeek() throws { date.day = current()?.isDigit ?? false == true ? try read_int(2).value : 1 date.type = .week } private func centuryOnly(_ segment: inout Int) throws { date.year = segment * 100 + now_cmps.year! % 100 date.month_or_week = 1 date.day = 1 } /// Return `true` if given character is a char /// /// - Parameter char: char to evaluate /// - Returns: `true` if char is a digit, `false` otherwise private func isDigit(_ char: UnicodeScalar?) -> Bool { guard let char = char else { return false } return char.isDigit } /// MARK: - Scanner internal functions /// Get the value at specified offset from current scanner position without /// moving the current scanner's index. /// /// - Parameter offset: offset to move /// - Returns: char at given position, `nil` if not found @discardableResult public func seek(_ offset: Int = 1) -> ISOChar? { let move_idx = string.index(cIdx, offsetBy: offset) guard move_idx < eIdx else { return nil } return string[move_idx] } /// Return the char at the current position of the scanner /// /// - Parameter next: if `true` return the current char and move to the next position /// - Returns: the char sat the current position of the scanner @discardableResult public func current(_ next: Bool = false) -> ISOChar? { guard cIdx != eIdx else { return nil } let current = string[cIdx] if next == true { cIdx = string.index(after: cIdx) } return current } /// Move by `offset` characters the index of the scanner and return the char at the current /// position. If EOF is reached `nil` is returned. /// /// - Parameter offset: offset value (use negative number to move backwards) /// - Returns: character at the current position. @discardableResult private func next(_ offset: Int = 1) -> ISOChar? { let next = string.index(cIdx, offsetBy: offset) guard next < eIdx else { return nil } cIdx = next return string[cIdx] } /// Read from the current scanner index and parse the value as Int. /// /// - Parameter max_count: number of characters to move. If nil scanners continues until a non /// digit value is encountered. /// - Returns: parsed value /// - Throws: throw an exception if parser fails @discardableResult private func read_int(_ max_count: Int? = nil) throws -> (count: Int, value: Int) { var move_idx = cIdx var count = 0 while move_idx < eIdx { if let max = max_count, count >= max { break } if string[move_idx].isDigit == false { break } count += 1 move_idx = string.index(after: move_idx) } let raw_value = String(string[cIdx.. (count: Int, value: Double) { var move_idx = cIdx var count = 0 var fractional_start = false while move_idx < eIdx { let char = string[move_idx] if char == "." || char == "," { if fractional_start == true { throw ISO8601ParserError.notDouble } else { fractional_start = true } } else { if char.isDigit == false { break } } count += 1 move_idx = string.index(after: move_idx) } let raw_value = String(string[cIdx.. Int { var move_idx = cIdx var count = 0 while move_idx < eIdx { guard string[move_idx] == char else { break } move_idx = string.index(after: move_idx) count += 1 } cIdx = move_idx return count } /// Move the current scanner index to the next position until passed `char` value is /// encountered or `eof` is reached. /// /// - Parameter char: char /// - Returns: the number of characters passed @discardableResult private func moveUntil(isNot char: UnicodeScalar) -> Int { var move_idx = cIdx var count = 0 while move_idx < eIdx { guard string[move_idx] != char else { break } move_idx = string.index(after: move_idx) count += 1 } cIdx = move_idx return count } /// Return a date parsed from a valid ISO8601 string /// /// - Parameter string: source string /// - Returns: a valid `Date` object or `nil` if date cannot be parsed public static func date(from string: String) -> ISOParsedDate? { guard let parser = ISOParser(string) else { return nil } return (parser.parsedDate, parser.parsedTimeZone) } public static func parse(_ string: String, region: Region?, options: Any?) -> DateInRegion? { let formatOptions = options as? ISOParser.Options guard let parser = ISOParser(string, options: formatOptions), let date = parser.parsedDate else { return nil } let parsedRegion = Region(calendar: region?.calendar ?? Region.ISO.calendar, zone: (region?.timeZone ?? parser.parsedTimeZone ?? Region.ISO.timeZone), locale: region?.locale ?? Region.ISO.locale) return DateInRegion(date, region: parsedRegion) } }