diff --git a/Sources/NaiveDate.swift b/Sources/NaiveDate.swift index 927849d..4404a30 100644 --- a/Sources/NaiveDate.swift +++ b/Sources/NaiveDate.swift @@ -3,18 +3,20 @@ import Foundation // MARK: - NaiveDate /// Calendar date without a timezone. -public struct NaiveDate: Equatable, Hashable, Comparable, LosslessStringConvertible, Codable, _DateComponentsConvertible { - public let year: Int, month: Int, day: Int +public struct NaiveDate: Sendable, Equatable, Hashable, Comparable, LosslessStringConvertible, Codable, _DateComponentsConvertible { + public var year: Int, month: Int, day: Int /// Initializes the naive date with a given date components. /// - important: The naive types don't validate input components. For any /// precise manipulations with time use native `Date` and `Calendar` types. + @inlinable public init(year: Int, month: Int, day: Int) { self.year = year; self.month = month; self.day = day } // MARK: Comparable + @inlinable public static func <(lhs: NaiveDate, rhs: NaiveDate) -> Bool { return (lhs.year, lhs.month, lhs.day) < (rhs.year, rhs.month, rhs.day) } @@ -22,6 +24,7 @@ public struct NaiveDate: Equatable, Hashable, Comparable, LosslessStringConverti // MARK: LosslessStringConvertible /// Creates a naive date from a given string (e.g. "2017-12-30"). + @inlinable public init?(_ string: String) { // Not using `ISO8601DateFormatter` because it only works with `Date` guard let cmps = _components(from: string, separator: "-"), cmps.count == 3 else { return nil } @@ -29,22 +32,26 @@ public struct NaiveDate: Equatable, Hashable, Comparable, LosslessStringConverti } /// Returns a string representation of a naive date (e.g. "2017-12-30"). + @inlinable public var description: String { return String(format: "%i-%.2i-%.2i", year, month, day) } // MARK: Codable + @inlinable public init(from decoder: Decoder) throws { self = try _decode(from: decoder) } + @inlinable public func encode(to encoder: Encoder) throws { try _encode(self, to: encoder) } // MARK: _DateComponentsConvertible + @inlinable public var dateComponents: DateComponents { return DateComponents(year: year, month: month, day: day) } @@ -53,8 +60,8 @@ public struct NaiveDate: Equatable, Hashable, Comparable, LosslessStringConverti // MARK: - NaiveTime /// Time without a timezone. Allows for second precision. -public struct NaiveTime: Equatable, Hashable, Comparable, LosslessStringConvertible, Codable, _DateComponentsConvertible { - public let hour: Int, minute: Int, second: Int +public struct NaiveTime: Sendable, Equatable, Hashable, Comparable, LosslessStringConvertible, Codable, _DateComponentsConvertible { + public var hour: Int, minute: Int, second: Int /// Initializes the naive time with a given date components. /// - important: The naive types don't validate input components. For any @@ -116,9 +123,9 @@ public struct NaiveTime: Equatable, Hashable, Comparable, LosslessStringConverti // MARK: - NaiveDateTime /// Combined date and time without timezone. -public struct NaiveDateTime: Equatable, Hashable, Comparable, LosslessStringConvertible, Codable, _DateComponentsConvertible { - public let date: NaiveDate - public let time: NaiveTime +public struct NaiveDateTime: Sendable, Equatable, Hashable, Comparable, LosslessStringConvertible, Codable, _DateComponentsConvertible { + public var date: NaiveDate + public var time: NaiveTime /// Initializes the naive datetime with a given date components. /// - important: The naive types don't validate input components. For any @@ -175,26 +182,31 @@ public struct NaiveDateTime: Equatable, Hashable, Comparable, LosslessStringConv } + // MARK: - Calendar Extensions public extension Calendar { // MARK: Naive* -> Date /// Returns a date in calendar's time zone created from the naive date. + @inlinable func date(from date: NaiveDate, in timeZone: TimeZone? = nil) -> Date? { return _date(from: date, in: timeZone) } /// Returns a date in calendar's time zone created from the naive time. + @inlinable func date(from time: NaiveTime, in timeZone: TimeZone? = nil) -> Date? { return _date(from: time, in: timeZone) } /// Returns a date in calendar's time zone created from the naive datetime. + @inlinable func date(from dateTime: NaiveDateTime, in timeZone: TimeZone? = nil) -> Date? { return _date(from: dateTime, in: timeZone) } + @usableFromInline internal func _date(from value: T, in timeZone: TimeZone? = nil) -> Date? { var components = value.dateComponents components.timeZone = timeZone @@ -205,6 +217,7 @@ public extension Calendar { /// Returns naive date from a date, as if in a given time zone. User calendar's time zone. /// - parameter timeZone: By default uses calendar's time zone. + @inlinable func naiveDate(from date: Date, in timeZone: TimeZone? = nil) -> NaiveDate { let components = self.dateComponents(in: timeZone ?? self.timeZone, from: date) return NaiveDate(year: components.year!, month: components.month!, day: components.day!) @@ -212,6 +225,7 @@ public extension Calendar { /// Returns naive time from a date, as if in a given time zone. User calendar's time zone. /// - parameter timeZone: By default uses calendar's time zone. + @inlinable func naiveTime(from date: Date, in timeZone: TimeZone? = nil) -> NaiveTime { let components = self.dateComponents(in: timeZone ?? self.timeZone, from: date) return NaiveTime(hour: components.hour!, minute: components.minute!, second: components.second!) @@ -219,6 +233,7 @@ public extension Calendar { /// Returns naive time from a date, as if in a given time zone. User calendar's time zone. /// - parameter timeZone: By default uses calendar's time zone. + @inlinable func naiveDateTime(from date: Date, in timeZone: TimeZone? = nil) -> NaiveDateTime { let components = self.dateComponents(in: timeZone ?? self.timeZone, from: date) return NaiveDateTime( @@ -231,11 +246,13 @@ public extension Calendar { // MARK: - Private /// A type that can be converted to DateComponents (and in turn to Date). -internal protocol _DateComponentsConvertible { +@usableFromInline +protocol _DateComponentsConvertible { var dateComponents: DateComponents { get } } -private func _decode(from decoder: Decoder) throws -> T { +@usableFromInline +func _decode(from decoder: Decoder) throws -> T { let container = try decoder.singleValueContainer() let string = try container.decode(String.self) guard let value = T(string) else { @@ -244,12 +261,14 @@ private func _decode(from decoder: Decoder) throws return value } -private func _encode(_ value: T, to encoder: Encoder) throws { +@usableFromInline +func _encode(_ value: T, to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(value.description) } -private func _components(from string: String, separator: String) -> [Int]? { +@usableFromInline +func _components(from string: String, separator: String) -> [Int]? { let substrings = string.components(separatedBy: separator) let components = substrings.compactMap(Int.init) return components.count == substrings.count ? components : nil diff --git a/Sources/NaiveDateFormatStyle.swift b/Sources/NaiveDateFormatStyle.swift new file mode 100644 index 0000000..ca62d55 --- /dev/null +++ b/Sources/NaiveDateFormatStyle.swift @@ -0,0 +1,280 @@ +import Foundation + +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +public extension NaiveDate { + struct FormatStyle: Foundation.FormatStyle { + var date: Date.FormatStyle.DateStyle? + var time: Date.FormatStyle.TimeStyle? + var locale: Locale + var calendar: Calendar + var timeZone: TimeZone + var capitalizationContext: FormatStyleCapitalizationContext + + /// Creates a format style for a NaiveDate. + /// + /// - Parameters: + /// - date: The style to use for formatting the date component. Defaults to `nil`. + /// - time: The style to use for formatting the time component. Defaults to `nil`. + /// - locale: The locale to use for formatting. Defaults to `autoupdatingCurrent`. + /// - calendar: The calendar to use for formatting. Defaults to `autoupdatingCurrent`. + /// - timeZone: The time zone to use for formatting. Defaults to `autoupdatingCurrent`. + /// - capitalizationContext: The context for capitalization. Defaults to `unknown`. + public init(date: Date.FormatStyle.DateStyle? = nil, + time: Date.FormatStyle.TimeStyle? = nil, + locale: Locale = .autoupdatingCurrent, + calendar: Calendar = .autoupdatingCurrent, + timeZone: TimeZone = .autoupdatingCurrent, + capitalizationContext: FormatStyleCapitalizationContext = .unknown) { + self.date = date + self.time = time + self.locale = locale + self.calendar = calendar + self.timeZone = timeZone + self.capitalizationContext = capitalizationContext + } + + /// Formats the given NaiveDate value. + /// + /// - Parameter value: The NaiveDate value to format. + /// - Returns: A formatted string representing the NaiveDate. + public func format(_ value: NaiveDate) -> String { + calendar.date(from: value).map { date in + let dateStyle = Date.FormatStyle( + date: self.date, + time: self.time, + locale: locale, + calendar: calendar, + timeZone: timeZone, + capitalizationContext: capitalizationContext + ) + return date.formatted(dateStyle) + } ?? "" + } + + /// Returns a new format style with the specified locale. + /// + /// - Parameter locale: The locale to apply to the format style. + /// - Returns: A new `NaiveDate.FormatStyle` with the given locale. + public func locale(_ locale: Locale) -> NaiveDate.FormatStyle { + .init( + date: date, + time: time, + locale: locale, + calendar: calendar, + timeZone: timeZone, + capitalizationContext: capitalizationContext + ) + } + } + + /// Formats the NaiveDate using the provided format style. + /// + /// - Parameter format: The format style to apply. + /// - Returns: The formatted string output. + func formatted(_ format: F) -> F.FormatOutput where F.FormatInput == NaiveDate { + format.format(self) + } + + /// Formats the NaiveDate using the default format style. + /// + /// - Returns: A formatted string representation of the NaiveDate. + func formatted() -> String { + formatted(FormatStyle()) + } + + /// Formats the NaiveDate with specified date and time styles. + /// + /// - Parameters: + /// - date: The style to use for formatting the date component. + /// - time: The style to use for formatting the time component. + /// - Returns: A formatted string representation of the NaiveDate. + func formatted(date: Date.FormatStyle.DateStyle, time: Date.FormatStyle.TimeStyle = .omitted) -> String { + formatted(FormatStyle(date: date, time: time)) + } +} + +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +public extension NaiveTime { + struct FormatStyle: Foundation.FormatStyle { + var date: Date.FormatStyle.DateStyle? + var time: Date.FormatStyle.TimeStyle? + var locale: Locale + var calendar: Calendar + var timeZone: TimeZone + var capitalizationContext: FormatStyleCapitalizationContext + + /// Creates a format style for a NaiveTime. + /// + /// - Parameters: + /// - dateStyle: The style to use for formatting the date component. Defaults to `nil`. + /// - timeStyle: The style to use for formatting the time component. Defaults to `nil`. + /// - locale: The locale to use for formatting. Defaults to `autoupdatingCurrent`. + /// - calendar: The calendar to use for formatting. Defaults to `autoupdatingCurrent`. + /// - timeZone: The time zone to use for formatting. Defaults to `autoupdatingCurrent`. + /// - capitalizationContext: The context for capitalization. Defaults to `unknown`. + public init(date: Date.FormatStyle.DateStyle? = nil, + time: Date.FormatStyle.TimeStyle? = nil, + locale: Locale = .autoupdatingCurrent, + calendar: Calendar = .autoupdatingCurrent, + timeZone: TimeZone = .autoupdatingCurrent, + capitalizationContext: FormatStyleCapitalizationContext = .unknown) { + self.date = date + self.time = time + self.locale = locale + self.calendar = calendar + self.timeZone = timeZone + self.capitalizationContext = capitalizationContext + } + + /// Formats the given NaiveTime value. + /// + /// - Parameter value: The NaiveTime value to format. + /// - Returns: A formatted string representing the NaiveTime. + public func format(_ value: NaiveTime) -> String { + calendar.date(from: value).map { date in + let dateStyle = Date.FormatStyle( + date: self.date, + time: self.time, + locale: locale, + calendar: calendar, + timeZone: timeZone, + capitalizationContext: capitalizationContext + ) + return date.formatted(dateStyle) + } ?? "" + } + + /// Returns a new format style with the specified locale. + /// + /// - Parameter locale: The locale to apply to the format style. + /// - Returns: A new `NaiveTime.FormatStyle` with the given locale. + public func locale(_ locale: Locale) -> NaiveTime.FormatStyle { + .init( + date: date, + time: time, + locale: locale, + calendar: calendar, + timeZone: timeZone, + capitalizationContext: capitalizationContext + ) + } + } + + /// Formats the NaiveTime using the provided format style. + /// + /// - Parameter format: The format style to apply. + /// - Returns: The formatted string output. + func formatted(_ format: F) -> F.FormatOutput where F.FormatInput == NaiveTime { + format.format(self) + } + + /// Formats the NaiveTime using the default format style. + /// + /// - Returns: A formatted string representation of the NaiveTime. + func formatted() -> String { + formatted(FormatStyle()) + } + + /// Formats the NaiveTime with specified date and time styles. + /// + /// - Parameters: + /// - date: The style to use for formatting the date component. + /// - time: The style to use for formatting the time component. + /// - Returns: A formatted string representation of the NaiveTime. + func formatted(date: Date.FormatStyle.DateStyle = .omitted, time: Date.FormatStyle.TimeStyle) -> String { + formatted(FormatStyle(date: date, time: time)) + } +} + +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +public extension NaiveDateTime { + struct FormatStyle: Foundation.FormatStyle { + var date: Date.FormatStyle.DateStyle? + var time: Date.FormatStyle.TimeStyle? + var locale: Locale + var calendar: Calendar + var timeZone: TimeZone + var capitalizationContext: FormatStyleCapitalizationContext + + /// Creates a format style for a NaiveDateTime. + /// + /// - Parameters: + /// - dateStyle: The style to use for formatting the date component. Defaults to `nil`. + /// - timeStyle: The style to use for formatting the time component. Defaults to `nil`. + /// - locale: The locale to use for formatting. Defaults to `autoupdatingCurrent`. + /// - calendar: The calendar to use for formatting. Defaults to `autoupdatingCurrent`. + /// - timeZone: The time zone to use for formatting. Defaults to `autoupdatingCurrent`. + /// - capitalizationContext: The context for capitalization. Defaults to `unknown`. + public init(date: Date.FormatStyle.DateStyle? = nil, + time: Date.FormatStyle.TimeStyle? = nil, + locale: Locale = .autoupdatingCurrent, + calendar: Calendar = .autoupdatingCurrent, + timeZone: TimeZone = .autoupdatingCurrent, + capitalizationContext: FormatStyleCapitalizationContext = .unknown) { + self.date = date + self.time = time + self.locale = locale + self.calendar = calendar + self.timeZone = timeZone + self.capitalizationContext = capitalizationContext + } + + /// Formats the given NaiveDateTime value. + /// + /// - Parameter value: The NaiveDateTime value to format. + /// - Returns: A formatted string representing the NaiveDateTime. + public func format(_ value: NaiveDateTime) -> String { + calendar.date(from: value).map { date in + let dateStyle = Date.FormatStyle( + date: self.date, + time: time, + locale: locale, + calendar: calendar, + timeZone: timeZone, + capitalizationContext: capitalizationContext + ) + + return date.formatted(dateStyle) + } ?? "" + } + + /// Returns a new format style with the specified locale. + /// + /// - Parameter locale: The locale to apply to the format style. + /// - Returns: A new `NaiveDateTime.FormatStyle` with the given locale. + public func locale(_ locale: Locale) -> NaiveDate.FormatStyle { + .init( + date: date, + locale: locale, + calendar: calendar, + timeZone: timeZone, + capitalizationContext: capitalizationContext + ) + } + } + + /// Formats the NaiveDateTime using the provided format style. + /// + /// - Parameter format: The format style to apply. + /// - Returns: The formatted string output. + func formatted(_ format: F) -> F.FormatOutput where F.FormatInput == NaiveDateTime { + format.format(self) + } + + /// Formats the NaiveDateTime using the default format style. + /// + /// - Returns: A formatted string representation of the NaiveDateTime. + func formatted() -> String { + formatted(FormatStyle()) + } + + /// Formats the NaiveDateTime with specified date and time styles. + /// + /// - Parameters: + /// - date: The style to use for formatting the date component. + /// - time: The style to use for formatting the time component. + /// - Returns: A formatted string representation of the NaiveDateTime. + func formatted(date: Date.FormatStyle.DateStyle, time: Date.FormatStyle.TimeStyle) -> String { + formatted(FormatStyle(date: date, time: time)) + } +} diff --git a/Sources/NaiveDateFormatter.swift b/Sources/NaiveDateFormatter.swift index beb3d4f..25c4a34 100644 --- a/Sources/NaiveDateFormatter.swift +++ b/Sources/NaiveDateFormatter.swift @@ -4,18 +4,22 @@ import Foundation /// Formatting without time zones. public final class NaiveDateFormatter { - private let formatter = DateFormatter() + @usableFromInline + let formatter = DateFormatter() + @inlinable public init(_ closure: (_ formatter: DateFormatter) -> Void) { closure(formatter) } + @inlinable public convenience init(format: String) { self.init { $0.dateFormat = format } } + @inlinable public convenience init(dateStyle: DateFormatter.Style = .none, timeStyle: DateFormatter.Style = .none) { self.init { $0.dateStyle = dateStyle @@ -27,10 +31,12 @@ public final class NaiveDateFormatter { return formatter.calendar._date(from: value).map { formatter.string(from: $0) } } + @inlinable public func string(from value: NaiveTime) -> String? { return formatter.calendar._date(from: value).map { formatter.string(from: $0) } } + @inlinable public func string(from value: NaiveDateTime) -> String? { return formatter.calendar._date(from: value).map { formatter.string(from: $0) } } @@ -40,18 +46,22 @@ public final class NaiveDateFormatter { /// Formatting without time zones. public final class NaiveDateRangeFormatter { - private let formatter = DateIntervalFormatter() + @usableFromInline + let formatter = DateIntervalFormatter() + @inlinable public init(_ closure: (_ formatter: DateIntervalFormatter) -> Void) { closure(formatter) } + @inlinable public convenience init(format: String) { self.init { $0.dateTemplate = format } } + @inlinable public convenience init(dateStyle: DateIntervalFormatter.Style = .none, timeStyle: DateIntervalFormatter.Style = .none) { self.init { $0.dateStyle = dateStyle @@ -59,14 +69,17 @@ public final class NaiveDateRangeFormatter { } } + @inlinable public func string(from start: NaiveDate, to end: NaiveDate) -> String? { return formatter.calendar._dateRange(from: start, to: end).map { formatter.string(from: $0, to: $1) } } + @inlinable public func string(from start: NaiveTime, to end: NaiveTime) -> String? { return formatter.calendar._dateRange(from: start, to: end).map { formatter.string(from: $0, to: $1) } } + @inlinable public func string(from start: NaiveDateTime, to end: NaiveDateTime) -> String? { return formatter.calendar._dateRange(from: start, to: end).map { formatter.string(from: $0, to: $1) } } @@ -74,7 +87,8 @@ public final class NaiveDateRangeFormatter { // MARK: - Private -private extension Calendar { +extension Calendar { + @inlinable func _dateRange(from start: T, to end: T) -> (Date, Date)? { guard let start = _date(from: start), let end = _date(from: end) else { return nil } return (start, end) diff --git a/Tests/NaiveDateFormatStyleTests.swift b/Tests/NaiveDateFormatStyleTests.swift new file mode 100644 index 0000000..74cbecc --- /dev/null +++ b/Tests/NaiveDateFormatStyleTests.swift @@ -0,0 +1,46 @@ +import Foundation +import XCTest +import NaiveDate + +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +final class NaiveDateFormatStyleTest: XCTestCase { + func testFormattedNaiveDateAgainstDate_withoutParameters() throws { + let naiveDate = NaiveDate(year: 2024, month: 8, day: 12) + let foundationDate = try XCTUnwrap(Calendar.current.date(from: naiveDate)) + + let formattedFoundationDate = foundationDate.formatted() + let formattedNaiveDate = naiveDate.formatted() + + XCTAssertEqual(formattedNaiveDate, formattedFoundationDate) + } + + func testFormattedNaiveDateAgainstDate_numeric() throws { + let naiveDate = NaiveDate(year: 2024, month: 8, day: 12) + let foundationDate = try XCTUnwrap(Calendar.current.date(from: naiveDate)) + + let formattedFoundationDate = foundationDate.formatted(date: .numeric, time: .omitted) + let formattedNaiveDate = naiveDate.formatted(date: .numeric) + + XCTAssertEqual(formattedNaiveDate, formattedFoundationDate) + } + + func testFormattedNaiveDateTimeAgainstDate_withoutParameters() throws { + let naiveDate = NaiveDateTime(date: .init(year: 2024, month: 8, day: 12), time: .init(hour: 5, minute: 3, second: 1)) + let foundationDate = try XCTUnwrap(Calendar.current.date(from: naiveDate)) + + let formattedFoundationDate = foundationDate.formatted() + let formattedNaiveDate = naiveDate.formatted() + + XCTAssertEqual(formattedNaiveDate, formattedFoundationDate) + } + + func testFormattedNaiveDateTimeAgainstDate_numeric() throws { + let naiveDate = NaiveDateTime(date: .init(year: 2024, month: 8, day: 12), time: .init(hour: 5, minute: 3, second: 1)) + let foundationDate = try XCTUnwrap(Calendar.current.date(from: naiveDate)) + + let formattedFoundationDate = foundationDate.formatted(date: .numeric, time: .standard) + let formattedNaiveDate = naiveDate.formatted(date: .numeric, time: .standard) + + XCTAssertEqual(formattedNaiveDate, formattedFoundationDate) + } +}