Skip to content
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
71 changes: 71 additions & 0 deletions Sources/Parsing/Conversions/AnyConversion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,58 @@ extension Conversion {
) -> Self where Self == AnyConversion<Input, Output> {
.init(apply: apply, unapply: unapply)
}

/// A conversion that invokes the given throwing apply and unapply functions.
///
/// This overload is useful when your conversion logic can fail and you want to throw specific
/// errors rather than relying on the optional-based conversion that throws a generic
/// ``ConvertingError``.
///
/// ```swift
/// enum ValidationError: Error {
/// case invalidAmount
/// }
///
/// struct Amount {
/// var cents: Int
/// init(dollars: Int, cents: Int) throws {
/// guard dollars >= 0, cents >= 0, cents < 100 else {
/// throw ValidationError.invalidAmount
/// }
/// self.cents = dollars * 100 + cents
/// }
/// }
///
/// let amount = Parse(
/// .convert(
/// apply: { (dollars: Int, cents: Int) in try Amount(dollars: dollars, cents: cents) },
/// unapply: { amount in amount.cents.quotientAndRemainder(dividingBy: 100) }
/// )
/// ) {
/// Digits()
/// "."
/// Digits(2)
/// }
/// ```
///
/// For better performance, consider defining a custom type that conforms to ``Conversion``.
///
/// - Parameters:
/// - apply: A closure that attempts to convert an input into an output. `apply` is executed
/// each time the ``apply(_:)`` method is called on the resulting conversion. The closure
/// can throw an error if the conversion fails.
/// - unapply: A closure that attempts to convert an output into an input. `unapply` is executed
/// each time the ``unapply(_:)`` method is called on the resulting conversion. The closure
/// can throw an error if the conversion fails.
/// - Returns: A conversion that invokes the given throwing apply and unapply functions.
@inlinable
@_disfavoredOverload
public static func convert<Input, Output>(
apply: @escaping (Input) throws -> Output,
unapply: @escaping (Output) throws -> Input
) -> Self where Self == AnyConversion<Input, Output> {
.init(apply: apply, unapply: unapply)
}
}

/// A type-erased ``Conversion``.
Expand Down Expand Up @@ -148,6 +200,25 @@ public struct AnyConversion<Input, Output>: Conversion {
}
}

/// Creates a conversion that wraps the given throwing closures in its ``apply(_:)`` and ``unapply(_:)``
/// methods.
///
/// - Parameters:
/// - apply: A closure that attempts to convert an input into an output. `apply` is executed
/// each time the ``apply(_:)`` method is called on the resulting conversion. The closure
/// can throw an error if the conversion fails.
/// - unapply: A closure that attempts to convert an output into an input. `unapply` is executed
/// each time the ``unapply(_:)`` method is called on the resulting conversion. The closure
/// can throw an error if the conversion fails.
@inlinable
public init(
apply: @escaping (Input) throws -> Output,
unapply: @escaping (Output) throws -> Input
) {
self._apply = apply
self._unapply = unapply
}

@inlinable
public func apply(_ input: Input) throws -> Output {
try self._apply(input)
Expand Down
109 changes: 109 additions & 0 deletions Tests/ParsingTests/ConvertTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import Parsing
import XCTest

final class ConvertTests: XCTestCase {
func testNonThrowingConvert() throws {
let conversion = AnyConversion<Int, String>(
apply: { input in input > 0 ? "\(input)" : nil },
unapply: { output in Int(output) }
)

let result = try conversion.apply(42)
XCTAssertEqual(result, "42")

let reversed = try conversion.unapply("123")
XCTAssertEqual(reversed, 123)

// Test error case
XCTAssertThrowsError(try conversion.apply(-5))
}

func testThrowingConvert() throws {
enum ValidationError: Error {
case invalidInput
case invalidOutput
}

let conversion = AnyConversion<Int, String>(
apply: { input in
guard input > 0 else {
throw ValidationError.invalidInput
}
return "\(input)"
},
unapply: { output in
guard let number = Int(output), number > 0 else {
throw ValidationError.invalidOutput
}
return number
}
)

// Test successful apply
let result = try conversion.apply(42)
XCTAssertEqual(result, "42")

// Test successful unapply
let reversed = try conversion.unapply("123")
XCTAssertEqual(reversed, 123)

// Test throwing apply
XCTAssertThrowsError(try conversion.apply(-5)) { error in
XCTAssertTrue(error is ValidationError)
}

// Test throwing unapply
XCTAssertThrowsError(try conversion.unapply("invalid")) { error in
XCTAssertTrue(error is ValidationError)
}
}

func testConvertStaticMethodThrowing() throws {
enum ValidationError: Error {
case invalidNumber
}

let conversion: AnyConversion<String, Int> = .convert(
apply: { (input: String) throws -> Int in
guard let number = Int(input), number > 0 else {
throw ValidationError.invalidNumber
}
return number
},
unapply: { (output: Int) throws -> String in
guard output > 0 else {
throw ValidationError.invalidNumber
}
return "\(output)"
}
)

// Test successful operations
let result = try conversion.apply("42")
XCTAssertEqual(result, 42)

let reversed = try conversion.unapply(123)
XCTAssertEqual(reversed, "123")

// Test throwing operations
XCTAssertThrowsError(try conversion.apply("invalid"))
XCTAssertThrowsError(try conversion.unapply(-5))
}

func testThrowingConvertOverloadDisambiguation() throws {
// Test that compiler correctly chooses non-throwing version for non-throwing functions
let nonThrowing: AnyConversion<Int, String> = .convert(
apply: { "\($0)" },
unapply: { Int($0) }
)

// Test that we can explicitly use throwing version
let throwing: AnyConversion<Int, String> = .convert(
apply: { (input: Int) throws -> String in "\(input)" },
unapply: { (output: String) throws -> Int in Int(output) ?? 0 }
)

XCTAssertEqual(try nonThrowing.apply(42), "42")
XCTAssertEqual(try throwing.apply(42), "42")
}
}