diff --git a/Sources/FoundationEssentials/Formatting/Date+HTTPFormatStyle.swift b/Sources/FoundationEssentials/Formatting/Date+HTTPFormatStyle.swift index f3860f457..4adde9c1c 100644 --- a/Sources/FoundationEssentials/Formatting/Date+HTTPFormatStyle.swift +++ b/Sources/FoundationEssentials/Formatting/Date+HTTPFormatStyle.swift @@ -60,32 +60,23 @@ extension Date { } fileprivate func parse(_ value: String, in range: Range) -> (String.Index, Date)? { - var v = value[range] + let v = value[range] guard !v.isEmpty else { return nil } - - let result = v.withUTF8 { buffer -> (Int, Date)? in - let view = BufferView(unsafeBufferPointer: buffer)! - guard let comps = try? componentsStyle.components(from: value, in: view) else { - return nil - } - - // HTTP dates are always GMT - guard let date = Calendar(identifier: .gregorian).date(from: comps.components) else { - return nil - } - - return (comps.consumed, date) + guard #available(FoundationSpan 6.2, *) else { + fatalError("Span unavailable") } - - guard let result else { + + guard let comps = try? componentsStyle.components(in: v.utf8Span), + let date = Calendar(identifier: .gregorian).date(from: comps.components) + else { return nil } - - let endIndex = value.utf8.index(v.startIndex, offsetBy: result.0) - return (endIndex, result.1) + + let endIndex = value.utf8.index(v.startIndex, offsetBy: comps.consumed) + return (endIndex, date) } } } @@ -316,39 +307,41 @@ extension DateComponents { } private func parse(_ value: String, in range: Range) -> (String.Index, DateComponents)? { - var v = value[range] + let v = value[range] guard !v.isEmpty else { return nil } - - let result = v.withUTF8 { buffer -> (Int, DateComponents)? in - let view = BufferView(unsafeBufferPointer: buffer)! - guard let comps = try? components(from: value, in: view) else { - return nil - } - - return (comps.consumed, comps.components) + guard #available(FoundationSpan 6.2, *) else { + fatalError("Span unavailable") } - - guard let result else { + + guard let comps = try? components(in: v.utf8Span) else { return nil } - - let endIndex = value.utf8.index(v.startIndex, offsetBy: result.0) - return (endIndex, result.1) + + let endIndex = value.utf8.index(v.startIndex, offsetBy: comps.consumed) + return (endIndex, comps.components) } - - fileprivate func components(from inputString: String, in view: borrowing BufferView) throws -> ComponentsParseResult { + + @available(FoundationSpan 6.2, *) + fileprivate func components( + in view: UTF8Span + ) throws -> ComponentsParseResult { // https://www.rfc-editor.org/rfc/rfc9110.html#http.date // , :: GMT - var it = view.makeIterator() + // Produce an error message to throw + func error(_ extendedDescription: String? = nil) -> CocoaError { + parseError(view, exampleFormattedString: Date.HTTPFormatStyle().format(Date.now), extendedDescription: extendedDescription) + } + + var it = view.makeCursor() var dc = DateComponents() // Despite the spec, we allow the weekday name to be optional. guard let maybeWeekday1 = it.peek() else { - throw parseError(inputString, exampleFormattedString: Date.HTTPFormatStyle().format(Date.now)) + throw error() } if isASCIIDigit(maybeWeekday1) { @@ -356,9 +349,9 @@ extension DateComponents { } else { // Anything else must be a day-name (Mon, Tue, ... Sun) guard let weekday1 = it.next(), let weekday2 = it.next(), let weekday3 = it.next() else { - throw parseError(inputString, exampleFormattedString: Date.HTTPFormatStyle().format(Date.now)) + throw error() } - + dc.weekday = switch (weekday1, weekday2, weekday3) { case (UInt8(ascii: "S"), UInt8(ascii: "u"), UInt8(ascii: "n")): 1 @@ -375,20 +368,30 @@ extension DateComponents { case (UInt8(ascii: "S"), UInt8(ascii: "a"), UInt8(ascii: "t")): 7 default: - throw parseError(inputString, exampleFormattedString: Date.HTTPFormatStyle().format(Date.now), extendedDescription: "Malformed weekday name") + throw error("Malformed weekday name") } // Move past , and space to weekday - try it.expectCharacter(UInt8(ascii: ","), input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now), extendedDescription: "Missing , after weekday") - try it.expectCharacter(UInt8(ascii: " "), input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now), extendedDescription: "Missing space after weekday") + guard it.matchByte(UInt8(ascii: ",")) else { + throw error("Missing , after weekday") + } + guard it.matchByte(UInt8(ascii: " ")) else { + throw error("Missing space after weekday") + } } - dc.day = try it.digits(minDigits: 2, maxDigits: 2, input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now), extendedDescription: "Missing or malformed day") - try it.expectCharacter(UInt8(ascii: " "), input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now)) + guard let day = it.parseNumber(minDigits: 2, maxDigits: 2) else { + throw error("Missing or malformed day") + } + dc.day = day + + guard it.matchByte(UInt8(ascii: " ")) else { + throw error() + } // month-name (Jan, Feb, ... Dec) guard let month1 = it.next(), let month2 = it.next(), let month3 = it.next() else { - throw parseError(inputString, exampleFormattedString: Date.HTTPFormatStyle().format(Date.now), extendedDescription: "Missing month") + throw error("Missing month") } dc.month = switch (month1, month2, month3) { @@ -417,32 +420,50 @@ extension DateComponents { case (UInt8(ascii: "D"), UInt8(ascii: "e"), UInt8(ascii: "c")): 12 default: - throw parseError(inputString, exampleFormattedString: Date.HTTPFormatStyle().format(Date.now), extendedDescription: "Month \(String(describing: dc.month)) is out of bounds") + throw error("Month \(String(describing: dc.month)) is out of bounds") + } + + guard it.matchByte(UInt8(ascii: " ")) else { + throw error() } - try it.expectCharacter(UInt8(ascii: " "), input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now)) + guard let year = it.parseNumber(minDigits: 4, maxDigits: 4) else { + throw error() + } + dc.year = year - dc.year = try it.digits(minDigits: 4, maxDigits: 4, input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now)) - try it.expectCharacter(UInt8(ascii: " "), input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now)) + guard it.matchByte(UInt8(ascii: " ")) else { + throw error() + } - let hour = try it.digits(minDigits: 2, maxDigits: 2, input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now)) + guard let hour = it.parseNumber(minDigits: 2, maxDigits: 2) else { + throw error() + } if hour < 0 || hour > 23 { - throw parseError(inputString, exampleFormattedString: Date.HTTPFormatStyle().format(Date.now), extendedDescription: "Hour \(hour) is out of bounds") + throw error("Hour \(hour) is out of bounds") } dc.hour = hour - try it.expectCharacter(UInt8(ascii: ":"), input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now)) - let minute = try it.digits(minDigits: 2, maxDigits: 2, input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now)) + guard it.matchByte(UInt8(ascii: ":")) else { + throw error() + } + guard let minute = it.parseNumber(minDigits: 2, maxDigits: 2) else { + throw error() + } if minute < 0 || minute > 59 { - throw parseError(inputString, exampleFormattedString: Date.HTTPFormatStyle().format(Date.now), extendedDescription: "Minute \(minute) is out of bounds") + throw error("Minute \(minute) is out of bounds") } dc.minute = minute - try it.expectCharacter(UInt8(ascii: ":"), input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now)) - let second = try it.digits(minDigits: 2, maxDigits: 2, input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now)) + guard it.matchByte(UInt8(ascii: ":")) else { + throw error() + } + guard let second = it.parseNumber(minDigits: 2, maxDigits: 2) else { + throw error() + } // second '60' is supported in the spec for leap seconds, but Foundation does not support leap seconds. 60 is adjusted to 59. if second < 0 || second > 60 { - throw parseError(inputString, exampleFormattedString: Date.HTTPFormatStyle().format(Date.now), extendedDescription: "Second \(second) is out of bounds") + throw error("Second \(second) is out of bounds") } // Foundation does not support leap seconds. We convert 60 seconds into 59 seconds. if second == 60 { @@ -450,19 +471,24 @@ extension DateComponents { } else { dc.second = second } - try it.expectCharacter(UInt8(ascii: " "), input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now)) + guard it.matchByte(UInt8(ascii: " ")) else { + throw error() + } // "GMT" - try it.expectCharacter(UInt8(ascii: "G"), input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now), extendedDescription: "Missing GMT time zone") - try it.expectCharacter(UInt8(ascii: "M"), input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now), extendedDescription: "Missing GMT time zone") - try it.expectCharacter(UInt8(ascii: "T"), input: inputString, onFailure: Date.HTTPFormatStyle().format(Date.now), extendedDescription: "Missing GMT time zone") + guard it.matchByte(UInt8(ascii: "G")), + it.matchByte(UInt8(ascii: "M")), + it.matchByte(UInt8(ascii: "T")) + else { + throw error("Missing GMT time zone") + } // Time zone is always GMT, calendar is always Gregorian dc.timeZone = .gmt dc.calendar = Calendar(identifier: .gregorian) // Would be nice to see this functionality on BufferView, but for now we calculate it ourselves. - let utf8CharactersRead = it.curPointer - view.startIndex._rawValue + let utf8CharactersRead = it.currentOffset return ComponentsParseResult(consumed: utf8CharactersRead, components: dc) } diff --git a/Sources/FoundationEssentials/Formatting/DateComponents+ISO8601FormatStyle.swift b/Sources/FoundationEssentials/Formatting/DateComponents+ISO8601FormatStyle.swift index 7bf1d3dc0..88fb48fdd 100644 --- a/Sources/FoundationEssentials/Formatting/DateComponents+ISO8601FormatStyle.swift +++ b/Sources/FoundationEssentials/Formatting/DateComponents+ISO8601FormatStyle.swift @@ -407,10 +407,16 @@ extension DateComponents.ISO8601FormatStyle { var components: DateComponents } - private func components(from inputString: String, fillMissingUnits: Bool, defaultTimeZone: TimeZone, in view: borrowing BufferView) throws -> ComponentsParseResult { + @available(FoundationSpan 6.2, *) + private func components(fillMissingUnits: Bool, defaultTimeZone: TimeZone, in view: UTF8Span) throws -> ComponentsParseResult { let fields = formatFields - - var it = view.makeIterator() + + // Produce an error message to throw + func error(_ extendedDescription: String? = nil) -> CocoaError { + parseError(view, exampleFormattedString: Date.ISO8601FormatStyle(self).format(Date.now), extendedDescription: extendedDescription) + } + + var it = view.makeCursor() var needsSeparator = false // Keep these fields local and set them in the DateComponents once for improved performance @@ -429,7 +435,9 @@ extension DateComponents.ISO8601FormatStyle { if fields.contains(.year) { let max = dateSeparator == .omitted ? 4 : nil - let value = try it.digits(maxDigits: max, input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) + guard let value = it.parseNumber(maxDigits: max) else { + throw error() + } if fields.contains(.weekOfYear) { yearForWeekOfYear = value } else { @@ -444,30 +452,38 @@ extension DateComponents.ISO8601FormatStyle { if fields.contains(.month) { if needsSeparator && dateSeparator == .dash { - try it.expectCharacter(UInt8(ascii: "-"), input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) + guard it.matchByte(UInt8(ascii: "-")) else { + throw error() + } } // parse month digits let max = dateSeparator == .omitted ? 2 : nil - let value = try it.digits(maxDigits: max, input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) - guard _calendar.maximumRange(of: .month)!.contains(value) else { - throw parseError(inputString, exampleFormattedString: Date.ISO8601FormatStyle(self).format(Date.now)) + guard let value = it.parseNumber(maxDigits: max), + _calendar.maximumRange(of: .month)!.contains(value) + else { + throw error() } month = value needsSeparator = true } else if fields.contains(.weekOfYear) { if needsSeparator && dateSeparator == .dash { - try it.expectCharacter(UInt8(ascii: "-"), input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) + guard it.matchByte(UInt8(ascii: "-")) else { + throw error() + } } // parse W - try it.expectCharacter(UInt8(ascii: "W"), input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) + guard it.matchByte(UInt8(ascii: "W")) else { + throw error() + } // parse week of year digits let max = dateSeparator == .omitted ? 2 : nil - let value = try it.digits(maxDigits: max, input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) - guard _calendar.maximumRange(of: .weekOfYear)!.contains(value) else { - throw parseError(inputString, exampleFormattedString: Date.ISO8601FormatStyle(self).format(Date.now)) + guard let value = it.parseNumber(maxDigits: max), + _calendar.maximumRange(of: .weekOfYear)!.contains(value) + else { + throw error() } weekOfYear = value @@ -479,26 +495,32 @@ extension DateComponents.ISO8601FormatStyle { if fields.contains(.day) { if needsSeparator && dateSeparator == .dash { - try it.expectCharacter(UInt8(ascii: "-"), input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) + guard it.matchByte(UInt8(ascii: "-")) else { + throw error() + } } if fields.contains(.weekOfYear) { // parse day of week ('ee') // ISO8601 "1" is Monday. For our date components, 2 is Monday. Add 1 to account for difference. let max = dateSeparator == .omitted ? 2 : nil - let value = (try it.digits(maxDigits: max, input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) % 7) + 1 - + guard let n = it.parseNumber(maxDigits: max) else { + throw error() + } + let value = (n % 7) + 1 + guard _calendar.maximumRange(of: .weekday)!.contains(value) else { - throw parseError(inputString, exampleFormattedString: Date.ISO8601FormatStyle(self).format(Date.now)) + throw error() } weekday = value } else if fields.contains(.month) { // parse day of month ('dd') let max = dateSeparator == .omitted ? 2 : nil - let value = try it.digits(maxDigits: max, input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) - guard _calendar.maximumRange(of: .day)!.contains(value) else { - throw parseError(inputString, exampleFormattedString: Date.ISO8601FormatStyle(self).format(Date.now)) + guard let value = it.parseNumber(maxDigits: max), + _calendar.maximumRange(of: .day)!.contains(value) + else { + throw error() } day = value @@ -506,9 +528,10 @@ extension DateComponents.ISO8601FormatStyle { } else { // parse 3 digit day of year ('DDD') let max = dateSeparator == .omitted ? 3 : nil - let value = try it.digits(maxDigits: max, input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) - guard _calendar.maximumRange(of: .dayOfYear)!.contains(value) else { - throw parseError(inputString, exampleFormattedString: Date.ISO8601FormatStyle(self).format(Date.now)) + guard let value = it.parseNumber(maxDigits: max), + _calendar.maximumRange(of: .dayOfYear)!.contains(value) + else { + throw error() } dayOfYear = value @@ -522,32 +545,48 @@ extension DateComponents.ISO8601FormatStyle { switch dateTimeSeparator { case .standard: // parse T - try it.expectCharacter(UInt8(ascii: "T"), input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) + guard it.matchByte(UInt8(ascii: "T")) else { + throw error() + } case .space: // parse any number of spaces - try it.expectOneOrMoreCharacters(UInt8(ascii: " "), input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) + guard let _ = it.matchOneOrMore(UInt8(ascii: " ")) else { + throw error() + } } } switch timeSeparator { case .colon: - hour = try it.digits(input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) - try it.expectCharacter(UInt8(ascii: ":"), input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) - minute = try it.digits(input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) - try it.expectCharacter(UInt8(ascii: ":"), input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) - second = try it.digits(input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) + guard let hrs = it.parseNumber(), + it.matchByte(UInt8(ascii: ":")), + let mins = it.parseNumber(), + it.matchByte(UInt8(ascii: ":")), + let secs = it.parseNumber() + else { + throw error() + } + (hour, minute, second) = (hrs, mins, secs) + case .omitted: - hour = try it.digits(maxDigits: 2, input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) - minute = try it.digits(maxDigits: 2, input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) - second = try it.digits(maxDigits: 2, input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) + guard let hrs = it.parseNumber(maxDigits: 2), + it.matchByte(UInt8(ascii: ":")), + let mins = it.parseNumber(maxDigits: 2), + it.matchByte(UInt8(ascii: ":")), + let secs = it.parseNumber(maxDigits: 2) + else { + throw error() + } + (hour, minute, second) = (hrs, mins, secs) } // When parsing, fractional seconds are always optional (as of Swift 6.2). // Peek ahead and see if the next character is a period or not. If not, just continue on. - if let next = it.peek(), next == UInt8(ascii: ".") { + if it.matchByte(UInt8(ascii: ".")) { // Looks like a fractional seconds - let _ = it.next() // consume the period - let fractionalSeconds = try it.digits(nanoseconds: true, input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) + guard let fractionalSeconds = it.parseNumber(nanoseconds: true) else { + throw error() + } nanosecond = fractionalSeconds } @@ -557,15 +596,14 @@ extension DateComponents.ISO8601FormatStyle { if fields.contains(.timeZone) { // For compatibility with ICU implementation, if the dateTimeSeparator is a space, consume any number (including zero) of spaces here. if dateTimeSeparator == .space { - it.expectZeroOrMoreCharacters(UInt8(ascii: " ")) + it.matchZeroOrMore(UInt8(ascii: " ")) } guard let plusOrMinusOrZ = it.next() else { // Expected time zone - throw parseError(inputString, exampleFormattedString: Date.ISO8601FormatStyle(self).format(Date.now)) + throw error() } - if plusOrMinusOrZ == UInt8(ascii: "Z") || plusOrMinusOrZ == UInt8(ascii: "z") { timeZone = .gmt } else { @@ -581,7 +619,7 @@ extension DateComponents.ISO8601FormatStyle { if let next = it.peek(), (next == UInt8(ascii: "+") || next == UInt8(ascii: "-")) { if next == UInt8(ascii: "+") { positive = true } else { positive = false } - it.advance() + it.uncheckedAdvance() } else { positive = true tzOffset = 0 @@ -594,7 +632,7 @@ extension DateComponents.ISO8601FormatStyle { if let next = it.peek(), (next == UInt8(ascii: "+") || next == UInt8(ascii: "-")) { if next == UInt8(ascii: "+") { positive = true } else { positive = false } - it.advance() + it.uncheckedAdvance() } else { positive = true tzOffset = 0 @@ -606,7 +644,7 @@ extension DateComponents.ISO8601FormatStyle { positive = false } else { // Expected time zone, found garbage - throw parseError(inputString, exampleFormattedString: Date.ISO8601FormatStyle(self).format(Date.now)) + throw error() } if !skipDigits { @@ -614,15 +652,17 @@ extension DateComponents.ISO8601FormatStyle { // parse Time Zone: ISO8601 extended hms?, with Z // examples: -08:00, -07:52:58, Z - let hours = try it.digits(maxDigits: 2, input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) - + guard let hours = it.parseNumber(maxDigits: 2) else { + throw error() + } + // Expect a colon, or a minutes value, or the end. let expectMinutes: Bool if let next = it.peek() { if next == UInt8(ascii: ":") { // Throw it away - it.advance() - + it.uncheckedAdvance() + // But we should have minutes after this expectMinutes = true } else if isASCIIDigit(next) { @@ -641,16 +681,17 @@ extension DateComponents.ISO8601FormatStyle { tzOffset = hours * 3600 } else { // Continue on - let minutes = try it.digits(maxDigits: 2, input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) - - if let maybeColon = it.peek(), maybeColon == UInt8(ascii: ":") { - // Throw it away - it.advance() + guard let minutes = it.parseNumber(maxDigits: 2) else { + throw error() } - if let secondsTens = it.peek(), isASCIIDigit(secondsTens) { + _ = it.matchByte(UInt8(ascii: ":")) + + if it.peek(isASCIIDigit) != nil { // We have seconds - let seconds = try it.digits(maxDigits: 2, input: inputString, onFailure: Date.ISO8601FormatStyle(self).format(Date.now)) + guard let seconds = it.parseNumber(maxDigits: 2) else { + throw error() + } tzOffset = (hours * 3600) + (minutes * 60) + seconds } else { // If the next character is missing, that's allowed - the time can be something like just -0852 and then the string can end @@ -664,7 +705,7 @@ extension DateComponents.ISO8601FormatStyle { } else { guard let parsedTimeZone = TimeZone(secondsFromGMT: positive ? tzOffset : -tzOffset) else { // Out of range time zone - throw parseError(inputString, exampleFormattedString: Date.ISO8601FormatStyle(self).format(Date.now)) + throw error() } timeZone = parsedTimeZone @@ -692,7 +733,7 @@ extension DateComponents.ISO8601FormatStyle { rawDayOfYear: dayOfYear) // Would be nice to see this functionality on BufferView, but for now we calculate it ourselves. - let utf8CharactersRead = it.curPointer - view.startIndex._rawValue + let utf8CharactersRead = it.currentOffset return ComponentsParseResult(consumed: utf8CharactersRead, components: dc) } } @@ -732,27 +773,21 @@ extension DateComponents.ISO8601FormatStyle : ParseStrategy { } internal func parse(_ value: String, fillMissingUnits: Bool, in range: Range) -> (String.Index, DateComponents)? { - var v = value[range] + let v = value[range] guard !v.isEmpty else { return nil } - - let result = v.withUTF8 { buffer -> (Int, DateComponents)? in - let view = BufferView(unsafeBufferPointer: buffer)! - guard let comps = try? components(from: value, fillMissingUnits: fillMissingUnits, defaultTimeZone: timeZone, in: view) else { - return nil - } - - return (comps.consumed, comps.components) + guard #available(FoundationSpan 6.2, *) else { + fatalError("Span unavailable") } - - guard let result else { + + guard let comps = try? components(fillMissingUnits: fillMissingUnits, defaultTimeZone: timeZone, in: v.utf8Span) else { return nil } - - let endIndex = value.utf8.index(v.startIndex, offsetBy: result.0) - return (endIndex, result.1) + + let endIndex = value.utf8.index(v.startIndex, offsetBy: comps.consumed) + return (endIndex, comps.components) } } diff --git a/Sources/FoundationEssentials/Formatting/FormatParsingUtilities.swift b/Sources/FoundationEssentials/Formatting/FormatParsingUtilities.swift index a7142388f..a18c4c9bf 100644 --- a/Sources/FoundationEssentials/Formatting/FormatParsingUtilities.swift +++ b/Sources/FoundationEssentials/Formatting/FormatParsingUtilities.swift @@ -10,6 +10,23 @@ // //===----------------------------------------------------------------------===// +internal // NOTE: internal because BufferView is internal, `parseError` below is `package` +func parseError( + _ value: BufferView, exampleFormattedString: String?, extendedDescription: String? = nil +) -> CocoaError { + // TODO: change to UTF8Span, and prototype string append and interpolation taking UTF8Span + parseError(String(decoding: value, as: UTF8.self), exampleFormattedString: exampleFormattedString, extendedDescription: extendedDescription) +} + +@available(FoundationSpan 6.2, *) +func parseError( + _ value: UTF8Span, exampleFormattedString: String?, extendedDescription: String? = nil +) -> CocoaError { + // TODO: change to UTF8Span, and prototype string append and interpolation taking UTF8Span + parseError(String(copying: value), exampleFormattedString: exampleFormattedString, extendedDescription: extendedDescription) +} + + package func parseError(_ value: String, exampleFormattedString: String?, extendedDescription: String? = nil) -> CocoaError { let errorStr: String if let exampleFormattedString = exampleFormattedString { @@ -24,30 +41,127 @@ func isASCIIDigit(_ x: UInt8) -> Bool { x >= UInt8(ascii: "0") && x <= UInt8(ascii: "9") } -extension BufferViewIterator { - mutating func expectCharacter(_ expected: UInt8, input: String, onFailure: @autoclosure () -> (String), extendedDescription: String? = nil) throws { - guard let parsed = next(), parsed == expected else { - throw parseError(input, exampleFormattedString: onFailure(), extendedDescription: extendedDescription) + +@available(FoundationSpan 6.2, *) +extension UTF8Span { + // This is just an iterator style type, though for UTF8 we can + // load scalars and Characters, presumably. + // + // NOTE: I'm calling this "Cursor" temporarily as "Iterator" might + // end up being taken for other reasons. + struct Cursor: ~Escapable { + var span: UTF8Span + var currentOffset: Int + + @lifetime(copy span) + init(_ span: UTF8Span) { + self.span = span + self.currentOffset = 0 } } - - mutating func expectOneOrMoreCharacters(_ expected: UInt8, input: String, onFailure: @autoclosure () -> (String), extendedDescription: String? = nil) throws { - guard let parsed = next(), parsed == expected else { - throw parseError(input, exampleFormattedString: onFailure(), extendedDescription: extendedDescription) + + @lifetime(copy self) // copy or borrow? + func makeCursor() -> Cursor { + .init(self) + } +} + +@available(FoundationSpan 6.2, *) +extension UTF8Span.Cursor { + @lifetime(self: copy self) + mutating func uncheckedAdvance() { + assert(self.currentOffset < span.count) + self.currentOffset += 1 + } + + func peek() -> UInt8? { + guard !isEmpty else { return nil } + return span.span[unchecked: self.currentOffset] + } + + @lifetime(self: copy self) + mutating func next() -> UInt8? { + guard !isEmpty else { return nil } + defer { uncheckedAdvance() } + return peek() + } + + var isEmpty: Bool { self.currentOffset >= span.count } + + @lifetime(self: copy self) + mutating func consume(_ byte: UInt8) -> Bool { + guard peek() == byte else { + return false } - - while let parsed = peek(), parsed == expected { - advance() + uncheckedAdvance() + return true + } + +} + +@available(FoundationSpan 6.2, *) +extension UTF8Span.Cursor { + // Returns the next byte if there is one and it + // matches the predicate, otherwise false + func peek(_ f: (UInt8) -> Bool) -> UInt8? { + guard let b = peek(), f(b) else { + return nil } + return b } - - mutating func expectZeroOrMoreCharacters(_ expected: UInt8) { - while let parsed = peek(), parsed == expected { - advance() + + @lifetime(self: copy self) + mutating func matchByte(_ expected: UInt8) -> Bool { + if peek() == expected { + uncheckedAdvance() + return true + } + return false + } + + @lifetime(self: copy self) + mutating func matchPredicate(_ f: (UInt8) -> Bool) -> UInt8? { + guard let b = peek(f) else { + return nil } + uncheckedAdvance() + return b } - - mutating func digits(minDigits: Int? = nil, maxDigits: Int? = nil, nanoseconds: Bool = false, input: String, onFailure: @autoclosure () -> (String), extendedDescription: String? = nil) throws -> Int { + + /** + NOTE: We want a `match(anyOf:)` operation that takes an Array or Set + literal (or String literal, clearly delineated to mean ASCII), but is guaranteed not to actually materialize a runtime managed object. + + For example, that would handle this pattern from ISO8601: + ``` + if let next = it.peek(), (next == UInt8(ascii: "+") || next == UInt8(ascii: "-")) { + if next == UInt8(ascii: "+") { positive = true } + else { positive = false } + it.uncheckedAdvance() + ``` + */ + + @lifetime(self: copy self) + @discardableResult + mutating func matchZeroOrMore(_ expected: UInt8) -> Int { + var count = 0 + while matchByte(expected) { + count += 1 + } + return count + } + + @lifetime(self: copy self) + @discardableResult + mutating func matchOneOrMore(_ expected: UInt8) -> Int? { + let c = matchZeroOrMore(expected) + return c == 0 ? nil : c + } + + // TODO: I think it would be cleaner to separate out + // nanosecond handling here... + @lifetime(self: copy self) + mutating func parseNumber(minDigits: Int? = nil, maxDigits: Int? = nil, nanoseconds: Bool = false) -> Int? { // Consume all leading zeros, parse until we no longer see a digit var result = 0 var count = 0 @@ -57,21 +171,21 @@ extension BufferViewIterator { let digit = Int(next - UInt8(ascii: "0")) result *= 10 result += digit - advance() + uncheckedAdvance() count += 1 if count >= max { break } } - + guard count > 0 else { // No digits actually found - throw parseError(input, exampleFormattedString: onFailure(), extendedDescription: extendedDescription) + return nil } - + if let minDigits, count < minDigits { // Too few digits found - throw parseError(input, exampleFormattedString: onFailure(), extendedDescription: extendedDescription) + return nil } - + if nanoseconds { // Keeps us in the land of integers if count == 1 { return result * 100_000_000 } @@ -83,13 +197,11 @@ extension BufferViewIterator { if count == 7 { return result * 100 } if count == 8 { return result * 10 } if count == 9 { return result } - throw parseError(input, exampleFormattedString: onFailure(), extendedDescription: extendedDescription) + return nil } return result } - - } // Formatting helpers diff --git a/Sources/FoundationEssentials/JSON/BufferViewIterator.swift b/Sources/FoundationEssentials/JSON/BufferViewIterator.swift index 0ca685dc5..cc1d715f2 100644 --- a/Sources/FoundationEssentials/JSON/BufferViewIterator.swift +++ b/Sources/FoundationEssentials/JSON/BufferViewIterator.swift @@ -49,4 +49,9 @@ extension BufferViewIterator: IteratorProtocol { guard curPointer < endPointer else { return } curPointer = curPointer.advanced(by: MemoryLayout.stride) } + + mutating func _uncheckedAdvance() { + assert(curPointer < endPointer) + curPointer = curPointer.advanced(by: MemoryLayout.stride) + } }