Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
88 changes: 88 additions & 0 deletions Sources/NIOCore/EventLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
/// - 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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, I wouldn't default defaultUnit to anything. Why is it seconds?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this honestly makes sense AND this is not covered in tests, one sec.

// 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 {
Expand Down
107 changes: 106 additions & 1 deletion Tests/NIOCoreTests/TimeAmountTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
import NIOCore
@testable import NIOCore
import XCTest

class TimeAmountTests: XCTestCase {
Expand Down Expand Up @@ -61,4 +61,109 @@ 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")
}
}
Loading