From 447f0c022a4372a709606353dc28713d75f00fb7 Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Fri, 19 May 2023 15:21:42 -0500 Subject: [PATCH 1/5] Add OptionSet feature/add-positional-and-option-set --- README.md | 9 +- Sources/ArgumentEncoding/ArgumentGroup.swift | 5 + Sources/ArgumentEncoding/Option.swift | 80 ++--- Sources/ArgumentEncoding/OptionSet.swift | 274 ++++++++++++++++++ .../OptionSetTests.swift | 65 +++++ 5 files changed, 372 insertions(+), 61 deletions(-) create mode 100644 Sources/ArgumentEncoding/OptionSet.swift create mode 100644 Tests/ArgumentEncodingTests/OptionSetTests.swift diff --git a/README.md b/README.md index acce05a..6ab84dc 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ struct MyCommand: TopLevelCommandRepresentable { } ``` -In addition to modeling the ability to enable/disable a feature, we need to set a value against some variable. For this, we can use `Option`. +In addition to modeling the ability to enable/disable a feature, we need to set a value against some variable. For this, we can use `Option`. For options that can have multiple values, there is `OptionSet`. ```swift struct MyCommand: TopLevelCommandRepresentable { @@ -47,6 +47,7 @@ struct MyCommand: TopLevelCommandRepresentable { @Flag var myFlag: Bool = false @Option var myOption: Int = 0 + @OptionSet var myOptions: [String] = ["value1", "value2"] } ``` @@ -141,8 +142,6 @@ struct TestCommand: CommandRepresentable { @Flag var parallel: Bool = true @Option var numWorkers: Int = 1 @Flag var showCodecovPath: Bool = false - var testProducts: [Command] + @OptionSet var testProducts: [String] = [] } - -extension [Command]: ArgumentGroup {} -``` \ No newline at end of file +``` diff --git a/Sources/ArgumentEncoding/ArgumentGroup.swift b/Sources/ArgumentEncoding/ArgumentGroup.swift index 59c7d60..1c101dc 100644 --- a/Sources/ArgumentEncoding/ArgumentGroup.swift +++ b/Sources/ArgumentEncoding/ArgumentGroup.swift @@ -100,6 +100,8 @@ extension ArgumentGroup { return container } else if let option = value as? OptionProtocol { return .option(option) + } else if let optionSet = value as? OptionSetProtocol { + return .optionSet(optionSet) } else if let flag = value as? Flag { return .flag(flag) } else if let command = value as? Command { @@ -122,6 +124,8 @@ extension ArgumentGroup { switch value { case let .option(option): return option.arguments(key: label) + case let .optionSet(optionSet): + return optionSet.arguments(key: label) case let .flag(flag): return flag.arguments(key: label) case let .command(command): @@ -250,6 +254,7 @@ extension ArgumentGroup { // Represents the possible underlying argument types private enum Container { case option(any OptionProtocol) + case optionSet(any OptionSetProtocol) case flag(Flag) case command(Command) case topLevelCommandRep(any TopLevelCommandRepresentable) diff --git a/Sources/ArgumentEncoding/Option.swift b/Sources/ArgumentEncoding/Option.swift index e104423..68881be 100644 --- a/Sources/ArgumentEncoding/Option.swift +++ b/Sources/ArgumentEncoding/Option.swift @@ -37,16 +37,19 @@ public struct Option: OptionProtocol { // Different Value types will encode to arguments differently. // Using unwrap, this can be handled individually per type or collectively by protocol - private let unwrap: @Sendable (Value) -> [String] - internal var unwrapped: [String] { + private let unwrap: @Sendable (Value) -> String? + internal var unwrapped: String? { unwrap(wrappedValue) } - func encoding(key: String? = nil) -> [OptionEncoding] { + func encoding(key: String? = nil) -> OptionEncoding? { guard let _key = keyOverride ?? key else { - return [] + return nil } - return unwrapped.map { OptionEncoding(key: _key, value: $0) } + guard let unwrapped else { + return nil + } + return OptionEncoding(key: _key, value: unwrapped) } /// Get the Option's argument encoding. If `keyOverRide` and `key` are both `nil`, it will return an empty array. @@ -55,7 +58,7 @@ public struct Option: OptionProtocol { /// - key: Optionally provide a key value. /// - Returns: The argument encoding which is an array of strings public func arguments(key: String? = nil) -> [String] { - encoding(key: key).flatMap { $0.arguments() } + encoding(key: key)?.arguments() ?? [] } /// Initializes a new option when not used as a `@propertyWrapper` @@ -64,7 +67,7 @@ public struct Option: OptionProtocol { /// - key: Explicit key value /// - wrappedValue: The underlying value /// - unwrap: A closure for mapping a Value to [String] - public init(key: some CustomStringConvertible, value: Value, unwrap: @escaping @Sendable (Value) -> [String]) { + public init(key: some CustomStringConvertible, value: Value, unwrap: @escaping @Sendable (Value) -> String?) { keyOverride = key.description wrappedValue = value self.unwrap = unwrap @@ -76,7 +79,7 @@ public struct Option: OptionProtocol { /// - wrappedValue: The underlying value /// - _ key: Optional explicit key value /// - _ unwrap: A closure for mapping a Value to [String] - public init(wrappedValue: Value, _ key: String? = nil, _ unwrap: @escaping @Sendable (Value) -> [String]) { + public init(wrappedValue: Value, _ key: String? = nil, _ unwrap: @escaping @Sendable (Value) -> String?) { keyOverride = key self.wrappedValue = wrappedValue self.unwrap = unwrap @@ -128,8 +131,8 @@ extension Option where Value: CustomStringConvertible { } @Sendable - public static func unwrap(_ value: Value) -> [String] { - [value.description] + public static func unwrap(_ value: Value) -> String? { + value.description } } @@ -157,8 +160,8 @@ extension Option where Value: RawRepresentable, Value.RawValue: CustomStringConv } @Sendable - public static func unwrap(_ value: Value) -> [String] { - [value.rawValue.description] + public static func unwrap(_ value: Value) -> String? { + value.rawValue.description } } @@ -188,8 +191,8 @@ extension Option where Value: CustomStringConvertible, Value: RawRepresentable, } @Sendable - public static func unwrap(_ value: Value) -> [String] { - [value.rawValue.description] + public static func unwrap(_ value: Value) -> String? { + value.rawValue.description } } @@ -223,47 +226,10 @@ extension Option { } @Sendable - public static func unwrap(_ value: Wrapped?) -> [String] where Wrapped: CustomStringConvertible, + public static func unwrap(_ value: Wrapped?) -> String? where Wrapped: CustomStringConvertible, Value == Wrapped? { - [value?.description].compactMap { $0 } - } -} - -// MARK: Convenience initializers when Value == Sequence - -extension Option { - /// Initializes a new option when not used as a `@propertyWrapper` - /// - /// - Parameters - /// - key: Explicit key value - /// - wrappedValue: The underlying value - public init(key: some CustomStringConvertible, values: Value) where Value: Sequence, Value.Element == E, - E: CustomStringConvertible - { - keyOverride = key.description - wrappedValue = values - unwrap = Self.unwrap(_:) - } - - /// Initializes a new option when used as a `@propertyWrapper` - /// - /// - Parameters - /// - wrappedValue: The underlying value - /// - _ key: Optional explicit key value - public init(wrappedValue: Value, _ key: String? = nil) where Value: Sequence, Value.Element == E, - E: CustomStringConvertible - { - keyOverride = key - self.wrappedValue = wrappedValue - unwrap = Self.unwrap(_:) - } - - @Sendable - public static func unwrap(_ value: Value) -> [String] where Value: Sequence, Value.Element == E, - E: CustomStringConvertible - { - value.map(\E.description) + value?.description } } @@ -271,14 +237,14 @@ extension Option { extension Option: ExpressibleByIntegerLiteral where Value: BinaryInteger, Value.IntegerLiteralType == Int { public init(integerLiteral value: IntegerLiteralType) { - self.init(wrappedValue: Value(integerLiteral: value), nil) { [$0.description] } + self.init(wrappedValue: Value(integerLiteral: value), nil) { $0.description } } } #if os(macOS) extension Option: ExpressibleByFloatLiteral where Value: BinaryFloatingPoint { public init(floatLiteral value: FloatLiteralType) { - self.init(wrappedValue: Value(value), nil) { [$0.formatted()] } + self.init(wrappedValue: Value(value), nil) { $0.formatted() } } } #endif @@ -307,8 +273,10 @@ extension Option: ExpressibleByStringInterpolation where Value: StringProtocol { } } +// MARK: Coding + extension Option: DecodableWithConfiguration where Value: Decodable { - public init(from decoder: Decoder, configuration: @escaping @Sendable (Value) -> [String]) throws { + public init(from decoder: Decoder, configuration: @escaping @Sendable (Value) -> String?) throws { let container = try decoder.singleValueContainer() try self.init(wrappedValue: container.decode(Value.self), nil, configuration) } diff --git a/Sources/ArgumentEncoding/OptionSet.swift b/Sources/ArgumentEncoding/OptionSet.swift new file mode 100644 index 0000000..a96a206 --- /dev/null +++ b/Sources/ArgumentEncoding/OptionSet.swift @@ -0,0 +1,274 @@ +// OptionSet.swift +// ArgumentEncoding +// +// Copyright © 2023 MFB Technologies, Inc. All rights reserved. + +import Dependencies +import Foundation + +/// A sequence of key/value pair arguments that provides a given value for a option set or variable. +/// +/// If an option set is not contained within an ``ArgumentGroup`` it needs an explicit `key` value. The explicit `key` +/// value +/// may be provided when initialized or when calling `arguments(key: String? = nil) -> [String]`. +/// +/// ```swift +/// let standaloneOptionSet = OptionSet("name", value: ["value1", "value2]) +/// +/// standAloneOptionSet.arguments() == ["--name", "value1", "--name", "value2"] +/// ``` +/// +/// Usually, an option set should be contained within a ``ArgumentGroup`` conforming type that will provide a `key` +/// value. +/// +/// ```swift +/// struct Container: ArgumentGroup, FormatterNode { +/// let flagFormatter: FlagFormatter = .doubleDashPrefix +/// let optionFormatter: OptionSetFormatter = .doubleDashPrefix +/// +/// @OptionSet var name: String = ["value1", "value2"] +/// } +/// +/// OptionSetContainer().arguments() == ["--name", "value1", "--name", "value2"] +/// ``` +@propertyWrapper +public struct OptionSet: OptionSetProtocol where Value: Sequence { + /// Explicitly specify the key value + public let keyOverride: String? + public var wrappedValue: Value + + // Different Value types will encode to arguments differently. + // Using unwrap, this can be handled individually per type or collectively by protocol + private let unwrap: @Sendable (Value.Element) -> String? + internal var unwrapped: [String] { + wrappedValue.compactMap(unwrap) + } + + func encoding(key: String? = nil) -> OptionSetEncoding { + guard let _key = keyOverride ?? key else { + return OptionSetEncoding(values: []) + } + return OptionSetEncoding(values: unwrapped.map { OptionEncoding(key: _key, value: $0) }) + } + + /// Get the OptionSet's argument encoding. If `keyOverRide` and `key` are both `nil`, it will return an empty array. + /// + /// - Parameters + /// - key: OptionSetally provide a key value. + /// - Returns: The argument encoding which is an array of strings + public func arguments(key: String? = nil) -> [String] { + encoding(key: key).arguments() + } + + /// Initializes a new option set when not used as a `@propertyWrapper` + /// + /// - Parameters + /// - key: Explicit key value + /// - wrappedValue: The underlying value + /// - unwrap: A closure for mapping a Value to [String] + public init( + key: some CustomStringConvertible, + value: Value, + unwrap: @escaping @Sendable (Value.Element) -> String? + ) { + keyOverride = key.description + wrappedValue = value + self.unwrap = unwrap + } + + /// Initializes a new option set when used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + /// - _ key: OptionSetal explicit key value + /// - _ unwrap: A closure for mapping a Value to [String] + public init(wrappedValue: Value, _ key: String? = nil, _ unwrap: @escaping @Sendable (Value.Element) -> String?) { + keyOverride = key + self.wrappedValue = wrappedValue + self.unwrap = unwrap + } +} + +// MARK: Conditional Conformances + +extension OptionSet: Equatable where Value: Equatable { + public static func == (lhs: OptionSet, rhs: OptionSet) -> Bool { + lhs.keyOverride == rhs.keyOverride + && lhs.unwrapped == rhs.unwrapped + } +} + +extension OptionSet: Hashable where Value: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(keyOverride) + hasher.combine(unwrapped) + hasher.combine(ObjectIdentifier(Self.self)) + } +} + +extension OptionSet: Sendable where Value: Sendable {} + +// MARK: Convenience initializers when Value: CustomStringConvertible + +extension OptionSet where Value.Element: CustomStringConvertible { + /// Initializes a new option set when not used as a `@propertyWrapper` + /// + /// - Parameters + /// - key: Explicit key value + /// - wrappedValue: The underlying value + public init(key: some CustomStringConvertible, value: Value) { + keyOverride = key.description + wrappedValue = value + unwrap = Self.unwrap(_:) + } + + /// Initializes a new option set when used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + /// - _ key: Optional explicit key value + public init(wrappedValue: Value, _ key: String? = nil) { + keyOverride = key + self.wrappedValue = wrappedValue + unwrap = Self.unwrap(_:) + } + + @Sendable + public static func unwrap(_ value: Value.Element) -> String? { + value.description + } +} + +extension OptionSet where Value.Element: RawRepresentable, Value.Element.RawValue: CustomStringConvertible { + /// Initializes a new option set when not used as a `@propertyWrapper` + /// + /// - Parameters + /// - key: Explicit key value + /// - wrappedValue: The underlying value + public init(key: some CustomStringConvertible, value: Value) { + keyOverride = key.description + wrappedValue = value + unwrap = Self.unwrap(_:) + } + + /// Initializes a new option set when used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + /// - _ key: Optional explicit key value + public init(wrappedValue: Value, _ key: String? = nil) { + keyOverride = key + self.wrappedValue = wrappedValue + unwrap = Self.unwrap(_:) + } + + @Sendable + public static func unwrap(_ value: Value.Element) -> String? { + value.rawValue.description + } +} + +extension OptionSet where Value.Element: CustomStringConvertible, Value.Element: RawRepresentable, + Value.Element.RawValue: CustomStringConvertible +{ + /// Initializes a new option set when not used as a `@propertyWrapper` + /// + /// - Parameters + /// - key: Explicit key value + /// - wrappedValue: The underlying value + public init(key: some CustomStringConvertible, value: Value) { + keyOverride = key.description + wrappedValue = value + unwrap = Self.unwrap(_:) + } + + /// Initializes a new option set when used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + /// - _ key: Optional explicit key value + public init(wrappedValue: Value, _ key: String? = nil) { + keyOverride = key + self.wrappedValue = wrappedValue + unwrap = Self.unwrap(_:) + } + + @Sendable + public static func unwrap(_ value: Value.Element) -> String? { + value.rawValue.description + } +} + +// MARK: Coding + +extension OptionSet: DecodableWithConfiguration where Value: Decodable { + public init(from decoder: Decoder, configuration: @escaping @Sendable (Value.Element) -> String?) throws { + let container = try decoder.singleValueContainer() + try self.init(wrappedValue: container.decode(Value.self), nil, configuration) + } +} + +extension OptionSet: Decodable where Value: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + guard let configurationCodingUserInfoKey = Self.configurationCodingUserInfoKey(for: Value.Type.self) else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "No CodingUserInfoKey found for accessing the DecodingConfiguration.", + underlyingError: nil + )) + } + guard let _configuration = decoder.userInfo[configurationCodingUserInfoKey] else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "No DecodingConfiguration found for key: \(configurationCodingUserInfoKey.rawValue)", + underlyingError: nil + )) + } + guard let configuration = _configuration as? Self.DecodingConfiguration else { + let desc = "Invalid DecodingConfiguration found for key: \(configurationCodingUserInfoKey.rawValue)" + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: desc, + underlyingError: nil + )) + } + try self.init(wrappedValue: container.decode(Value.self), nil, configuration) + } + + public static func configurationCodingUserInfoKey(for _: (some Any).Type) -> CodingUserInfoKey? { + CodingUserInfoKey(rawValue: ObjectIdentifier(Self.self).debugDescription) + } +} + +extension OptionSet: Encodable where Value: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(wrappedValue) + } +} + +// MARK: Internal Types + +/* + Dependencies library is used for injecting the formatters. OptionEncoding is + initialized within a `withDependencies` closure so that the formatter is + correctly injected. + */ +struct OptionSetEncoding { + @Dependency(\.optionFormatter) var formatter + + let values: [OptionEncoding] + + func arguments() -> [String] { + values.map { formatter.format(encoding: $0) }.flatMap { $0 } + } +} + +/* + Since OptionSet is generic, we need a single type to cast to in ArgumentGroup. + OptionSetProtocol is that type and OptionSet is the only type that conforms. + */ +protocol OptionSetProtocol { + func arguments(key: String?) -> [String] +} diff --git a/Tests/ArgumentEncodingTests/OptionSetTests.swift b/Tests/ArgumentEncodingTests/OptionSetTests.swift new file mode 100644 index 0000000..9784b45 --- /dev/null +++ b/Tests/ArgumentEncodingTests/OptionSetTests.swift @@ -0,0 +1,65 @@ +// OptionSetTests.swift +// ArgumentEncoding +// +// Copyright © 2023 MFB Technologies, Inc. All rights reserved. + +import ArgumentEncoding +import Dependencies +import XCTest + +final class OptionSetTests: XCTestCase { + func testOptionSet() throws { + let optionSet = OptionSet(key: "configuration", value: ["release", "debug"]) + let args = withDependencies { values in + values.optionFormatter = .doubleDashPrefix + } operation: { + optionSet.arguments() + } + XCTAssertEqual(args, ["--configuration", "release", "--configuration", "debug"]) + } + + func testBothRawValueAndStringConvertible() throws { + let optionSet = OptionSet( + key: "configuration", + value: [ + RawValueCustomStringConvertible(rawValue: "release"), + RawValueCustomStringConvertible(rawValue: "debug"), + ] + ) + let args = withDependencies { values in + values.optionFormatter = .doubleDashPrefix + } operation: { + optionSet.arguments() + } + XCTAssertEqual(args, ["--configuration", "release", "--configuration", "debug"]) + } + + func testBothRawValueAndStringConvertibleContainer() throws { + let container = Container(configuration: [ + RawValueCustomStringConvertible(rawValue: "release"), + RawValueCustomStringConvertible(rawValue: "debug"), + ]) + let args = withDependencies { values in + values.optionFormatter = .doubleDashPrefix + } operation: { + container.arguments() + } + XCTAssertEqual(args, ["--configuration", "release", "--configuration", "debug"]) + } +} + +private struct RawValueCustomStringConvertible: RawRepresentable, CustomStringConvertible { + var rawValue: String + + var description: String { + "description=" + rawValue + } +} + +private struct Container: ArgumentGroup { + @OptionSet var configuration: [RawValueCustomStringConvertible] + + init(configuration: [RawValueCustomStringConvertible]) { + _configuration = OptionSet(wrappedValue: configuration) + } +} From 07499541081136a6af3dc15e46e1516aef0a5706 Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Fri, 19 May 2023 15:29:15 -0500 Subject: [PATCH 2/5] Add Positional feature/add-positional-and-option-set --- README.md | 19 +- Sources/ArgumentEncoding/ArgumentGroup.swift | 5 + .../ArgumentEncoding/PositionalArgument.swift | 323 ++++++++++++++++++ .../ArgumentGroupTests.swift | 34 +- .../PositionalTests.swift | 44 +++ 5 files changed, 412 insertions(+), 13 deletions(-) create mode 100644 Sources/ArgumentEncoding/PositionalArgument.swift create mode 100644 Tests/ArgumentEncodingTests/PositionalTests.swift diff --git a/README.md b/README.md index 6ab84dc..0ba0f08 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,21 @@ struct MyCommand: TopLevelCommandRepresentable { } ``` +Positional arguments that are just a value, with no key are supported through the `Positional` type. + +```swift +struct MyCommand: TopLevelCommandRepresentable { + func commandValue() -> Command { "my-command" } + var flagFormatter: FlagFormatter { .doubleDashPrefix } + var optionFormatter: OptionFormatter { .doubleDashPrefix } + + @Flag var myFlag: Bool = false + @Option var myOption: Int = 0 + @OptionSet var myOptions: [String] = ["value1", "value2"] + @Positional var myPositional: String = "positional" +} +``` + ## Motivation When running executables with Swift, it may be helpful to encode structured types (struct, class, enum) into argument arrays that are passed to executables. @@ -126,12 +141,12 @@ struct RunCommand: CommandRepresentable { let flagFormatter: FlagFormatter = .doubleDashPrefixKebabCase let optionFormatter: OptionFormatter = .doubleDashPrefixKebabCase - let executable: Command + @Positional var executable: String } extension RunCommand: ExpressibleByStringLiteral { init(stringLiteral value: StringLiteralType) { - self.init(executable: Command(rawValue: value)) + self.init(executable: Positional(wrapped: value)) } } diff --git a/Sources/ArgumentEncoding/ArgumentGroup.swift b/Sources/ArgumentEncoding/ArgumentGroup.swift index 1c101dc..b26453d 100644 --- a/Sources/ArgumentEncoding/ArgumentGroup.swift +++ b/Sources/ArgumentEncoding/ArgumentGroup.swift @@ -112,6 +112,8 @@ extension ArgumentGroup { return .commandRep(commandRep) } else if let group = value as? (any ArgumentGroup) { return .group(group) + } else if let positional = value as? PositionalProtocol { + return .positional(positional) } else { return nil } @@ -140,6 +142,8 @@ extension ArgumentGroup { } case let .group(group): return group.arguments() + case let .positional(positional): + return positional.arguments() } }) } @@ -260,4 +264,5 @@ private enum Container { case topLevelCommandRep(any TopLevelCommandRepresentable) case commandRep(any CommandRepresentable) case group(any ArgumentGroup) + case positional(any PositionalProtocol) } diff --git a/Sources/ArgumentEncoding/PositionalArgument.swift b/Sources/ArgumentEncoding/PositionalArgument.swift new file mode 100644 index 0000000..68ca5ea --- /dev/null +++ b/Sources/ArgumentEncoding/PositionalArgument.swift @@ -0,0 +1,323 @@ +// PositionalArgument.swift +// ArgumentEncoding +// +// Copyright © 2023 MFB Technologies, Inc. All rights reserved. + +import Foundation + +/// A value only argument type that is not a command or sub-command. +/// +/// Because positional argumnents do not have a key, they encode to only their value.. +/// +/// ```swift +/// struct Container: ArgumentGroup, FormatterNode { +/// let flagFormatter: FlagFormatter = .doubleDashPrefix +/// let optionFormatter: OptionFormatter = .doubleDashPrefix +/// +/// @Positional var name: String = "value" +/// } +/// +/// Container().arguments() == ["value"] +/// ``` +@propertyWrapper +public struct Positional: PositionalProtocol { + public var wrappedValue: Value + + // Different Value types will encode to arguments differently. + // Using unwrap, this can be handled individually per type or collectively by protocol + private let unwrap: @Sendable (Value) -> [String] + internal var unwrapped: [String] { + unwrap(wrappedValue) + } + + func encoding() -> [Command] { + unwrapped.map(Command.init(rawValue:)) + } + + /// Get the Positional's argument encoding. + /// - Returns: The argument encoding which is an array of strings + public func arguments() -> [String] { + encoding().flatMap { $0.arguments() } + } + + /// Initializes a new positional when not used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + /// - unwrap: A closure for mapping a Value to [String] + public init(value: Value, unwrap: @escaping @Sendable (Value) -> [String]) { + wrappedValue = value + self.unwrap = unwrap + } + + /// Initializes a new positional when used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + /// - _ unwrap: A closure for mapping a Value to [String] + public init(wrappedValue: Value, _ unwrap: @escaping @Sendable (Value) -> [String]) { + self.wrappedValue = wrappedValue + self.unwrap = unwrap + } +} + +// MARK: Conditional Conformances + +extension Positional: Equatable where Value: Equatable { + public static func == (lhs: Positional, rhs: Positional) -> Bool { + lhs.unwrapped == rhs.unwrapped + } +} + +extension Positional: Hashable where Value: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(unwrapped) + hasher.combine(ObjectIdentifier(Self.self)) + } +} + +extension Positional: Sendable where Value: Sendable {} + +// MARK: Convenience initializers when Value: CustomStringConvertible + +extension Positional where Value: CustomStringConvertible { + /// Initializes a new positional when not used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + public init(value: Value) { + wrappedValue = value + unwrap = Self.unwrap(_:) + } + + /// Initializes a new positional when used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + public init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + unwrap = Self.unwrap(_:) + } + + @Sendable + public static func unwrap(_ value: Value) -> [String] { + [value.description] + } +} + +extension Positional where Value: RawRepresentable, Value.RawValue: CustomStringConvertible { + /// Initializes a new positional when not used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + public init(value: Value) { + wrappedValue = value + unwrap = Self.unwrap(_:) + } + + /// Initializes a new positional when used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + public init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + unwrap = Self.unwrap(_:) + } + + @Sendable + public static func unwrap(_ value: Value) -> [String] { + [value.rawValue.description] + } +} + +extension Positional where Value: CustomStringConvertible, Value: RawRepresentable, + Value.RawValue: CustomStringConvertible +{ + /// Initializes a new positional when not used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + public init(value: Value) { + wrappedValue = value + unwrap = Self.unwrap(_:) + } + + /// Initializes a new positional when used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + public init(wrappedValue: Value) { + self.wrappedValue = wrappedValue + unwrap = Self.unwrap(_:) + } + + @Sendable + public static func unwrap(_ value: Value) -> [String] { + [value.rawValue.description] + } +} + +// MARK: Convenience initializers when Value == Positionalal + +extension Positional { + /// Initializes a new positional when not used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + public init(value: Wrapped?) where Wrapped: CustomStringConvertible, + Value == Wrapped? + { + wrappedValue = value + unwrap = Self.unwrap(_:) + } + + /// Initializes a new positional when used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + public init(wrappedValue: Wrapped?) where Wrapped: CustomStringConvertible, + Value == Wrapped? + { + self.wrappedValue = wrappedValue + unwrap = Self.unwrap(_:) + } + + @Sendable + public static func unwrap(_ value: Wrapped?) -> [String] where Wrapped: CustomStringConvertible, + Value == Wrapped? + { + [value?.description].compactMap { $0 } + } +} + +// MARK: Convenience initializers when Value == Sequence + +extension Positional { + /// Initializes a new positional when not used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + public init(values: Value) where Value: Sequence, Value.Element == E, + E: CustomStringConvertible + { + wrappedValue = values + unwrap = Self.unwrap(_:) + } + + /// Initializes a new positional when used as a `@propertyWrapper` + /// + /// - Parameters + /// - wrappedValue: The underlying value + public init(wrappedValue: Value) where Value: Sequence, Value.Element == E, + E: CustomStringConvertible + { + self.wrappedValue = wrappedValue + unwrap = Self.unwrap(_:) + } + + @Sendable + public static func unwrap(_ value: Value) -> [String] where Value: Sequence, Value.Element == E, + E: CustomStringConvertible + { + value.map(\E.description) + } +} + +// MARK: ExpressibleBy...Literal conformances + +extension Positional: ExpressibleByIntegerLiteral where Value: BinaryInteger, Value.IntegerLiteralType == Int { + public init(integerLiteral value: IntegerLiteralType) { + self.init(wrappedValue: Value(integerLiteral: value)) { [$0.description] } + } +} + +#if os(macOS) + extension Positional: ExpressibleByFloatLiteral where Value: BinaryFloatingPoint { + public init(floatLiteral value: FloatLiteralType) { + self.init(wrappedValue: Value(value)) { [$0.formatted()] } + } + } +#endif + +extension Positional: ExpressibleByExtendedGraphemeClusterLiteral where Value: StringProtocol { + public init(extendedGraphemeClusterLiteral value: String) { + self.init(wrappedValue: Value(stringLiteral: value)) + } +} + +extension Positional: ExpressibleByUnicodeScalarLiteral where Value: StringProtocol { + public init(unicodeScalarLiteral value: String) { + self.init(wrappedValue: Value(stringLiteral: value)) + } +} + +extension Positional: ExpressibleByStringLiteral where Value: StringProtocol { + public init(stringLiteral value: StringLiteralType) { + self.init(wrappedValue: Value(stringLiteral: value)) + } +} + +extension Positional: ExpressibleByStringInterpolation where Value: StringProtocol { + public init(stringInterpolation: DefaultStringInterpolation) { + self.init(wrappedValue: Value(stringInterpolation: stringInterpolation)) + } +} + +extension Positional: DecodableWithConfiguration where Value: Decodable { + public init(from decoder: Decoder, configuration: @escaping @Sendable (Value) -> [String]) throws { + let container = try decoder.singleValueContainer() + try self.init(wrappedValue: container.decode(Value.self), configuration) + } +} + +// MARK: Coding + +extension Positional: Decodable where Value: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + guard let configurationCodingUserInfoKey = Self.configurationCodingUserInfoKey(for: Value.Type.self) else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "No CodingUserInfoKey found for accessing the DecodingConfiguration.", + underlyingError: nil + )) + } + guard let _configuration = decoder.userInfo[configurationCodingUserInfoKey] else { + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "No DecodingConfiguration found for key: \(configurationCodingUserInfoKey.rawValue)", + underlyingError: nil + )) + } + guard let configuration = _configuration as? Self.DecodingConfiguration else { + let desc = "Invalid DecodingConfiguration found for key: \(configurationCodingUserInfoKey.rawValue)" + throw DecodingError.dataCorrupted(DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: desc, + underlyingError: nil + )) + } + try self.init(wrappedValue: container.decode(Value.self), configuration) + } + + public static func configurationCodingUserInfoKey(for _: (some Any).Type) -> CodingUserInfoKey? { + CodingUserInfoKey(rawValue: ObjectIdentifier(Self.self).debugDescription) + } +} + +extension Positional: Encodable where Value: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(wrappedValue) + } +} + +// MARK: Internal Types + +/* + Since Positional is generic, we need a single type to cast to in ArgumentGroup. + PositionalProtocol is that type and Positional is the only type that conforms. + */ +protocol PositionalProtocol { + func arguments() -> [String] +} diff --git a/Tests/ArgumentEncodingTests/ArgumentGroupTests.swift b/Tests/ArgumentEncodingTests/ArgumentGroupTests.swift index 313a5cb..f9c4828 100644 --- a/Tests/ArgumentEncodingTests/ArgumentGroupTests.swift +++ b/Tests/ArgumentEncodingTests/ArgumentGroupTests.swift @@ -23,10 +23,12 @@ final class ArgumentGroupTests: XCTestCase { @Flag var asyncMain: Bool @Option var numThreads: Int = 0 + @Positional var target: String - init(asyncMain: Bool, numThreads: Int) { + init(asyncMain: Bool, numThreads: Int, target: String) { self.asyncMain = asyncMain self.numThreads = numThreads + _target = Positional(value: target) } } @@ -34,17 +36,19 @@ final class ArgumentGroupTests: XCTestCase { XCTAssertEqual( Group( asyncMain: false, - numThreads: 2 + numThreads: 2, + target: "target" ).arguments(), - ["--numThreads", "2"] + ["--numThreads", "2", "target"] ) XCTAssertEqual( Group( asyncMain: true, - numThreads: 0 + numThreads: 0, + target: "target" ).arguments(), - ["--asyncMain", "--numThreads", "0"] + ["--asyncMain", "--numThreads", "0", "target"] ) } @@ -54,11 +58,13 @@ final class ArgumentGroupTests: XCTestCase { @Flag var asyncMain: Bool @Option var numThreads: Int = 0 + @Positional var target: String var child: ChildGroup - init(asyncMain: Bool, numThreads: Int, child: ChildGroup) { + init(asyncMain: Bool, numThreads: Int, target: String, child: ChildGroup) { self.asyncMain = asyncMain self.numThreads = numThreads + _target = Positional(value: target) self.child = child } } @@ -69,10 +75,12 @@ final class ArgumentGroupTests: XCTestCase { @Option var configuration: Configuration = .arm64 @Flag var buildTests: Bool + @Positional var target: String - init(configuration: Configuration, buildTests: Bool) { + init(configuration: Configuration, buildTests: Bool, target: String) { self.configuration = configuration self.buildTests = buildTests + _target = Positional(value: target) } enum Configuration: String, CustomStringConvertible { @@ -88,24 +96,28 @@ final class ArgumentGroupTests: XCTestCase { ParentGroup( asyncMain: false, numThreads: 2, + target: "target", child: ChildGroup( configuration: .arm64, - buildTests: false + buildTests: false, + target: "target" ) ).arguments(), - ["--numThreads", "2", "-configuration", "arm64"] + ["--numThreads", "2", "target", "-configuration", "arm64", "target"] ) XCTAssertEqual( ParentGroup( asyncMain: true, numThreads: 1, + target: "target", child: ChildGroup( configuration: .x86_64, - buildTests: true + buildTests: true, + target: "target" ) ).arguments(), - ["--asyncMain", "--numThreads", "1", "-configuration", "x86_64", "-buildTests"] + ["--asyncMain", "--numThreads", "1", "target", "-configuration", "x86_64", "-buildTests", "target"] ) } diff --git a/Tests/ArgumentEncodingTests/PositionalTests.swift b/Tests/ArgumentEncodingTests/PositionalTests.swift new file mode 100644 index 0000000..b2191b8 --- /dev/null +++ b/Tests/ArgumentEncodingTests/PositionalTests.swift @@ -0,0 +1,44 @@ +// PositionalTests.swift +// ArgumentEncoding +// +// Copyright © 2023 MFB Technologies, Inc. All rights reserved. + +import ArgumentEncoding +import Dependencies +import XCTest + +final class PositionalTests: XCTestCase { + func testPositional() throws { + let positional = Positional(value: "positional-argument") + let args = positional.arguments() + XCTAssertEqual(args, ["positional-argument"]) + } + + func testBothRawValueAndStringConvertible() throws { + let positional = Positional(value: RawValueCustomStringConvertible(rawValue: "positional-argument")) + let args = positional.arguments() + XCTAssertEqual(args, ["positional-argument"]) + } + + func testBothRawValueAndStringConvertibleContainer() throws { + let container = Container(configuration: RawValueCustomStringConvertible(rawValue: "positional-argument")) + let args = container.arguments() + XCTAssertEqual(args, ["positional-argument"]) + } +} + +private struct RawValueCustomStringConvertible: RawRepresentable, CustomStringConvertible { + var rawValue: String + + var description: String { + "description=" + rawValue + } +} + +private struct Container: ArgumentGroup { + @Positional var configuration: RawValueCustomStringConvertible + + init(configuration: RawValueCustomStringConvertible) { + _configuration = Positional(wrappedValue: configuration) + } +} From cab7be220560a04fa57a4bc5e1215c27f5f2f316 Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Fri, 19 May 2023 15:29:35 -0500 Subject: [PATCH 3/5] Fix typo in doc comment feature/add-positional-and-option-set --- Sources/ArgumentEncoding/Option.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ArgumentEncoding/Option.swift b/Sources/ArgumentEncoding/Option.swift index 68881be..fdc38c5 100644 --- a/Sources/ArgumentEncoding/Option.swift +++ b/Sources/ArgumentEncoding/Option.swift @@ -27,7 +27,7 @@ import Foundation /// @Option var name: String = "value" /// } /// -/// OptionContainer().arguments() == ["--name", "value"] +/// Container().arguments() == ["--name", "value"] /// ``` @propertyWrapper public struct Option: OptionProtocol { From 9f7581f5dc0ceffbf6c87bb6e8fd37f4beb72f84 Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Fri, 19 May 2023 15:30:08 -0500 Subject: [PATCH 4/5] Update dependencies in Package.resolved feature/add-positional-and-option-set --- Package.resolved | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Package.resolved b/Package.resolved index a7048b0..594f9bd 100644 --- a/Package.resolved +++ b/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies.git", "state" : { - "revision" : "ad0a6a0dd4d4741263e798f4f5029589c9b5da73", - "version" : "0.4.2" + "revision" : "25c9b6789b4b7ada649a3808e6d8de1489082a33", + "version" : "0.5.0" } }, { From 4a2751390ee0c608ed381d2eeecf0cfe615ee68d Mon Sep 17 00:00:00 2001 From: Andrew Roan Date: Fri, 19 May 2023 21:20:34 -0500 Subject: [PATCH 5/5] Refactor formatter APIs feature/refactor-formatters --- README.md | 26 +-- Sources/ArgumentEncoding/CaseConverter.swift | 7 +- .../CommandRepresentable.swift | 2 +- Sources/ArgumentEncoding/Flag.swift | 2 +- Sources/ArgumentEncoding/FlagFormatter.swift | 68 ------- Sources/ArgumentEncoding/Formatters.swift | 171 ++++++++++++++++++ Sources/ArgumentEncoding/Option.swift | 4 +- .../ArgumentEncoding/OptionFormatter.swift | 84 --------- Sources/ArgumentEncoding/OptionSet.swift | 2 +- .../ArgumentEncoding/PositionalArgument.swift | 2 +- .../TopLevelCommandRepresentable.swift | 2 +- .../ArgumentEncodingTests.swift | 13 -- .../ArgumentGroupTests.swift | 42 ++--- .../CommandRepresentableTests.swift | 35 ++-- Tests/ArgumentEncodingTests/FlagTests.swift | 4 +- .../FormatterTests.swift | 87 +++++++++ .../OptionSetTests.swift | 12 +- Tests/ArgumentEncodingTests/OptionTests.swift | 12 +- .../TopLevelCommandRepresentableTests.swift | 35 ++-- 19 files changed, 348 insertions(+), 262 deletions(-) delete mode 100644 Sources/ArgumentEncoding/FlagFormatter.swift create mode 100644 Sources/ArgumentEncoding/Formatters.swift delete mode 100644 Sources/ArgumentEncoding/OptionFormatter.swift delete mode 100644 Tests/ArgumentEncodingTests/ArgumentEncodingTests.swift create mode 100644 Tests/ArgumentEncodingTests/FormatterTests.swift diff --git a/README.md b/README.md index 0ba0f08..f688683 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ Typically, modeling a CLI tool will begin with a `TopLevelCommandRepresentable`. ```swift struct MyCommand: TopLevelCommandRepresentable { func commandValue() -> Command { "my-command" } - var flagFormatter: FlagFormatter { .doubleDashPrefix } - var optionFormatter: OptionFormatter { .doubleDashPrefix } + let flagFormatter = FlagFormatter(prefix: .doubleDash) } + let optionFormatter = OptionFormatter(prefix: .doubleDash) } } ``` @@ -30,8 +30,8 @@ Within `MyCommand` we need the ability to model a boolean value to enable/disabl ```swift struct MyCommand: TopLevelCommandRepresentable { func commandValue() -> Command { "my-command" } - var flagFormatter: FlagFormatter { .doubleDashPrefix } - var optionFormatter: OptionFormatter { .doubleDashPrefix } + let flagFormatter = FlagFormatter(prefix: .doubleDash) } + let optionFormatter = OptionFormatter(prefix: .doubleDash) } @Flag var myFlag: Bool = false } @@ -42,8 +42,8 @@ In addition to modeling the ability to enable/disable a feature, we need to set ```swift struct MyCommand: TopLevelCommandRepresentable { func commandValue() -> Command { "my-command" } - var flagFormatter: FlagFormatter { .doubleDashPrefix } - var optionFormatter: OptionFormatter { .doubleDashPrefix } + let flagFormatter = FlagFormatter(prefix: .doubleDash) } + let optionFormatter = OptionFormatter(prefix: .doubleDash) } @Flag var myFlag: Bool = false @Option var myOption: Int = 0 @@ -56,8 +56,8 @@ Positional arguments that are just a value, with no key are supported through th ```swift struct MyCommand: TopLevelCommandRepresentable { func commandValue() -> Command { "my-command" } - var flagFormatter: FlagFormatter { .doubleDashPrefix } - var optionFormatter: OptionFormatter { .doubleDashPrefix } + let flagFormatter = FlagFormatter(prefix: .doubleDash) } + let optionFormatter = OptionFormatter(prefix: .doubleDash) } @Flag var myFlag: Bool = false @Option var myOption: Int = 0 @@ -130,17 +130,14 @@ import ArgumentEncoding enum SwiftCommand: TopLevelCommandRepresentable { func commandValue() -> Command { "swift" } - var flagFormatter: FlagFormatter { .doubleDashPrefixKebabCase } - var optionFormatter: OptionFormatter { .doubleDashPrefixKebabCase } + var flagFormatter: FlagFormatter { FlagFormatter(prefix: .doubleDash, body: .kebabCase) } + var optionFormatter: OptionFormatter { OptionFormatter(prefix: .doubleDash, body: .kebabCase) } case run(RunCommand) case test(TestCommand) } struct RunCommand: CommandRepresentable { - let flagFormatter: FlagFormatter = .doubleDashPrefixKebabCase - let optionFormatter: OptionFormatter = .doubleDashPrefixKebabCase - @Positional var executable: String } @@ -151,9 +148,6 @@ extension RunCommand: ExpressibleByStringLiteral { } struct TestCommand: CommandRepresentable { - let flagFormatter: FlagFormatter = .doubleDashPrefixKebabCase - let optionFormatter: OptionFormatter = .doubleDashPrefixKebabCase - @Flag var parallel: Bool = true @Option var numWorkers: Int = 1 @Flag var showCodecovPath: Bool = false diff --git a/Sources/ArgumentEncoding/CaseConverter.swift b/Sources/ArgumentEncoding/CaseConverter.swift index 0cb9214..48085da 100644 --- a/Sources/ArgumentEncoding/CaseConverter.swift +++ b/Sources/ArgumentEncoding/CaseConverter.swift @@ -7,11 +7,12 @@ import Foundation /// Convert from Swift's typical camelCase to kebab-case and snake_case as some argument formats require them. public enum CaseConverter { - public static let kebabCase: (String) -> String = fromCamelCase(template: "$1-$2") + public static let kebabCase: @Sendable (String) -> String = fromCamelCase(template: "$1-$2") - public static let snakeCase: (String) -> String = fromCamelCase(template: "$1_$2") + public static let snakeCase: @Sendable (String) -> String = fromCamelCase(template: "$1_$2") - private static func fromCamelCase(template: String) -> (String) -> String { + @Sendable + private static func fromCamelCase(template: String) -> @Sendable (String) -> String { guard let regex = try? NSRegularExpression(pattern: "([a-z0-9])([A-Z])", options: []) else { return { $0 } } diff --git a/Sources/ArgumentEncoding/CommandRepresentable.swift b/Sources/ArgumentEncoding/CommandRepresentable.swift index cd8c09f..9f1f933 100644 --- a/Sources/ArgumentEncoding/CommandRepresentable.swift +++ b/Sources/ArgumentEncoding/CommandRepresentable.swift @@ -13,7 +13,7 @@ import Dependencies /// struct ParentGroup: CommandRepresentable { /// // Formatters to satisfy `FormatterNode` requirements /// let flagFormatter: FlagFormatter = .doubleDashPrefix -/// let optionFormatter: OptionFormatter = .doubleDashPrefix +/// let optionFormatter: OptionFormatter = OptionFormatter(prefix: .doubleDash) /// /// // Properties that represent the child arguments /// @Flag var asyncMain: Bool diff --git a/Sources/ArgumentEncoding/Flag.swift b/Sources/ArgumentEncoding/Flag.swift index 7388bae..dfe6657 100644 --- a/Sources/ArgumentEncoding/Flag.swift +++ b/Sources/ArgumentEncoding/Flag.swift @@ -22,7 +22,7 @@ import Dependencies /// ```swift /// struct FlagContainer: ArgumentGroup, FormatterNode { /// let flagFormatter: FlagFormatter = .doubleDashPrefix -/// let optionFormatter: OptionFormatter = .doubleDashPrefix +/// let optionFormatter: OptionFormatter = OptionFormatter(prefix: .doubleDash) /// /// @Flag var name: Bool = true /// } diff --git a/Sources/ArgumentEncoding/FlagFormatter.swift b/Sources/ArgumentEncoding/FlagFormatter.swift deleted file mode 100644 index 40143c0..0000000 --- a/Sources/ArgumentEncoding/FlagFormatter.swift +++ /dev/null @@ -1,68 +0,0 @@ -// FlagFormatter.swift -// ArgumentEncoding -// -// Copyright © 2023 MFB Technologies, Inc. All rights reserved. - -import Dependencies -import XCTestDynamicOverlay - -/// Formats `Flag`s to match how different executables format arguments -public struct FlagFormatter { - /// Formats a key string - public let format: (_ key: String) -> String - - internal func _format(encoding: FlagEncoding) -> String { - format(encoding.key) - } - - /// Initialize a new formatter - /// - /// - Parameters - /// - _ format: An escaping closure that takes the Flag's key value as input and returns a formatted String - public init(_ format: @escaping (_ key: String) -> String) { - self.format = format - } -} - -extension FlagFormatter { - /// A formatter that prefixes flags with '-' - public static let singleDashPrefix = FlagFormatter { StaticString.singleDash.description + $0 } - - /// A formatter that prefixes flags with '-' and converts from camelCase to kebab-case - public static let singleDashPrefixKebabCase = FlagFormatter { input in - StaticString.singleDash.description + CaseConverter.kebabCase(input) - } - - /// A formatter that prefixes flags with '-' and converts from camelCase to snake_case - public static let singleDashPrefixSnakeCase = FlagFormatter { input in - StaticString.singleDash.description + CaseConverter.snakeCase(input) - } - - /// A formatter that prefixes flags with '--' - public static let doubleDashPrefix = FlagFormatter { StaticString.doubleDash.description + $0 } - - /// A formatter that prefixes flags with '--' and converts from camelCase to kebab-case - public static let doubleDashPrefixKebabCase = FlagFormatter { input in - StaticString.doubleDash.description + CaseConverter.kebabCase(input) - } - - /// A formatter that prefixes flags with '--' and converts from camelCase to snake_case - public static let doubleDashPrefixSnakeCase = FlagFormatter { input in - StaticString.doubleDash.description + CaseConverter.snakeCase(input) - } -} - -extension FlagFormatter: TestDependencyKey { - public static let testValue: FlagFormatter = .unimplemented -} - -extension DependencyValues { - public var flagFormatter: FlagFormatter { - get { self[FlagFormatter.self] } - set { self[FlagFormatter.self] = newValue } - } -} - -extension FlagFormatter { - public static let unimplemented = FlagFormatter(XCTestDynamicOverlay.unimplemented(placeholder: "unimplemented")) -} diff --git a/Sources/ArgumentEncoding/Formatters.swift b/Sources/ArgumentEncoding/Formatters.swift new file mode 100644 index 0000000..c5d0968 --- /dev/null +++ b/Sources/ArgumentEncoding/Formatters.swift @@ -0,0 +1,171 @@ +// Formatters.swift +// ArgumentEncoding +// +// Copyright © 2023 MFB Technologies, Inc. All rights reserved. + +import Dependencies +import XCTestDynamicOverlay + +/// Formats `Flag`s to match how different executables format arguments +public struct FlagFormatter: Sendable { + /// Formats a key string + public let prefix: @Sendable () -> String + public let body: @Sendable (_ key: String) -> String + + @Sendable + public func format(key: String) -> String { + prefix() + body(key) + } + + @Sendable + internal func _format(encoding: FlagEncoding) -> String { + format(key: encoding.key) + } + + /// Initialize a new formatter + /// + /// - Parameters + /// - prefix: Closure that returns the prefix string + /// - body: Closure that transforms the key string for formatting + public init( + prefix: @escaping @Sendable () -> String, + body: @escaping @Sendable (_ key: String) -> String + ) { + self.prefix = prefix + self.body = body + } + + /// Initialize a new formatter + /// + /// - Parameters + /// - prefix: Name spaced closure that returns the prefix string for a Flag + /// - body: Name spaced closure that transforms the key string for formatting + public init(prefix: PrefixFormatter = .empty, body: BodyFormatter = .empty) { + self.init( + prefix: prefix.transform, + body: body.transform + ) + } +} + +/// Formats `Option`s to match how different executables format arguments +public struct OptionFormatter: Sendable { + public let prefix: @Sendable () -> String + public let body: @Sendable (_ key: String) -> String + public let separator: @Sendable () -> String + + public func format(key: String, value: String) -> String { + prefix() + body(key) + separator() + value + } + + internal func format(encoding: OptionEncoding) -> String { + format(key: encoding.key, value: encoding.value) + } + + /// Initialize a new formatter + /// + /// - Parameters + /// - prefix: Closure that returns the prefix string + /// - body: Closure that transforms the key string for formatting + /// - separator: Closure that returns the string that separates the key and value + public init( + prefix: @escaping @Sendable () -> String, + body: @escaping @Sendable (_ key: String) -> String, + separator: @escaping @Sendable () -> String + ) { + self.prefix = prefix + self.body = body + self.separator = separator + } + + /// Initialize a new formatter + /// + /// - Parameters + /// - prefix: Name spaced closure that returns the prefix string for a Flag + /// - body: Name spaced closure that transforms the key string for formatting + /// - separator: Name spaced closure that returns the string that separates the key and value + public init( + prefix: PrefixFormatter = .empty, + body: BodyFormatter = .empty, + separator: SeparatorFormatter = .space + ) { + self.init( + prefix: prefix.transform, + body: body.transform, + separator: separator.transform + ) + } +} + +// MARK: Supporting formatters + +/// Name space for a closure that returns a string that prefixes a Flag or Option's key +public struct PrefixFormatter: Sendable { + public let transform: @Sendable () -> String + + public init(_ transform: @escaping @Sendable () -> String) { + self.transform = transform + } + + public static let empty = Self { "" } + public static let singleDash = Self { StaticString.singleDash.description } + public static let doubleDash = Self { StaticString.doubleDash.description } +} + +/// Name space for a closure that transforms a Flag or Option's key +public struct BodyFormatter: Sendable { + public let transform: @Sendable (_ key: String) -> String + + public init(_ transform: @escaping @Sendable (_ key: String) -> String) { + self.transform = transform + } + + public static let empty = Self { $0 } + public static let kebabCase = Self(CaseConverter.kebabCase) + public static let snakeCase = Self(CaseConverter.snakeCase) +} + +/// Name space for a closure that returns the separator string between an Option's key and value +public struct SeparatorFormatter: Sendable { + public let transform: @Sendable () -> String + + public init(_ transform: @escaping @Sendable () -> String) { + self.transform = transform + } + + public static let space = Self { StaticString.space.description } + public static let equal = Self { StaticString.equal.description } +} + +// MARK: Dependency + +extension FlagFormatter: TestDependencyKey { + public static let testValue: FlagFormatter = .unimplemented +} + +extension DependencyValues { + public var flagFormatter: FlagFormatter { + get { self[FlagFormatter.self] } + set { self[FlagFormatter.self] = newValue } + } +} + +extension FlagFormatter { + public static let unimplemented: FlagFormatter = XCTestDynamicOverlay.unimplemented(placeholder: FlagFormatter()) +} + +extension OptionFormatter: TestDependencyKey { + public static let testValue: OptionFormatter = .unimplemented +} + +extension DependencyValues { + public var optionFormatter: OptionFormatter { + get { self[OptionFormatter.self] } + set { self[OptionFormatter.self] = newValue } + } +} + +extension OptionFormatter { + public static let unimplemented: OptionFormatter = XCTestDynamicOverlay + .unimplemented(placeholder: OptionFormatter()) +} diff --git a/Sources/ArgumentEncoding/Option.swift b/Sources/ArgumentEncoding/Option.swift index fdc38c5..4ec6cbb 100644 --- a/Sources/ArgumentEncoding/Option.swift +++ b/Sources/ArgumentEncoding/Option.swift @@ -22,7 +22,7 @@ import Foundation /// ```swift /// struct Container: ArgumentGroup, FormatterNode { /// let flagFormatter: FlagFormatter = .doubleDashPrefix -/// let optionFormatter: OptionFormatter = .doubleDashPrefix +/// let optionFormatter: OptionFormatter = OptionFormatter(prefix: .doubleDash) /// /// @Option var name: String = "value" /// } @@ -336,7 +336,7 @@ struct OptionEncoding { let value: String func arguments() -> [String] { - formatter.format(encoding: self) + [formatter.format(encoding: self)] } } diff --git a/Sources/ArgumentEncoding/OptionFormatter.swift b/Sources/ArgumentEncoding/OptionFormatter.swift deleted file mode 100644 index aab2a16..0000000 --- a/Sources/ArgumentEncoding/OptionFormatter.swift +++ /dev/null @@ -1,84 +0,0 @@ -// OptionFormatter.swift -// ArgumentEncoding -// -// Copyright © 2023 MFB Technologies, Inc. All rights reserved. - -import Dependencies -import XCTestDynamicOverlay - -/// Formats `Option`s to match how different executables format arguments -public struct OptionFormatter { - private let format: (_ key: String, _ value: String) -> [String] - - internal func format(encoding: OptionEncoding) -> [String] { - format(encoding.key, encoding.value) - } - - /// Initialize a new formatter - /// - /// - Parameters - /// - _ format: An escaping closure that takes the Option's key and value as input and returns an array of - /// formatted strings - public init(_ format: @escaping (_ key: String, _ value: String) -> [String]) { - self.format = format - } -} - -extension OptionFormatter { - /// A formatter that prefixes option names with '-' - public static let singleDashPrefix = OptionFormatter { [StaticString.singleDash.description + $0, $1] } - - /// A formatter that prefixes option names with '-' and converts from camelCase to kebab-case - public static let singleDashPrefixKebabCase = OptionFormatter { key, value in - [StaticString.singleDash.description + CaseConverter.kebabCase(key), value] - } - - /// A formatter that prefixes option names with '-' and converts from camelCase to snake_case - public static let singleDashPrefixSnakeCase = OptionFormatter { key, value in - [StaticString.singleDash.description + CaseConverter.snakeCase(key), value] - } - - /// A formatter that prefixes option names with '--' - public static let doubleDashPrefix = OptionFormatter { [StaticString.doubleDash.description + $0, $1] } - - /// A formatter that prefixes option names with '--' and converts from camelCase to kebab-case - public static let doubleDashPrefixKebabCase = OptionFormatter { key, value in - [StaticString.doubleDash.description + CaseConverter.kebabCase(key), value] - } - - /// A formatter that prefixes option names with '--' and converts from camelCase to snake_case - public static let doubleDashPrefixSnakeCase = OptionFormatter { key, value in - [StaticString.doubleDash.description + CaseConverter.snakeCase(key), value] - } - - /// A formatter that inserts an '=' between the option name and value - public static let equalSeparator = OptionFormatter { [$0 + StaticString.equal.description + $1] } - - /// A formatter that inserts an '=' between the option name and value. Also, converts from camelCase to kebab-case - public static let equalSeparatorKebabCase = OptionFormatter { key, value in - [CaseConverter.kebabCase(key) + StaticString.equal.description + value] - } - - /// A formatter that inserts an '=' between the option name and value. Also, converts from camelCase to snake_case - public static let equalSeparatorSnakeCase = OptionFormatter { key, value in - [CaseConverter.snakeCase(key) + StaticString.equal.description + value] - } -} - -extension OptionFormatter: TestDependencyKey { - public static let testValue: OptionFormatter = .unimplemented -} - -extension DependencyValues { - public var optionFormatter: OptionFormatter { - get { self[OptionFormatter.self] } - set { self[OptionFormatter.self] = newValue } - } -} - -extension OptionFormatter { - public static let unimplemented = OptionFormatter( - XCTestDynamicOverlay - .unimplemented(placeholder: ["unimplemented"]) - ) -} diff --git a/Sources/ArgumentEncoding/OptionSet.swift b/Sources/ArgumentEncoding/OptionSet.swift index a96a206..5a2b5c3 100644 --- a/Sources/ArgumentEncoding/OptionSet.swift +++ b/Sources/ArgumentEncoding/OptionSet.swift @@ -261,7 +261,7 @@ struct OptionSetEncoding { let values: [OptionEncoding] func arguments() -> [String] { - values.map { formatter.format(encoding: $0) }.flatMap { $0 } + values.map { formatter.format(encoding: $0) } } } diff --git a/Sources/ArgumentEncoding/PositionalArgument.swift b/Sources/ArgumentEncoding/PositionalArgument.swift index 68ca5ea..6204001 100644 --- a/Sources/ArgumentEncoding/PositionalArgument.swift +++ b/Sources/ArgumentEncoding/PositionalArgument.swift @@ -12,7 +12,7 @@ import Foundation /// ```swift /// struct Container: ArgumentGroup, FormatterNode { /// let flagFormatter: FlagFormatter = .doubleDashPrefix -/// let optionFormatter: OptionFormatter = .doubleDashPrefix +/// let optionFormatter: OptionFormatter = OptionFormatter(prefix: .doubleDash) /// /// @Positional var name: String = "value" /// } diff --git a/Sources/ArgumentEncoding/TopLevelCommandRepresentable.swift b/Sources/ArgumentEncoding/TopLevelCommandRepresentable.swift index fe7ec0b..ac0ab39 100644 --- a/Sources/ArgumentEncoding/TopLevelCommandRepresentable.swift +++ b/Sources/ArgumentEncoding/TopLevelCommandRepresentable.swift @@ -14,7 +14,7 @@ /// /// // Formatters to satisfy `FormatterNode` requirements /// let flagFormatter: FlagFormatter = .doubleDashPrefix -/// let optionFormatter: OptionFormatter = .doubleDashPrefix +/// let optionFormatter: OptionFormatter = OptionFormatter(prefix: .doubleDash) /// /// // Properties that represent the child arguments /// @Flag var asyncMain: Bool diff --git a/Tests/ArgumentEncodingTests/ArgumentEncodingTests.swift b/Tests/ArgumentEncodingTests/ArgumentEncodingTests.swift deleted file mode 100644 index 6aa9f85..0000000 --- a/Tests/ArgumentEncodingTests/ArgumentEncodingTests.swift +++ /dev/null @@ -1,13 +0,0 @@ -// ArgumentEncodingTests.swift -// ArgumentEncoding -// -// Copyright © 2023 MFB Technologies, Inc. All rights reserved. - -import ArgumentEncoding -import XCTest - -final class ArgumentEncodingTests: XCTestCase { - func testExample() throws { - XCTAssert(true) - } -} diff --git a/Tests/ArgumentEncodingTests/ArgumentGroupTests.swift b/Tests/ArgumentEncodingTests/ArgumentGroupTests.swift index f9c4828..7cbe09d 100644 --- a/Tests/ArgumentEncodingTests/ArgumentGroupTests.swift +++ b/Tests/ArgumentEncodingTests/ArgumentGroupTests.swift @@ -9,8 +9,8 @@ import XCTest final class ArgumentGroupTests: XCTestCase { private struct EmptyGroup: ArgumentGroup, FormatterNode { - let flagFormatter: FlagFormatter = .doubleDashPrefix - let optionFormatter: OptionFormatter = .doubleDashPrefix + let flagFormatter: FlagFormatter = .init(prefix: .doubleDash) + let optionFormatter: OptionFormatter = .init(prefix: .doubleDash) } func testEmptyGroup() throws { @@ -18,8 +18,8 @@ final class ArgumentGroupTests: XCTestCase { } private struct Group: ArgumentGroup, FormatterNode { - let flagFormatter: FlagFormatter = .doubleDashPrefix - let optionFormatter: OptionFormatter = .doubleDashPrefix + let flagFormatter: FlagFormatter = .init(prefix: .doubleDash) + let optionFormatter: OptionFormatter = .init(prefix: .doubleDash) @Flag var asyncMain: Bool @Option var numThreads: Int = 0 @@ -39,7 +39,7 @@ final class ArgumentGroupTests: XCTestCase { numThreads: 2, target: "target" ).arguments(), - ["--numThreads", "2", "target"] + ["--numThreads 2", "target"] ) XCTAssertEqual( @@ -48,13 +48,13 @@ final class ArgumentGroupTests: XCTestCase { numThreads: 0, target: "target" ).arguments(), - ["--asyncMain", "--numThreads", "0", "target"] + ["--asyncMain", "--numThreads 0", "target"] ) } private struct ParentGroup: ArgumentGroup, FormatterNode { - let flagFormatter: FlagFormatter = .doubleDashPrefix - let optionFormatter: OptionFormatter = .doubleDashPrefix + let flagFormatter: FlagFormatter = .init(prefix: .doubleDash) + let optionFormatter: OptionFormatter = .init(prefix: .doubleDash) @Flag var asyncMain: Bool @Option var numThreads: Int = 0 @@ -70,8 +70,8 @@ final class ArgumentGroupTests: XCTestCase { } private struct ChildGroup: ArgumentGroup, FormatterNode { - let flagFormatter: FlagFormatter = .singleDashPrefix - let optionFormatter: OptionFormatter = .singleDashPrefix + let flagFormatter: FlagFormatter = .init(prefix: .singleDash) + let optionFormatter: OptionFormatter = .init(prefix: .singleDash) @Option var configuration: Configuration = .arm64 @Flag var buildTests: Bool @@ -103,7 +103,7 @@ final class ArgumentGroupTests: XCTestCase { target: "target" ) ).arguments(), - ["--numThreads", "2", "target", "-configuration", "arm64", "target"] + ["--numThreads 2", "target", "-configuration arm64", "target"] ) XCTAssertEqual( @@ -117,7 +117,7 @@ final class ArgumentGroupTests: XCTestCase { target: "target" ) ).arguments(), - ["--asyncMain", "--numThreads", "1", "target", "-configuration", "x86_64", "-buildTests", "target"] + ["--asyncMain", "--numThreads 1", "target", "-configuration x86_64", "-buildTests", "target"] ) } @@ -171,8 +171,8 @@ final class ArgumentGroupTests: XCTestCase { } private enum ParentEnumGroup: ArgumentGroup, FormatterNode { - var flagFormatter: FlagFormatter { .singleDashPrefix } - var optionFormatter: OptionFormatter { .singleDashPrefix } + var flagFormatter: FlagFormatter { FlagFormatter(prefix: .singleDash) } + var optionFormatter: OptionFormatter { OptionFormatter(prefix: .singleDash) } case run(asyncMain: Flag, skipBuild: Flag) case test(numWorkers: Option, testProduct: Option) @@ -203,13 +203,13 @@ final class ArgumentGroupTests: XCTestCase { func testEnumGroupTest() throws { XCTAssertEqual( ParentEnumGroup.test(numWorkers: 2, testProduct: "PackageTarget").arguments(), - ["-numWorkers", "2", "-testProduct", "PackageTarget"] + ["-numWorkers 2", "-testProduct PackageTarget"] ) } private struct DeepNestedA: ArgumentGroup, FormatterNode { - let flagFormatter: FlagFormatter = .doubleDashPrefix - let optionFormatter: OptionFormatter = .doubleDashPrefix + let flagFormatter: FlagFormatter = .init(prefix: .doubleDash) + let optionFormatter: OptionFormatter = .init(prefix: .doubleDash) @Flag var deepNestedA: Bool = true var deepNestedB: DeepNestedB = .init() @@ -248,13 +248,13 @@ final class ArgumentGroupTests: XCTestCase { } extension Array: ArgumentGroup, FormatterNode { - public var flagFormatter: ArgumentEncoding.FlagFormatter { .doubleDashPrefix } + public var flagFormatter: ArgumentEncoding.FlagFormatter { FlagFormatter(prefix: .doubleDash) } - public var optionFormatter: ArgumentEncoding.OptionFormatter { .doubleDashPrefix } + public var optionFormatter: ArgumentEncoding.OptionFormatter { OptionFormatter(prefix: .doubleDash) } } extension Dictionary: ArgumentGroup, FormatterNode { - public var flagFormatter: ArgumentEncoding.FlagFormatter { .doubleDashPrefix } + public var flagFormatter: ArgumentEncoding.FlagFormatter { FlagFormatter(prefix: .doubleDash) } - public var optionFormatter: ArgumentEncoding.OptionFormatter { .doubleDashPrefix } + public var optionFormatter: ArgumentEncoding.OptionFormatter { OptionFormatter(prefix: .doubleDash) } } diff --git a/Tests/ArgumentEncodingTests/CommandRepresentableTests.swift b/Tests/ArgumentEncodingTests/CommandRepresentableTests.swift index d6b98b6..38d2098 100644 --- a/Tests/ArgumentEncodingTests/CommandRepresentableTests.swift +++ b/Tests/ArgumentEncodingTests/CommandRepresentableTests.swift @@ -8,8 +8,8 @@ import XCTest final class CommandRepresentableTests: XCTestCase { private struct Container: ArgumentGroup, FormatterNode where T: CommandRepresentable { - var flagFormatter: FlagFormatter { .doubleDashPrefix } - var optionFormatter: OptionFormatter { .doubleDashPrefix } + var flagFormatter: FlagFormatter { FlagFormatter(prefix: .doubleDash) } + var optionFormatter: OptionFormatter { OptionFormatter(prefix: .doubleDash) } var command: T @@ -19,8 +19,8 @@ final class CommandRepresentableTests: XCTestCase { } private struct EmptyCommand: CommandRepresentable, FormatterNode { - let flagFormatter: FlagFormatter = .doubleDashPrefix - let optionFormatter: OptionFormatter = .doubleDashPrefix + let flagFormatter: FlagFormatter = .init(prefix: .doubleDash) + let optionFormatter: OptionFormatter = .init(prefix: .doubleDash) } func testEmptyCommand() throws { @@ -30,8 +30,8 @@ final class CommandRepresentableTests: XCTestCase { } private struct CommandGroup: CommandRepresentable, FormatterNode { - let flagFormatter: FlagFormatter = .doubleDashPrefix - let optionFormatter: OptionFormatter = .doubleDashPrefix + let flagFormatter: FlagFormatter = .init(prefix: .doubleDash) + let optionFormatter: OptionFormatter = .init(prefix: .doubleDash) @Flag var verbose: Bool @Option var product: String? = nil @@ -50,8 +50,7 @@ final class CommandRepresentableTests: XCTestCase { )).arguments(), [ "command", - "--product", - "Target", + "--product Target", ] ) @@ -68,8 +67,8 @@ final class CommandRepresentableTests: XCTestCase { } private struct ParentCommand: CommandRepresentable, FormatterNode { - let flagFormatter: FlagFormatter = .doubleDashPrefix - let optionFormatter: OptionFormatter = .doubleDashPrefix + let flagFormatter: FlagFormatter = .init(prefix: .doubleDash) + let optionFormatter: OptionFormatter = .init(prefix: .doubleDash) @Flag var verbose: Bool @Option var product: String? = nil @@ -83,8 +82,8 @@ final class CommandRepresentableTests: XCTestCase { } private struct ChildCommand: CommandRepresentable, FormatterNode { - let flagFormatter: FlagFormatter = .singleDashPrefix - let optionFormatter: OptionFormatter = .singleDashPrefix + let flagFormatter: FlagFormatter = .init(prefix: .singleDash) + let optionFormatter: OptionFormatter = .init(prefix: .singleDash) @Option var configuration: Configuration = .arm64 @Flag var buildTests: Bool @@ -114,7 +113,7 @@ final class CommandRepresentableTests: XCTestCase { buildTests: true ) )).arguments(), - ["command", "--product", "OtherTarget", "child", "-configuration", "arm64", "-buildTests"] + ["command", "--product OtherTarget", "child", "-configuration arm64", "-buildTests"] ) XCTAssertEqual( @@ -126,13 +125,13 @@ final class CommandRepresentableTests: XCTestCase { buildTests: false ) )).arguments(), - ["command", "--verbose", "child", "-configuration", "x86_64"] + ["command", "--verbose", "child", "-configuration x86_64"] ) } private enum ParentEnumCommand: CommandRepresentable, FormatterNode { - var flagFormatter: FlagFormatter { .singleDashPrefix } - var optionFormatter: OptionFormatter { .singleDashPrefix } + var flagFormatter: FlagFormatter { FlagFormatter(prefix: .singleDash) } + var optionFormatter: OptionFormatter { OptionFormatter(prefix: .singleDash) } case run(asyncMain: Flag, skipBuild: Flag) case test(numWorkers: Option, testProduct: Option) @@ -163,14 +162,14 @@ final class CommandRepresentableTests: XCTestCase { func testEnumTest() throws { XCTAssertEqual( ParentEnumCommand.test(numWorkers: 2, testProduct: "PackageTarget").arguments(), - ["test", "-numWorkers", "2", "-testProduct", "PackageTarget"] + ["test", "-numWorkers 2", "-testProduct PackageTarget"] ) } func testEnumChild() throws { XCTAssertEqual( ParentEnumCommand.child(ChildCommand(configuration: .arm64, buildTests: true)).arguments(), - ["child", "-configuration", "arm64", "-buildTests"] + ["child", "-configuration arm64", "-buildTests"] ) } } diff --git a/Tests/ArgumentEncodingTests/FlagTests.swift b/Tests/ArgumentEncodingTests/FlagTests.swift index f121659..8f7915b 100644 --- a/Tests/ArgumentEncodingTests/FlagTests.swift +++ b/Tests/ArgumentEncodingTests/FlagTests.swift @@ -11,7 +11,7 @@ final class FlagTests: XCTestCase { func testFlagImplicitEnabled() throws { let flag = Flag("verbose") let args = withDependencies { values in - values.flagFormatter = .doubleDashPrefix + values.flagFormatter = FlagFormatter(prefix: .doubleDash) } operation: { flag.arguments() } @@ -21,7 +21,7 @@ final class FlagTests: XCTestCase { func testFlagExplicitEnabled() throws { let flag = Flag("verbose", enabled: true) let args = withDependencies { values in - values.flagFormatter = .doubleDashPrefix + values.flagFormatter = FlagFormatter(prefix: .doubleDash) } operation: { flag.arguments() } diff --git a/Tests/ArgumentEncodingTests/FormatterTests.swift b/Tests/ArgumentEncodingTests/FormatterTests.swift new file mode 100644 index 0000000..f364a9c --- /dev/null +++ b/Tests/ArgumentEncodingTests/FormatterTests.swift @@ -0,0 +1,87 @@ +// FormatterTests.swift +// ArgumentEncoding +// +// Copyright © 2023 MFB Technologies, Inc. All rights reserved. + +import ArgumentEncoding +import Foundation +import XCTest + +final class FormatterTests: XCTestCase { + func testFlagFormatterSingleDashPrefix() throws { + XCTAssertEqual( + FlagFormatter(prefix: .singleDash).format(key: "flagKey"), + "-flagKey" + ) + } + + func testFlagFormatterDoubleDashPrefix() throws { + XCTAssertEqual( + FlagFormatter(prefix: .doubleDash).format(key: "flagKey"), + "--flagKey" + ) + } + + func testFlagFormatterEmptyPrefix() throws { + XCTAssertEqual( + FlagFormatter(prefix: .empty).format(key: "flagKey"), + "flagKey" + ) + } + + func testFlagFormatterKebabCaseBody() throws { + XCTAssertEqual( + FlagFormatter(body: .kebabCase).format(key: "flagKey"), + "flag-key" + ) + } + + func testFlagFormatterSnakeCaseBody() throws { + XCTAssertEqual( + FlagFormatter(body: .snakeCase).format(key: "flagKey"), + "flag_key" + ) + } + + func testOptionFormatterSingleDashPrefix() throws { + XCTAssertEqual( + OptionFormatter(prefix: .singleDash).format(key: "optionKey", value: "optionValue"), + "-optionKey optionValue" + ) + } + + func testOptionFormatterDoubleDashPrefix() throws { + XCTAssertEqual( + OptionFormatter(prefix: .doubleDash).format(key: "optionKey", value: "optionValue"), + "--optionKey optionValue" + ) + } + + func testOptionFormatterEmptyPrefix() throws { + XCTAssertEqual( + OptionFormatter(prefix: .empty).format(key: "optionKey", value: "optionValue"), + "optionKey optionValue" + ) + } + + func testOptionFormatterKebabCaseBody() throws { + XCTAssertEqual( + OptionFormatter(body: .kebabCase).format(key: "optionKey", value: "optionValue"), + "option-key optionValue" + ) + } + + func testOptionFormatterSnakeCaseBody() throws { + XCTAssertEqual( + OptionFormatter(body: .snakeCase).format(key: "optionKey", value: "optionValue"), + "option_key optionValue" + ) + } + + func testOptionFormatterEqualSeparator() throws { + XCTAssertEqual( + OptionFormatter(separator: .equal).format(key: "optionKey", value: "optionValue"), + "optionKey=optionValue" + ) + } +} diff --git a/Tests/ArgumentEncodingTests/OptionSetTests.swift b/Tests/ArgumentEncodingTests/OptionSetTests.swift index 9784b45..29b0a40 100644 --- a/Tests/ArgumentEncodingTests/OptionSetTests.swift +++ b/Tests/ArgumentEncodingTests/OptionSetTests.swift @@ -11,11 +11,11 @@ final class OptionSetTests: XCTestCase { func testOptionSet() throws { let optionSet = OptionSet(key: "configuration", value: ["release", "debug"]) let args = withDependencies { values in - values.optionFormatter = .doubleDashPrefix + values.optionFormatter = OptionFormatter(prefix: .doubleDash) } operation: { optionSet.arguments() } - XCTAssertEqual(args, ["--configuration", "release", "--configuration", "debug"]) + XCTAssertEqual(args, ["--configuration release", "--configuration debug"]) } func testBothRawValueAndStringConvertible() throws { @@ -27,11 +27,11 @@ final class OptionSetTests: XCTestCase { ] ) let args = withDependencies { values in - values.optionFormatter = .doubleDashPrefix + values.optionFormatter = OptionFormatter(prefix: .doubleDash) } operation: { optionSet.arguments() } - XCTAssertEqual(args, ["--configuration", "release", "--configuration", "debug"]) + XCTAssertEqual(args, ["--configuration release", "--configuration debug"]) } func testBothRawValueAndStringConvertibleContainer() throws { @@ -40,11 +40,11 @@ final class OptionSetTests: XCTestCase { RawValueCustomStringConvertible(rawValue: "debug"), ]) let args = withDependencies { values in - values.optionFormatter = .doubleDashPrefix + values.optionFormatter = OptionFormatter(prefix: .doubleDash) } operation: { container.arguments() } - XCTAssertEqual(args, ["--configuration", "release", "--configuration", "debug"]) + XCTAssertEqual(args, ["--configuration release", "--configuration debug"]) } } diff --git a/Tests/ArgumentEncodingTests/OptionTests.swift b/Tests/ArgumentEncodingTests/OptionTests.swift index 41488fb..6bf508f 100644 --- a/Tests/ArgumentEncodingTests/OptionTests.swift +++ b/Tests/ArgumentEncodingTests/OptionTests.swift @@ -11,31 +11,31 @@ final class OptionTests: XCTestCase { func testOption() throws { let option = Option(key: "configuration", value: "release") let args = withDependencies { values in - values.optionFormatter = .doubleDashPrefix + values.optionFormatter = OptionFormatter(prefix: .doubleDash) } operation: { option.arguments() } - XCTAssertEqual(args, ["--configuration", "release"]) + XCTAssertEqual(args, ["--configuration release"]) } func testBothRawValueAndStringConvertible() throws { let option = Option(key: "configuration", value: RawValueCustomStringConvertible(rawValue: "release")) let args = withDependencies { values in - values.optionFormatter = .doubleDashPrefix + values.optionFormatter = OptionFormatter(prefix: .doubleDash) } operation: { option.arguments() } - XCTAssertEqual(args, ["--configuration", "release"]) + XCTAssertEqual(args, ["--configuration release"]) } func testBothRawValueAndStringConvertibleContainer() throws { let container = Container(configuration: RawValueCustomStringConvertible(rawValue: "release")) let args = withDependencies { values in - values.optionFormatter = .doubleDashPrefix + values.optionFormatter = OptionFormatter(prefix: .doubleDash) } operation: { container.arguments() } - XCTAssertEqual(args, ["--configuration", "release"]) + XCTAssertEqual(args, ["--configuration release"]) } } diff --git a/Tests/ArgumentEncodingTests/TopLevelCommandRepresentableTests.swift b/Tests/ArgumentEncodingTests/TopLevelCommandRepresentableTests.swift index 54d0b09..a437c29 100644 --- a/Tests/ArgumentEncodingTests/TopLevelCommandRepresentableTests.swift +++ b/Tests/ArgumentEncodingTests/TopLevelCommandRepresentableTests.swift @@ -9,8 +9,8 @@ import XCTest final class TopLevelCommandRepresentableTests: XCTestCase { private struct EmptyCommand: TopLevelCommandRepresentable { func commandValue() -> Command { "swift" } - let flagFormatter: FlagFormatter = .doubleDashPrefix - let optionFormatter: OptionFormatter = .doubleDashPrefix + let flagFormatter: FlagFormatter = .init(prefix: .doubleDash) + let optionFormatter: OptionFormatter = .init(prefix: .doubleDash) } func testEmptyCommand() throws { @@ -21,8 +21,8 @@ final class TopLevelCommandRepresentableTests: XCTestCase { private struct CommandGroup: TopLevelCommandRepresentable { func commandValue() -> Command { "swift" } - let flagFormatter: FlagFormatter = .doubleDashPrefix - let optionFormatter: OptionFormatter = .doubleDashPrefix + let flagFormatter: FlagFormatter = .init(prefix: .doubleDash) + let optionFormatter: OptionFormatter = .init(prefix: .doubleDash) @Flag var verbose: Bool @Option var product: String? = nil @@ -41,8 +41,7 @@ final class TopLevelCommandRepresentableTests: XCTestCase { ).arguments(), [ "swift", - "--product", - "Target", + "--product Target", ] ) @@ -60,8 +59,8 @@ final class TopLevelCommandRepresentableTests: XCTestCase { private struct ParentCommand: TopLevelCommandRepresentable { func commandValue() -> Command { "parent" } - let flagFormatter: FlagFormatter = .doubleDashPrefix - let optionFormatter: OptionFormatter = .doubleDashPrefix + let flagFormatter: FlagFormatter = .init(prefix: .doubleDash) + let optionFormatter: OptionFormatter = .init(prefix: .doubleDash) @Flag var verbose: Bool @Option var product: String? = nil @@ -75,8 +74,8 @@ final class TopLevelCommandRepresentableTests: XCTestCase { struct ChildCommand: CommandRepresentable, FormatterNode { func commandValue() -> Command { "child" } - let flagFormatter: FlagFormatter = .singleDashPrefix - let optionFormatter: OptionFormatter = .singleDashPrefix + let flagFormatter: FlagFormatter = .init(prefix: .singleDash) + let optionFormatter: OptionFormatter = .init(prefix: .singleDash) @Option var configuration: Configuration = .arm64 @Flag var buildTests: Bool @@ -105,7 +104,7 @@ final class TopLevelCommandRepresentableTests: XCTestCase { buildTests: true ) ).arguments(), - ["parent", "--product", "OtherTarget", "child", "-configuration", "arm64", "-buildTests"] + ["parent", "--product OtherTarget", "child", "-configuration arm64", "-buildTests"] ) XCTAssertEqual( @@ -117,14 +116,14 @@ final class TopLevelCommandRepresentableTests: XCTestCase { buildTests: false ) ).arguments(), - ["parent", "--verbose", "child", "-configuration", "x86_64"] + ["parent", "--verbose", "child", "-configuration x86_64"] ) } private enum ParentEnumCommand: TopLevelCommandRepresentable { func commandValue() -> Command { "parent" } - var flagFormatter: FlagFormatter { .singleDashPrefix } - var optionFormatter: OptionFormatter { .singleDashPrefix } + var flagFormatter: FlagFormatter { FlagFormatter(prefix: .singleDash) } + var optionFormatter: OptionFormatter { OptionFormatter(prefix: .singleDash) } case run(asyncMain: Flag, skipBuild: Flag) case test(numWorkers: Option, testProduct: Option) @@ -133,8 +132,8 @@ final class TopLevelCommandRepresentableTests: XCTestCase { private struct ChildEnumCommand: TopLevelCommandRepresentable { func commandValue() -> Command { "child" } - let flagFormatter: FlagFormatter = .singleDashPrefix - let optionFormatter: OptionFormatter = .singleDashPrefix + let flagFormatter: FlagFormatter = .init(prefix: .singleDash) + let optionFormatter: OptionFormatter = .init(prefix: .singleDash) @Option var configuration: Configuration = .arm64 @Flag var buildTests: Bool @@ -176,14 +175,14 @@ final class TopLevelCommandRepresentableTests: XCTestCase { func testEnumTest() throws { XCTAssertEqual( ParentEnumCommand.test(numWorkers: 2, testProduct: "PackageTarget").arguments(), - ["parent", "-numWorkers", "2", "-testProduct", "PackageTarget"] + ["parent", "-numWorkers 2", "-testProduct PackageTarget"] ) } func testEnumChild() throws { XCTAssertEqual( ParentEnumCommand.child(ChildEnumCommand(configuration: .arm64, buildTests: true)).arguments(), - ["parent", "child", "-configuration", "arm64", "-buildTests"] + ["parent", "child", "-configuration arm64", "-buildTests"] ) } }