Skip to content

Commit

Permalink
feat: NIO.TimeAmount(string:) and TimeAmount.description (#3046)
Browse files Browse the repository at this point in the history
Adds `TimeAmount.init(string:)` and `TimeAmount.description` for parsing
time amounts from strings and pretty printing them.

Closes #2504.

### Motivation:

Had a minute, wanted to work on Swift-NIO a bit more, and saw @weissi
still wanted this.

### Modifications:

It's largely based on the snippet @weissi made in #2504 with a few
changes:
- Added unit tests.
- Added support for more unit aliases for convenience based on
@glbrntt's suggestion.
- Changed `.gitignore` to ignore `.build` everywhere, I've got a few of
them when testing locally.


### Open questions:

- I originally thought perhaps I should add support for multiple number
and unit pairs, i.e. `1h 31m`, but decided against it. Feels like an
edge case. Happy to add this if you think it's needed.

---------

Co-authored-by: Cory Benfield <[email protected]>
  • Loading branch information
natikgadzhi and Lukasa authored Jan 16, 2025
1 parent 30e1f2e commit f5773c1
Show file tree
Hide file tree
Showing 3 changed files with 185 additions and 2 deletions.
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".
///
/// 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 {
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")
}
}

0 comments on commit f5773c1

Please sign in to comment.