Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: NIO.TimeAmount(string:) and TimeAmount.description #3046

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.DS_Store
/.build
.build
/.index-build
/Packages
/*.xcodeproj
Expand Down
82 changes: 82 additions & 0 deletions Sources/NIOCore/EventLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,88 @@ 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
internal 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".
Lukasa marked this conversation as resolved.
Show resolved Hide resolved
///
/// 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
///
/// - Throws: ValidationError if the string cannot be parsed
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")
}

switch parsedUnit {
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 '\(parsedUnit)' in '\(userProvidedString)'")
}
}

/// Returns a human-readable string representation of the time amount
/// using the most appropriate unit
public var description: String {
Lukasa marked this conversation as resolved.
Show resolved Hide resolved
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 {
Expand Down
103 changes: 102 additions & 1 deletion Tests/NIOCoreTests/TimeAmountTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
import NIOCore

import XCTest

@testable import NIOCore

class TimeAmountTests: XCTestCase {
func testTimeAmountConversion() {
XCTAssertEqual(TimeAmount.nanoseconds(3), .nanoseconds(3))
Expand Down Expand Up @@ -61,4 +63,103 @@ 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 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")
}
}