From 5dca8f4bc264f52d4e0d1ab71e702038e0733ebf Mon Sep 17 00:00:00 2001 From: Natik Gadzhi Date: Sun, 12 Jan 2025 12:50:49 -0800 Subject: [PATCH 1/5] feat: NIO.TimeAmount(string:) and TimeAmount.description --- .gitignore | 2 +- Sources/NIOCore/EventLoop.swift | 88 ++++++++++++++++++++ Tests/NIOCoreTests/TimeAmountTests.swift | 101 +++++++++++++++++++++++ 3 files changed, 190 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f22758cad2..f6bd1f104c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ .DS_Store -/.build +.build /.index-build /Packages /*.xcodeproj diff --git a/Sources/NIOCore/EventLoop.swift b/Sources/NIOCore/EventLoop.swift index 021725af97..c733af133c 100644 --- a/Sources/NIOCore/EventLoop.swift +++ b/Sources/NIOCore/EventLoop.swift @@ -551,6 +551,94 @@ public struct TimeAmount: Hashable, Sendable { } } +/// Contains the logic for parsing time amounts from strings, +/// and printing pretty strings to represent time amounts. +extension TimeAmount: CustomStringConvertible { + + /// Errors thrown when parsint a TimeAmount from a string + public enum ValidationError: Error, Equatable { + /// Can't parse the provided unit + case unsupportedUnit(String) + + /// Can't parse the number into a Double + case invalidNumber(String) + } + + /// Creates a TimeAmount from a string representation with an optional default unit. + /// + /// Supports formats like: + /// - "5s" (5 seconds) + /// - "100ms" (100 milliseconds) + /// - "42" (42 of default unit) + /// - "1 hr" (1 hour) + /// + /// This function only supports one pair of the number and units, i.e. "5s" or "100ms" but not "5s 100ms". + /// + /// Supported units: + /// - h, hr, hrs (hours) + /// - m, min (minutes) + /// - s, sec, secs (seconds) + /// - ms, millis (milliseconds) + /// - us, µs, micros (microseconds) + /// - ns, nanos (nanoseconds) + /// + /// - Parameters: + /// - userProvidedString: The string to parse + /// - defaultUnit: Unit to use if no unit is specified in the string + /// + /// - Throws: ValidationError if the string cannot be parsed + public init(_ userProvidedString: String, defaultUnit: String = "s") throws { + // First parse the string into a number and supported units. + // Clean out white space from the string. + let string = String(userProvidedString.filter { !$0.isWhitespace }).lowercased() + // Grab the number from the string prefix. + let parsedNumbers = string.prefix(while: { $0.isWholeNumber || $0 == "," || $0 == "." }) + // Grab the rest of the string and match this to a known unit later. + let parsedUnit = string.dropFirst(parsedNumbers.count) + + guard let numbers = Int64(parsedNumbers) else { + throw ValidationError.invalidNumber("'\(userProvidedString)' cannot be parsed as number and unit") + } + let unit = parsedUnit.isEmpty ? defaultUnit : String(parsedUnit) + + switch unit { + case "h", "hr", "hrs": + self = .hours(numbers) + case "m", "min": + self = .minutes(numbers) + case "s", "sec", "secs": + self = .seconds(numbers) + case "ms", "millis": + self = .milliseconds(numbers) + case "us", "µs", "micros": + self = .microseconds(numbers) + case "ns", "nanos": + self = .nanoseconds(numbers) + default: + throw ValidationError.unsupportedUnit("Unknown unit '\(unit)' in '\(userProvidedString)'") + } + } + + /// Returns a human-readable string representation of the time amount + /// using the most appropriate unit + public var description: String { + let fullNS = self.nanoseconds + let (fullUS, remUS) = fullNS.quotientAndRemainder(dividingBy: 1_000) + let (fullMS, remMS) = fullNS.quotientAndRemainder(dividingBy: 1_000_000) + let (fullS, remS) = fullNS.quotientAndRemainder(dividingBy: 1_000_000_000) + + if remS == 0 { + return "\(fullS) s" + } else if remMS == 0 { + return "\(fullMS) ms" + } else if remUS == 0 { + return "\(fullUS) us" + } else { + return "\(fullNS) ns" + } + } +} + extension TimeAmount: Comparable { @inlinable public static func < (lhs: TimeAmount, rhs: TimeAmount) -> Bool { diff --git a/Tests/NIOCoreTests/TimeAmountTests.swift b/Tests/NIOCoreTests/TimeAmountTests.swift index 0cf762a1dd..9e02750440 100644 --- a/Tests/NIOCoreTests/TimeAmountTests.swift +++ b/Tests/NIOCoreTests/TimeAmountTests.swift @@ -61,4 +61,105 @@ class TimeAmountTests: XCTestCase { XCTAssertEqual(TimeAmount.minutes(.min), underflowCap) XCTAssertEqual(TimeAmount.hours(.min), underflowCap) } + + func testTimeAmountParsing() throws { + // Test all supported hour formats + XCTAssertEqual(try TimeAmount("2h"), .hours(2)) + XCTAssertEqual(try TimeAmount("2hr"), .hours(2)) + XCTAssertEqual(try TimeAmount("2hrs"), .hours(2)) + + // Test all supported minute formats + XCTAssertEqual(try TimeAmount("3m"), .minutes(3)) + XCTAssertEqual(try TimeAmount("3min"), .minutes(3)) + + // Test all supported second formats + XCTAssertEqual(try TimeAmount("4s"), .seconds(4)) + XCTAssertEqual(try TimeAmount("4sec"), .seconds(4)) + XCTAssertEqual(try TimeAmount("4secs"), .seconds(4)) + + // Test all supported millisecond formats + XCTAssertEqual(try TimeAmount("5ms"), .milliseconds(5)) + XCTAssertEqual(try TimeAmount("5millis"), .milliseconds(5)) + + // Test all supported microsecond formats + XCTAssertEqual(try TimeAmount("6us"), .microseconds(6)) + XCTAssertEqual(try TimeAmount("6µs"), .microseconds(6)) + XCTAssertEqual(try TimeAmount("6micros"), .microseconds(6)) + + // Test all supported nanosecond formats + XCTAssertEqual(try TimeAmount("7ns"), .nanoseconds(7)) + XCTAssertEqual(try TimeAmount("7nanos"), .nanoseconds(7)) + } + + func testTimeAmountParsingWithWhitespace() throws { + XCTAssertEqual(try TimeAmount("5 s"), .seconds(5)) + XCTAssertEqual(try TimeAmount("100 ms"), .milliseconds(100)) + XCTAssertEqual(try TimeAmount("42 ns"), .nanoseconds(42)) + XCTAssertEqual(try TimeAmount(" 5s "), .seconds(5)) + } + + func testTimeAmountParsingCaseInsensitive() throws { + XCTAssertEqual(try TimeAmount("5S"), .seconds(5)) + XCTAssertEqual(try TimeAmount("100MS"), .milliseconds(100)) + XCTAssertEqual(try TimeAmount("1HR"), .hours(1)) + XCTAssertEqual(try TimeAmount("30MIN"), .minutes(30)) + } + + func testTimeAmountParsingWithDefaultUnit() throws { + XCTAssertEqual(try TimeAmount("5", defaultUnit: "ms"), .milliseconds(5)) + XCTAssertEqual(try TimeAmount("42"), .seconds(42)) // default should be seconds + XCTAssertEqual(try TimeAmount("100", defaultUnit: "us"), .microseconds(100)) + } + + func testTimeAmountParsingInvalidInput() throws { + // Empty string + XCTAssertThrowsError(try TimeAmount("")) { error in + XCTAssertEqual( + error as? TimeAmount.ValidationError, + TimeAmount.ValidationError.invalidNumber("'' cannot be parsed as number and unit")) + } + + // Invalid number + XCTAssertThrowsError(try TimeAmount("abc")) { error in + XCTAssertEqual( + error as? TimeAmount.ValidationError, + TimeAmount.ValidationError.invalidNumber("'abc' cannot be parsed as number and unit")) + } + + // Unknown unit + XCTAssertThrowsError(try TimeAmount("5x")) { error in + XCTAssertEqual( + error as? TimeAmount.ValidationError, + TimeAmount.ValidationError.unsupportedUnit("Unknown unit 'x' in '5x'")) + } + + // Missing number + XCTAssertThrowsError(try TimeAmount("ms")) { error in + XCTAssertEqual( + error as? TimeAmount.ValidationError, + TimeAmount.ValidationError.invalidNumber("'ms' cannot be parsed as number and unit")) + } + } + + func testTimeAmountPrettyPrint() { + // Basic formatting + XCTAssertEqual(TimeAmount.seconds(5).description, "5 s") + XCTAssertEqual(TimeAmount.milliseconds(100).description, "100 ms") + XCTAssertEqual(TimeAmount.microseconds(250).description, "250 us") + XCTAssertEqual(TimeAmount.nanoseconds(42).description, "42 ns") + + // Unit selection based on value + XCTAssertEqual(TimeAmount.nanoseconds(1_000).description, "1 us") + XCTAssertEqual(TimeAmount.nanoseconds(1_000_000).description, "1 ms") + XCTAssertEqual(TimeAmount.nanoseconds(1_000_000_000).description, "1 s") + + // Values with remainders + XCTAssertEqual(TimeAmount.nanoseconds(1_500).description, "1500 ns") + XCTAssertEqual(TimeAmount.nanoseconds(1_500_000).description, "1500 us") + XCTAssertEqual(TimeAmount.nanoseconds(1_500_000_000).description, "1500 ms") + + // Negative values + XCTAssertEqual(TimeAmount.seconds(-5).description, "-5 s") + XCTAssertEqual(TimeAmount.milliseconds(-100).description, "-100 ms") + } } From 79f9b672d5b95e6f84a637cb3996681519634591 Mon Sep 17 00:00:00 2001 From: Natik Gadzhi Date: Sun, 12 Jan 2025 12:56:07 -0800 Subject: [PATCH 2/5] formatting --- Sources/NIOCore/EventLoop.swift | 4 +-- Tests/NIOCoreTests/TimeAmountTests.swift | 36 +++++++++++++----------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/Sources/NIOCore/EventLoop.swift b/Sources/NIOCore/EventLoop.swift index c733af133c..d133a1dcb0 100644 --- a/Sources/NIOCore/EventLoop.swift +++ b/Sources/NIOCore/EventLoop.swift @@ -565,7 +565,7 @@ extension TimeAmount: CustomStringConvertible { } /// Creates a TimeAmount from a string representation with an optional default unit. - /// + /// /// Supports formats like: /// - "5s" (5 seconds) /// - "100ms" (100 milliseconds) @@ -576,7 +576,7 @@ extension TimeAmount: CustomStringConvertible { /// /// Supported units: /// - h, hr, hrs (hours) - /// - m, min (minutes) + /// - m, min (minutes) /// - s, sec, secs (seconds) /// - ms, millis (milliseconds) /// - us, µs, micros (microseconds) diff --git a/Tests/NIOCoreTests/TimeAmountTests.swift b/Tests/NIOCoreTests/TimeAmountTests.swift index 9e02750440..b53f3a71a8 100644 --- a/Tests/NIOCoreTests/TimeAmountTests.swift +++ b/Tests/NIOCoreTests/TimeAmountTests.swift @@ -67,25 +67,25 @@ class TimeAmountTests: XCTestCase { XCTAssertEqual(try TimeAmount("2h"), .hours(2)) XCTAssertEqual(try TimeAmount("2hr"), .hours(2)) XCTAssertEqual(try TimeAmount("2hrs"), .hours(2)) - + // Test all supported minute formats XCTAssertEqual(try TimeAmount("3m"), .minutes(3)) XCTAssertEqual(try TimeAmount("3min"), .minutes(3)) - + // Test all supported second formats XCTAssertEqual(try TimeAmount("4s"), .seconds(4)) XCTAssertEqual(try TimeAmount("4sec"), .seconds(4)) XCTAssertEqual(try TimeAmount("4secs"), .seconds(4)) - + // Test all supported millisecond formats XCTAssertEqual(try TimeAmount("5ms"), .milliseconds(5)) XCTAssertEqual(try TimeAmount("5millis"), .milliseconds(5)) - + // Test all supported microsecond formats XCTAssertEqual(try TimeAmount("6us"), .microseconds(6)) XCTAssertEqual(try TimeAmount("6µs"), .microseconds(6)) XCTAssertEqual(try TimeAmount("6micros"), .microseconds(6)) - + // Test all supported nanosecond formats XCTAssertEqual(try TimeAmount("7ns"), .nanoseconds(7)) XCTAssertEqual(try TimeAmount("7nanos"), .nanoseconds(7)) @@ -107,7 +107,7 @@ class TimeAmountTests: XCTestCase { func testTimeAmountParsingWithDefaultUnit() throws { XCTAssertEqual(try TimeAmount("5", defaultUnit: "ms"), .milliseconds(5)) - XCTAssertEqual(try TimeAmount("42"), .seconds(42)) // default should be seconds + XCTAssertEqual(try TimeAmount("42"), .seconds(42)) // default should be seconds XCTAssertEqual(try TimeAmount("100", defaultUnit: "us"), .microseconds(100)) } @@ -116,28 +116,32 @@ class TimeAmountTests: XCTestCase { XCTAssertThrowsError(try TimeAmount("")) { error in XCTAssertEqual( error as? TimeAmount.ValidationError, - TimeAmount.ValidationError.invalidNumber("'' cannot be parsed as number and unit")) + TimeAmount.ValidationError.invalidNumber("'' cannot be parsed as number and unit") + ) } - + // Invalid number XCTAssertThrowsError(try TimeAmount("abc")) { error in XCTAssertEqual( error as? TimeAmount.ValidationError, - TimeAmount.ValidationError.invalidNumber("'abc' cannot be parsed as number and unit")) + TimeAmount.ValidationError.invalidNumber("'abc' cannot be parsed as number and unit") + ) } - + // Unknown unit XCTAssertThrowsError(try TimeAmount("5x")) { error in XCTAssertEqual( error as? TimeAmount.ValidationError, - TimeAmount.ValidationError.unsupportedUnit("Unknown unit 'x' in '5x'")) + TimeAmount.ValidationError.unsupportedUnit("Unknown unit 'x' in '5x'") + ) } - + // Missing number XCTAssertThrowsError(try TimeAmount("ms")) { error in XCTAssertEqual( error as? TimeAmount.ValidationError, - TimeAmount.ValidationError.invalidNumber("'ms' cannot be parsed as number and unit")) + TimeAmount.ValidationError.invalidNumber("'ms' cannot be parsed as number and unit") + ) } } @@ -147,17 +151,17 @@ class TimeAmountTests: XCTestCase { XCTAssertEqual(TimeAmount.milliseconds(100).description, "100 ms") XCTAssertEqual(TimeAmount.microseconds(250).description, "250 us") XCTAssertEqual(TimeAmount.nanoseconds(42).description, "42 ns") - + // Unit selection based on value XCTAssertEqual(TimeAmount.nanoseconds(1_000).description, "1 us") XCTAssertEqual(TimeAmount.nanoseconds(1_000_000).description, "1 ms") XCTAssertEqual(TimeAmount.nanoseconds(1_000_000_000).description, "1 s") - + // Values with remainders XCTAssertEqual(TimeAmount.nanoseconds(1_500).description, "1500 ns") XCTAssertEqual(TimeAmount.nanoseconds(1_500_000).description, "1500 us") XCTAssertEqual(TimeAmount.nanoseconds(1_500_000_000).description, "1500 ms") - + // Negative values XCTAssertEqual(TimeAmount.seconds(-5).description, "-5 s") XCTAssertEqual(TimeAmount.milliseconds(-100).description, "-100 ms") From ff8d127b916d526ed0b24aaab1929e6a11f3c828 Mon Sep 17 00:00:00 2001 From: Natik Gadzhi Date: Mon, 13 Jan 2025 17:41:17 -0800 Subject: [PATCH 3/5] Make validation error enum internal --- Sources/NIOCore/EventLoop.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/NIOCore/EventLoop.swift b/Sources/NIOCore/EventLoop.swift index d133a1dcb0..6da5e15f90 100644 --- a/Sources/NIOCore/EventLoop.swift +++ b/Sources/NIOCore/EventLoop.swift @@ -556,7 +556,7 @@ public struct TimeAmount: Hashable, Sendable { extension TimeAmount: CustomStringConvertible { /// Errors thrown when parsint a TimeAmount from a string - public enum ValidationError: Error, Equatable { + internal enum ValidationError: Error, Equatable { /// Can't parse the provided unit case unsupportedUnit(String) From ed935280a8702349216aa2b1906ae5ab1596c500 Mon Sep 17 00:00:00 2001 From: Natik Gadzhi Date: Mon, 13 Jan 2025 17:41:17 -0800 Subject: [PATCH 4/5] Make validation error enum internal --- Tests/NIOCoreTests/TimeAmountTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/NIOCoreTests/TimeAmountTests.swift b/Tests/NIOCoreTests/TimeAmountTests.swift index b53f3a71a8..391c28bef7 100644 --- a/Tests/NIOCoreTests/TimeAmountTests.swift +++ b/Tests/NIOCoreTests/TimeAmountTests.swift @@ -11,7 +11,7 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// -import NIOCore +@testable import NIOCore import XCTest class TimeAmountTests: XCTestCase { From b00626b418ffaeee961412d8a9df8a4106c79cf4 Mon Sep 17 00:00:00 2001 From: Natik Gadzhi Date: Tue, 14 Jan 2025 09:16:07 -0800 Subject: [PATCH 5/5] removed defaultUnit --- Sources/NIOCore/EventLoop.swift | 18 ++++++------------ Tests/NIOCoreTests/TimeAmountTests.swift | 10 +++------- 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/Sources/NIOCore/EventLoop.swift b/Sources/NIOCore/EventLoop.swift index 6da5e15f90..0885cf5993 100644 --- a/Sources/NIOCore/EventLoop.swift +++ b/Sources/NIOCore/EventLoop.swift @@ -584,24 +584,18 @@ extension TimeAmount: CustomStringConvertible { /// /// - Parameters: /// - userProvidedString: The string to parse - /// - defaultUnit: Unit to use if no unit is specified in the string /// /// - Throws: ValidationError if the string cannot be parsed - public init(_ userProvidedString: String, defaultUnit: String = "s") throws { - // First parse the string into a number and supported units. - // Clean out white space from the string. - let string = String(userProvidedString.filter { !$0.isWhitespace }).lowercased() - // Grab the number from the string prefix. - let parsedNumbers = string.prefix(while: { $0.isWholeNumber || $0 == "," || $0 == "." }) - // Grab the rest of the string and match this to a known unit later. - let parsedUnit = string.dropFirst(parsedNumbers.count) + public init(_ userProvidedString: String) throws { + let lowercased = String(userProvidedString.filter { !$0.isWhitespace }).lowercased() + let parsedNumbers = lowercased.prefix(while: { $0.isWholeNumber || $0 == "," || $0 == "." }) + let parsedUnit = String(lowercased.dropFirst(parsedNumbers.count)) guard let numbers = Int64(parsedNumbers) else { throw ValidationError.invalidNumber("'\(userProvidedString)' cannot be parsed as number and unit") } - let unit = parsedUnit.isEmpty ? defaultUnit : String(parsedUnit) - switch unit { + switch parsedUnit { case "h", "hr", "hrs": self = .hours(numbers) case "m", "min": @@ -615,7 +609,7 @@ extension TimeAmount: CustomStringConvertible { case "ns", "nanos": self = .nanoseconds(numbers) default: - throw ValidationError.unsupportedUnit("Unknown unit '\(unit)' in '\(userProvidedString)'") + throw ValidationError.unsupportedUnit("Unknown unit '\(parsedUnit)' in '\(userProvidedString)'") } } diff --git a/Tests/NIOCoreTests/TimeAmountTests.swift b/Tests/NIOCoreTests/TimeAmountTests.swift index 391c28bef7..1285b75849 100644 --- a/Tests/NIOCoreTests/TimeAmountTests.swift +++ b/Tests/NIOCoreTests/TimeAmountTests.swift @@ -11,9 +11,11 @@ // SPDX-License-Identifier: Apache-2.0 // //===----------------------------------------------------------------------===// -@testable import NIOCore + import XCTest +@testable import NIOCore + class TimeAmountTests: XCTestCase { func testTimeAmountConversion() { XCTAssertEqual(TimeAmount.nanoseconds(3), .nanoseconds(3)) @@ -105,12 +107,6 @@ class TimeAmountTests: XCTestCase { XCTAssertEqual(try TimeAmount("30MIN"), .minutes(30)) } - func testTimeAmountParsingWithDefaultUnit() throws { - XCTAssertEqual(try TimeAmount("5", defaultUnit: "ms"), .milliseconds(5)) - XCTAssertEqual(try TimeAmount("42"), .seconds(42)) // default should be seconds - XCTAssertEqual(try TimeAmount("100", defaultUnit: "us"), .microseconds(100)) - } - func testTimeAmountParsingInvalidInput() throws { // Empty string XCTAssertThrowsError(try TimeAmount("")) { error in