├── .github └── workflows │ └── swift.yml ├── .gitignore ├── CarthageSupport ├── SupportingFiles │ ├── Info.plist │ └── SwiftCLI.h └── SwiftCLI.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ └── SwiftCLI.xcscheme ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── SwiftCLI │ ├── ArgumentList.swift │ ├── ArgumentListManipulator.swift │ ├── CLI.swift │ ├── Command.swift │ ├── Compatibility.swift │ ├── CompletionGenerator.swift │ ├── Error.swift │ ├── HelpCommand.swift │ ├── HelpMessageGenerator.swift │ ├── Input.swift │ ├── Option.swift │ ├── OptionGroup.swift │ ├── OptionRegistry.swift │ ├── Parameter.swift │ ├── Parser.swift │ ├── Path.swift │ ├── Stream.swift │ ├── Task.swift │ ├── Term.swift │ ├── Validation.swift │ ├── ValueBox.swift │ └── VersionCommand.swift ├── SwiftCLI.podspec └── Tests ├── Info.plist ├── LinuxMain.swift └── SwiftCLITests ├── ArgumentListTests.swift ├── CompletionGeneratorTests.swift ├── Fixtures.swift ├── HelpMessageGeneratorTests.swift ├── InputTests.swift ├── OptionRegistryTests.swift ├── ParameterFillerTests.swift ├── ParserTests.swift ├── RouterTests.swift ├── StreamTests.swift ├── SwiftCLITests.swift ├── TaskTests.swift ├── ValidationTests.swift └── XCTestManifests.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | strategy: 8 | matrix: 9 | os: [macOS-latest, ubuntu-latest] 10 | swift: ["5.4"] 11 | runs-on: ${{ matrix.os }} 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: fwal/setup-swift@v1 15 | with: 16 | swift-version: ${{ matrix.swift }} 17 | - name: Build 18 | run: swift build 19 | - name: Run tests 20 | run: swift test 21 | 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .build 3 | /SwiftCLI.xcodeproj 4 | -------------------------------------------------------------------------------- /CarthageSupport/SupportingFiles/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | NSHumanReadableCopyright 22 | Copyright © 2018 Jake Heiser. All rights reserved. 23 | 24 | 25 | -------------------------------------------------------------------------------- /CarthageSupport/SupportingFiles/SwiftCLI.h: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftCLI.h 3 | // SwiftCLI 4 | // 5 | // Created by Cihat Gündüz on 05.11.18. 6 | // Copyright © 2018 Jake Heiser. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | //! Project version number for SwiftCLI. 12 | FOUNDATION_EXPORT double SwiftCLIVersionNumber; 13 | 14 | //! Project version string for SwiftCLI. 15 | FOUNDATION_EXPORT const unsigned char SwiftCLIVersionString[]; 16 | 17 | // In this header, you should import all the public headers of your framework using statements like #import 18 | 19 | 20 | -------------------------------------------------------------------------------- /CarthageSupport/SwiftCLI.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CarthageSupport/SwiftCLI.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /CarthageSupport/SwiftCLI.xcodeproj/xcshareddata/xcschemes/SwiftCLI.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 34 | 35 | 45 | 46 | 52 | 53 | 54 | 55 | 56 | 57 | 63 | 64 | 70 | 71 | 72 | 73 | 75 | 76 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Jake Heiser 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // Managed by ice 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SwiftCLI", 8 | products: [ 9 | .library(name: "SwiftCLI", targets: ["SwiftCLI"]), 10 | ], 11 | targets: [ 12 | .target(name: "SwiftCLI", dependencies: []), 13 | .testTarget(name: "SwiftCLITests", dependencies: ["SwiftCLI"]), 14 | ] 15 | ) 16 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/ArgumentList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArgumentList.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 3/28/17. 6 | // Copyright (c) 2017 jakeheis. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public class ArgumentList { 12 | 13 | private var storage: [String] 14 | 15 | /// Creates a list of the arguments from given array 16 | public init(arguments: [String]) { 17 | self.storage = arguments 18 | } 19 | 20 | /// Checks if list has another argument 21 | /// 22 | /// - Returns: whether list has another argument 23 | public func hasNext() -> Bool { 24 | return !storage.isEmpty 25 | } 26 | 27 | /// Pops off the next argument 28 | /// 29 | /// - Returns: the next argument 30 | /// - Precondition: list must not be empty 31 | @discardableResult 32 | public func pop() -> String { 33 | return storage.removeFirst() 34 | } 35 | 36 | /// Peeks at the next argument 37 | /// 38 | /// - Returns: the next argument 39 | /// - Precondition: list must not be empty 40 | @discardableResult 41 | public func peek() -> String { 42 | return storage[0] 43 | } 44 | 45 | /// Checks if the next argument is an option argument 46 | /// 47 | /// - Returns: whether next argument is an option 48 | public func nextIsOption() -> Bool { 49 | return storage.first?.hasPrefix("-") ?? false 50 | } 51 | 52 | /// Manipulate the argument list with the given closure 53 | /// 54 | /// - Parameter manipiulation: closure which takes in current array of arguments, returns manipulated array of args 55 | public func manipulate(_ manipiulation: ([String]) -> [String]) { 56 | storage = manipiulation(storage) 57 | } 58 | 59 | } 60 | 61 | extension ArgumentList: CustomStringConvertible { 62 | public var description: String { 63 | return storage.description 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/ArgumentListManipulator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArgumentListManipulator.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 3/28/17. 6 | // Copyright © 2017 jakeheis. All rights reserved. 7 | // 8 | 9 | public protocol _ArgumentListManipulator { 10 | func manipulate(arguments: ArgumentList) 11 | } 12 | 13 | @available(*, deprecated, message: "use a custom ParserResponse instead") 14 | public protocol ArgumentListManipulator: _ArgumentListManipulator { 15 | func manipulate(arguments: ArgumentList) 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/CLI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CLI.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 7/20/14. 6 | // Copyright (c) 2014 jakeheis. All rights reserved. 7 | // 8 | 9 | #if os(Linux) 10 | import Glibc 11 | #else 12 | import Darwin 13 | #endif 14 | 15 | public class CLI { 16 | 17 | /// The name of the CLI; used in help messages 18 | public let name: String 19 | 20 | /// The version of the CLI; if non-nil, a VersionCommand is automatically created 21 | public let version: String? 22 | 23 | /// The description of the CLI; used in help messages 24 | public var description: String? 25 | 26 | /// The array of commands (or command groups) 27 | public var commands: [Routable] 28 | 29 | /// A built-in help command; set to nil if this functionality is not wanted 30 | public lazy var helpCommand: HelpCommand? = HelpCommand(cli: self) 31 | 32 | /// A built-in version command; set to nil if this functionality is not wanted 33 | public lazy var versionCommand: VersionCommand? = { 34 | if let version = version { 35 | return VersionCommand(version: version) 36 | } 37 | return nil 38 | }() 39 | 40 | /// Options which every command inherits 41 | public var globalOptions: [Option] = [] 42 | 43 | /// Option groups which every command inherits 44 | public var globalOptionGroups: [OptionGroup] = [] 45 | 46 | /// A built-in help flag which each command automatically inherits; set to nil if this functionality is not wanted 47 | public var helpFlag: Flag? = Flag("-h", "--help", description: "Show help information") 48 | 49 | /// A map of command name aliases; by default, default maps "--version" to 'version' 50 | public var aliases: [String : String] = [ 51 | "--version": "version" 52 | ] 53 | 54 | public var helpMessageGenerator: HelpMessageGenerator = DefaultHelpMessageGenerator() 55 | public var argumentListManipulators: [_ArgumentListManipulator] = [] 56 | public var parser = Parser() 57 | 58 | /// Creates a new CLI 59 | /// 60 | /// - Parameter name: the name of the CLI executable 61 | /// - Parameter version: the current version of the CLI 62 | /// - Parameter description: a brief description of the CLI 63 | public init(name: String, version: String? = nil, description: String? = nil, commands: [Routable] = []) { 64 | self.name = name 65 | self.version = version 66 | self.description = description 67 | self.commands = commands 68 | } 69 | 70 | /// Create a single-command CLI; useful for example if you were implementing the 'ln' command 71 | /// 72 | /// - Parameter singleCommand: the single command 73 | public convenience init(singleCommand: Command) { 74 | self.init(name: singleCommand.name, commands: [singleCommand]) 75 | parser.routeBehavior = .automatically(singleCommand) 76 | } 77 | 78 | /// Kicks off the entire CLI process, routing to and executing the command specified by the passed arguments. 79 | /// Uses the arguments passed in the command line. Exits the program upon completion. 80 | /// 81 | /// - Returns: Never 82 | public func goAndExit() -> Never { 83 | let result = go() 84 | exit(result) 85 | } 86 | 87 | /// Kicks off the entire CLI process, routing to and executing the command specified by the passed arguments. 88 | /// Uses the arguments passed in the command line. 89 | /// 90 | /// - Returns: an Int32 representing the success of the CLI in routing to and executing the correct 91 | /// command. Usually should be passed to `exit(result)` 92 | @discardableResult 93 | public func go() -> Int32 { 94 | return go(with: ArgumentList(arguments: Array(CommandLine.arguments.dropFirst()))) 95 | } 96 | 97 | /// Kicks off the entire CLI process, routing to and executing the command specified by the passed arguments. 98 | /// 99 | /// - Parameter arguments: the arguments to execute with; should not include CLI name (i.e. if you wanted to execute "greeter greet world", 'arguments' should be ["greet", "world"]) 100 | /// - Returns: an Int32 representing the success of the CLI in routing to and executing the correct command. Usually should be passed to `exit(result)` 101 | @discardableResult 102 | public func go(with arguments: [String]) -> Int32 { 103 | return go(with: ArgumentList(arguments: arguments)) 104 | } 105 | 106 | // MARK: - Internal/private 107 | 108 | func go(with arguments: ArgumentList) -> Int32 { 109 | argumentListManipulators.forEach { $0.manipulate(arguments: arguments) } 110 | 111 | var exitStatus: Int32 = 0 112 | 113 | do { 114 | let path = try parse(arguments: arguments) 115 | if helpFlag?.wrappedValue == true { 116 | helpMessageGenerator.writeUsageStatement(for: path, to: stdout) 117 | } else { 118 | try path.command.execute() 119 | } 120 | } catch let error as ProcessError { 121 | if let message = error.message { 122 | stderr <<< message 123 | } 124 | exitStatus = Int32(error.exitStatus) 125 | } catch let error { 126 | helpMessageGenerator.writeUnrecognizedErrorMessage(for: error, to: stderr) 127 | exitStatus = 1 128 | } 129 | 130 | return exitStatus 131 | } 132 | 133 | private func parse(arguments: ArgumentList) throws -> CommandPath { 134 | do { 135 | return try parser.parse(cli: self, arguments: arguments) 136 | } catch let error as RouteError { 137 | helpMessageGenerator.writeRouteErrorMessage(for: error, to: stderr) 138 | throw CLI.Error() 139 | } catch let error as OptionError { 140 | if let command = error.command, command.command is HelpCommand { 141 | return command 142 | } 143 | 144 | helpMessageGenerator.writeMisusedOptionsStatement(for: error, to: stderr) 145 | throw CLI.Error() 146 | } catch let error as ParameterError { 147 | if error.command.command is HelpCommand || helpFlag?.wrappedValue == true { 148 | return error.command 149 | } 150 | 151 | helpMessageGenerator.writeParameterErrorMessage(for: error, to: stderr) 152 | throw CLI.Error() 153 | } 154 | } 155 | 156 | } 157 | 158 | extension CLI: CommandGroup { 159 | 160 | public var shortDescription: String { 161 | return description ?? "" 162 | } 163 | 164 | public var longDescription: String { 165 | return description ?? "" 166 | } 167 | 168 | public var children: [Routable] { 169 | var extra: [Routable] = [] 170 | if let helpCommand = helpCommand { 171 | extra.append(helpCommand) 172 | } 173 | if let versionCommand = versionCommand { 174 | extra.append(versionCommand) 175 | } 176 | return commands + extra 177 | } 178 | 179 | public var options: [Option] { 180 | if let helpFlag = helpFlag { 181 | return globalOptions + [helpFlag] 182 | } 183 | return globalOptions 184 | } 185 | 186 | public var optionGroups: [OptionGroup] { 187 | return globalOptionGroups 188 | } 189 | 190 | } 191 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/Command.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Command.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 7/11/14. 6 | // Copyright (c) 2014 jakeheis. All rights reserved. 7 | // 8 | 9 | // MARK: - Routables 10 | 11 | public protocol Routable: AnyObject { 12 | /// The name of the command or command group 13 | var name: String { get } 14 | 15 | /// A concise description of what this command or group is 16 | var shortDescription: String { get } 17 | 18 | /// A longer description of how to use this command or group 19 | var longDescription: String { get } 20 | 21 | /// The options this command accepts; dervied automatically, don't implement unless custom functionality needed 22 | var options: [Option] { get } 23 | 24 | /// The option groups of this command; defaults to empty array 25 | var optionGroups: [OptionGroup] { get } 26 | } 27 | 28 | extension Routable { 29 | 30 | /// Standard out stream 31 | public var stdout: WritableStream { 32 | return Term.stdout 33 | } 34 | 35 | /// Standard error stream 36 | public var stderr: WritableStream { 37 | return Term.stderr 38 | } 39 | 40 | public var options: [Option] { 41 | return optionsFromMirror(Mirror(reflecting: self)) 42 | } 43 | 44 | private func optionsFromMirror(_ mirror: Mirror) -> [Option] { 45 | var options: [Option] = [] 46 | if let superMirror = mirror.superclassMirror { 47 | options = optionsFromMirror(superMirror) 48 | } 49 | 50 | options.append(contentsOf: mirror.children.compactMap { (child) in 51 | #if !os(macOS) 52 | #if swift(>=4.1.50) 53 | print(child.label as Any, to: &NoStream.stream) 54 | guard child.label != "children" && child.label != "optionGroups" else { 55 | return nil 56 | } 57 | #endif 58 | #endif 59 | 60 | return child.value as? Option 61 | }) 62 | 63 | return options 64 | } 65 | 66 | public var optionGroups: [OptionGroup] { 67 | return [] 68 | } 69 | } 70 | 71 | // MARK: - 72 | 73 | public protocol Command: Routable { 74 | 75 | /// Executes the command 76 | /// 77 | /// - Throws: CLI.Error if command cannot execute successfully 78 | func execute() throws 79 | 80 | /// The parameters this command accepts; derived automatically, don't implement unless custom functionality needed 81 | var parameters: [NamedParameter] { get } 82 | 83 | } 84 | 85 | extension Command { 86 | 87 | public var parameters: [NamedParameter] { 88 | return parametersFromMirror(Mirror(reflecting: self)) 89 | } 90 | 91 | private func parametersFromMirror(_ mirror: Mirror) -> [NamedParameter] { 92 | var parameters: [NamedParameter] = [] 93 | if let superMirror = mirror.superclassMirror { 94 | parameters = parametersFromMirror(superMirror) 95 | } 96 | parameters.append(contentsOf: mirror.children.compactMap { (child) in 97 | guard var label = child.label else { 98 | return nil 99 | } 100 | 101 | #if !os(macOS) 102 | #if swift(>=4.1.50) 103 | print("label \(label)", to: &NoStream.stream) 104 | print("label \(label)", to: &NoStream.stream) 105 | guard label != "children" && label != "optionGroups" else { 106 | return nil 107 | } 108 | #endif 109 | #endif 110 | 111 | if let param = child.value as? AnyParameter { 112 | if label.hasPrefix("_") { 113 | label = String(label[label.index(after: label.startIndex)...]) 114 | } 115 | return NamedParameter(name: label, param: param) 116 | } 117 | return nil 118 | }) 119 | return parameters 120 | } 121 | 122 | public var shortDescription: String { 123 | return "" 124 | } 125 | 126 | public var longDescription: String { 127 | return "" 128 | } 129 | 130 | } 131 | 132 | // MARK: - 133 | 134 | public protocol CommandGroup: Routable { 135 | /// The sub-commands and sub-groups of this group 136 | var children: [Routable] { get } 137 | 138 | /// Aliases for chlidren, e.g. "--help" for "help"; default empty dictionary 139 | var aliases: [String: String] { get } 140 | } 141 | 142 | extension CommandGroup { 143 | public var aliases: [String: String] { 144 | return [:] 145 | } 146 | 147 | public var longDescription: String { 148 | return "" 149 | } 150 | } 151 | 152 | #if !os(macOS) 153 | #if swift(>=4.1.50) 154 | struct NoStream: TextOutputStream { 155 | 156 | // Fix for strange crash on Linux with Swift >= 4.2 157 | 158 | static var stream = NoStream() 159 | 160 | mutating func write(_ string: String) { 161 | // No-op 162 | } 163 | 164 | } 165 | #endif 166 | #endif 167 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/Compatibility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Compatibility.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 9/9/17. 6 | // 7 | 8 | import Foundation 9 | 10 | // MARK: Minor version deprecations 11 | 12 | @available(*, deprecated, message: "use Param") 13 | public typealias Parameter = Param 14 | 15 | @available(*, deprecated, message: "use Param") 16 | public typealias OptionalParameter = Param 17 | 18 | @available(*, deprecated, message: "use CollectedParam(minCount: 1)") 19 | public class CollectedParameter: CollectedParam { 20 | public init(completion: ShellCompletion = .filename, validation: [Validation] = []) { 21 | super.init(minCount: 1, completion: completion, validation: validation) 22 | } 23 | } 24 | 25 | @available(*, deprecated, message: "use CollectedParam") 26 | public typealias OptionalCollectedParameter = CollectedParam 27 | 28 | // MARK: - 29 | 30 | @available(*, unavailable, renamed: "Task.run") 31 | public func run(_ executable: String, _ arguments: String...) throws {} 32 | 33 | @available(*, unavailable, renamed: "Task.run") 34 | public func run(_ executable: String, arguments: [String], directory: String? = nil) throws {} 35 | 36 | @available(*, unavailable, renamed: "Task.capture") 37 | public func capture(_ executable: String, _ arguments: String...) throws -> CaptureResult { fatalError() } 38 | 39 | @available(*, unavailable, renamed: "Task.capture") 40 | public func capture(_ executable: String, arguments: [String], directory: String? = nil) throws -> CaptureResult { fatalError() } 41 | 42 | @available(*, unavailable, renamed: "Task.run") 43 | public func run(bash: String, directory: String? = nil) throws {} 44 | 45 | @available(*, unavailable, renamed: "Task.capture") 46 | public func capture(bash: String, directory: String? = nil) throws -> CaptureResult { fatalError() } 47 | 48 | #if !os(iOS) 49 | 50 | extension Task { 51 | 52 | @available(*, unavailable, message: "Use Task.execvp(_:arguments:directory:env) instead") 53 | public static func execvp(_ executable: String, directory: String? = nil, _ args: String..., env: [String: String]? = nil) throws -> Never { 54 | fatalError() 55 | } 56 | 57 | @available(*, unavailable, message: "Use Task.execvp(_:arguments:directory:env) instead") 58 | public static func execvp(_ executable: String, directory: String? = nil, _ args: [String], env: [String: String]? = nil) throws -> Never { 59 | fatalError() 60 | } 61 | 62 | @available(*, unavailable, renamed: "init(executable:arguments:directory:stdout:stderr:stdin:)") 63 | public convenience init(executable: String, args: [String] = [], currentDirectory: String? = nil, stdout: WritableStream = WriteStream.stdout, stderr: WritableStream = WriteStream.stderr, stdin: ReadableStream = ReadStream.stdin) { 64 | fatalError() 65 | } 66 | 67 | @available(*, unavailable) 68 | public static func findExecutable(named: String) -> String? { nil } 69 | 70 | } 71 | 72 | #endif 73 | 74 | extension Input { 75 | 76 | @available(*, unavailable, message: "Use Validation.custom instead of (String) -> Bool") 77 | public static func readLine(prompt: String? = nil, secure: Bool = false, validation: ((String) -> Bool)? = nil, errorResponse: InputReader.ErrorResponse? = nil) -> String { 78 | return "" 79 | } 80 | 81 | @available(*, unavailable, message: "Use Validation.custom instead of (String) -> Bool") 82 | public static func readInt(prompt: String? = nil, secure: Bool = false, validation: ((Int) -> Bool)? = nil, errorResponse: InputReader.ErrorResponse? = nil) -> Int { 83 | return 0 84 | } 85 | 86 | @available(*, unavailable, message: "Use Validation.custom instead of (String) -> Bool") 87 | public static func readDouble(prompt: String? = nil, secure: Bool = false, validation: ((Double) -> Bool)? = nil, errorResponse: InputReader.ErrorResponse? = nil) -> Double { 88 | return 0 89 | } 90 | 91 | @available(*, unavailable, message: "Use Validation.custom instead of (String) -> Bool") 92 | public static func readBool(prompt: String? = nil, secure: Bool = false, validation: ((Bool) -> Bool)? = nil, errorResponse: InputReader.ErrorResponse? = nil) -> Bool { 93 | return false 94 | } 95 | 96 | @available(*, unavailable, message: "Use Validation.custom instead of (T) -> Bool") 97 | public static func readObject(prompt: String? = nil, secure: Bool = false, validation: ((T) -> Bool)? = nil, errorResponse: InputReader.ErrorResponse? = nil) -> T { 98 | fatalError() 99 | } 100 | 101 | } 102 | 103 | extension InputReader { 104 | @available(*, unavailable, message: "Use Validation.custom instead of InputReader.Validation") 105 | public typealias Validation = (T) -> Bool 106 | } 107 | 108 | extension CLI { 109 | 110 | @available(*, unavailable, message: "Use go(with:)") 111 | public func debugGo(with argumentString: String) -> Int32 { 112 | return 1 113 | } 114 | 115 | } 116 | 117 | @available(*, unavailable, renamed: "ShellCompletion") 118 | public typealias Completion = ShellCompletion 119 | 120 | extension CaptureResult { 121 | 122 | /// The full stdout contents; use `stdout` for trimmed contents 123 | @available(*, unavailable, message: "Use stdout or stdoutData") 124 | public var rawStdout: String { 125 | return String(data: stdoutData, encoding: .utf8) ?? "" 126 | } 127 | 128 | /// The full stderr contents; use `stderr` for trimmed contents 129 | @available(*, unavailable, message: "Use stderr or stderrData") 130 | public var rawStderr: String { 131 | return String(data: stderrData, encoding: .utf8) ?? "" 132 | } 133 | 134 | } 135 | 136 | // MARK: - Swift versions 137 | 138 | #if !swift(>=4.1) 139 | 140 | extension Sequence { 141 | func compactMap(_ transform: (Element) -> T?) -> [T] { 142 | return flatMap(transform) 143 | } 144 | } 145 | 146 | #endif 147 | 148 | #if !swift(>=5.0) 149 | 150 | extension Collection { 151 | func firstIndex(where test: (Element) -> Bool) -> Index? { 152 | return index(where: test) 153 | } 154 | } 155 | 156 | extension Collection where Element: Equatable { 157 | func firstIndex(of element: Element) -> Index? { 158 | return index(of: element) 159 | } 160 | } 161 | 162 | #endif 163 | 164 | // MARK: Unavailable 165 | 166 | extension CLI { 167 | 168 | @available(*, unavailable, message: "Create a new CLI object: let cli = CLI(..)") 169 | public static var name: String { return "" } 170 | 171 | @available(*, unavailable, message: "Create a new CLI object: let cli = CLI(..)") 172 | public static var version: String? { return nil } 173 | 174 | @available(*, unavailable, message: "Create a new CLI object: let cli = CLI(..)") 175 | public static var description: String? { return "" } 176 | 177 | @available(*, unavailable, message: "Create a custom HelpMessageGenerator instead") 178 | public static var helpCommand: Command? = nil 179 | 180 | @available(*, unavailable, message: "Create the CLI object with a nil version and register a custom version command") 181 | public static var versionCommand: Command? = nil 182 | 183 | @available(*, unavailable, message: "Create a new CLI object: let cli = CLI(..)") 184 | public static var helpMessageGenerator: HelpMessageGenerator { return DefaultHelpMessageGenerator() } 185 | 186 | @available(*, unavailable, message: "Create a new CLI object: let cli = CLI(..)") 187 | public static var argumentListManipulators: [_ArgumentListManipulator] { return [] } 188 | 189 | @available(*, unavailable, message: "Create a new CLI object: let cli = CLI(..)") 190 | public static func setup(name: String, version: String? = nil, description: String? = nil) {} 191 | 192 | @available(*, unavailable, message: "Create a new CLI object: let cli = CLI(..)") 193 | public static func register(command: Command) {} 194 | 195 | @available(*, unavailable, message: "Create a new CLI object: let cli = CLI(..)") 196 | public static func register(commands: [Command]) {} 197 | 198 | @available(*, unavailable, message: "Create a new CLI object: let cli = CLI(..)") 199 | public static func go() -> Int32 { return 0 } 200 | 201 | @available(*, unavailable, message: "Create a new CLI object: let cli = CLI(..)") 202 | public static func debugGo(with argumentString: String) -> Int32 { return 0 } 203 | 204 | } 205 | 206 | extension Input { 207 | 208 | @available(*, unavailable, message: "Use Input.readLine()") 209 | public static func awaitInput(message: String?, secure: Bool = false) -> String { return "" } 210 | 211 | @available(*, unavailable, message: "Use Input.readLine() with a validation closure") 212 | public static func awaitInputWithValidation(message: String?, secure: Bool = false, validation: (_ input: String) -> Bool) -> String { return "" } 213 | 214 | @available(*, unavailable, message: "Implement CovertibleFromString on a custom object and use Input.readObject()") 215 | public static func awaitInputWithConversion(message: String?, secure: Bool = false, conversion: (_ input: String) -> T?) -> T { return conversion("")! } 216 | 217 | @available(*, unavailable, message: "Use Input.readInt() instead") 218 | public static func awaitInt(message: String?) -> Int { return 0 } 219 | 220 | @available(*, unavailable, message: "Use Input.readBool() instead") 221 | public static func awaitYesNoInput(message: String = "Confirm?") -> Bool { return false } 222 | 223 | } 224 | 225 | @available(*, unavailable, renamed: "WritableStream") 226 | public typealias OutputByteStream = WritableStream 227 | 228 | @available(*, unavailable, message: "Use WriteStream.stdout instead") 229 | public class StdoutStream {} 230 | 231 | @available(*, unavailable, message: "Use WriteStream.stderr instead") 232 | public class StderrStream {} 233 | 234 | @available(*, unavailable, message: "Use WriteStream.null instead") 235 | public class NullStream {} 236 | 237 | @available(*, unavailable, renamed: "WriteStream") 238 | public typealias FileStream = WriteStream 239 | 240 | extension WritableStream { 241 | 242 | @available(*, unavailable, renamed: "print") 243 | func output(_ content: String) {} 244 | 245 | @available(*, unavailable, renamed: "print") 246 | public func output(_ content: String, terminator: String) {} 247 | 248 | } 249 | 250 | @available(*, unavailable, message: "use CLI.Error instead") 251 | public enum CLIError: Error { 252 | case error(String) 253 | case emptyError 254 | } 255 | 256 | @available(*, unavailable, message: "Implement HelpMessageGenerator instead") 257 | public protocol UsageStatementGenerator { 258 | func generateUsageStatement(for command: Command) -> String 259 | } 260 | 261 | @available(*, unavailable, renamed: "WriteStream.stderr.print") 262 | public func printError(_ error: String) {} 263 | 264 | @available(*, unavailable, renamed: "WriteStream.stderr.print") 265 | public func printError(_ error: String, terminator: String) {} 266 | 267 | extension WriteStream { 268 | @available(*, unavailable, renamed: "WriteStream.for(path:)") 269 | public init?(path: String) { 270 | return nil 271 | } 272 | 273 | @available(*, unavailable, renamed: "WriteStream.for(fileHandle:)") 274 | public init(writeHandle: FileHandle) { 275 | fatalError() 276 | } 277 | 278 | @available(*, unavailable, renamed: "closeWrite") 279 | public func close() {} 280 | } 281 | 282 | extension WriteStream.FileStream { 283 | @available(*, unavailable, renamed: "closeWrite") 284 | public func close() {} 285 | } 286 | 287 | extension WriteStream.FileHandleStream { 288 | @available(*, unavailable, renamed: "closeWrite") 289 | public func close() {} 290 | } 291 | 292 | extension ReadStream { 293 | @available(*, unavailable, renamed: "ReadStream.for(path:)") 294 | public init?(path: String) { 295 | return nil 296 | } 297 | 298 | @available(*, unavailable, renamed: "ReadStream.for(fileHandle:)") 299 | public init(readHandle: FileHandle) { 300 | fatalError() 301 | } 302 | 303 | @available(*, unavailable, renamed: "closeRead") 304 | public func close() {} 305 | } 306 | 307 | extension ReadStream.FileStream { 308 | @available(*, unavailable, renamed: "closeRead") 309 | public func close() {} 310 | } 311 | 312 | extension ReadStream.FileHandleStream { 313 | @available(*, unavailable, renamed: "closeRead") 314 | public func close() {} 315 | } 316 | 317 | extension LineStream { 318 | @available(*, unavailable, message: "no longer needs to be called if this stream is the stdout or stderr of a Task; otherwise call waitToFinishProcessing()") 319 | public func wait() {} 320 | } 321 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/CompletionGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompletionGenerator.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 9/10/17. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum ShellCompletion { 11 | case none 12 | case filename 13 | case values([(name: String, description: String)]) 14 | case function(String) 15 | } 16 | 17 | public protocol CompletionGenerator { 18 | init(cli: CLI) 19 | init(cli: CLI, functions: [String: String]) 20 | func writeCompletions() 21 | func writeCompletions(into stream: WritableStream) 22 | } 23 | 24 | public final class ZshCompletionGenerator: CompletionGenerator { 25 | 26 | public let cli: CLI 27 | public let functions: [String: String] 28 | 29 | public convenience init(cli: CLI) { 30 | self.init(cli: cli, functions: [:]) 31 | } 32 | 33 | public init(cli: CLI, functions: [String: String]) { 34 | self.cli = cli 35 | self.functions = functions 36 | } 37 | 38 | public func writeCompletions() { 39 | writeCompletions(into: Term.stdout) 40 | } 41 | 42 | public func writeCompletions(into stream: WritableStream) { 43 | stream <<< """ 44 | #compdef \(cli.name) 45 | local context state state_descr line 46 | typeset -A opt_args 47 | 48 | """ 49 | 50 | writeGroup(for: CommandGroupPath(top: cli), into: stream) 51 | 52 | functions.forEach { writeFunction(name: $0.key, body: $0.value, into: stream) } 53 | 54 | stream <<< "_\(cli.name)" 55 | } 56 | 57 | func writeGroup(for group: CommandGroupPath, into stream: WritableStream) { 58 | let name = functionName(for: group) 59 | 60 | let options = genOptionArgs(for: group.bottom).joined(separator: " \\\n") 61 | let commandList = group.bottom.children.map { (child) in 62 | return " \"\(child.name):\(escapeDescription(child.shortDescription))\"" 63 | }.joined(separator: "\n") 64 | 65 | 66 | let subcommands = group.bottom.children.map { (child) -> String in 67 | let indentation = " " 68 | return """ 69 | \(indentation)(\(child.name)) 70 | \(indentation) _\(functionName(for: group.appending(child))) 71 | \(indentation) ;; 72 | """ 73 | }.joined(separator: "\n") 74 | 75 | stream <<< """ 76 | _\(name)() { 77 | _arguments -C \\ 78 | """ 79 | if !options.isEmpty { 80 | stream <<< options + " \\" 81 | } 82 | stream <<< """ 83 | '(-): :->command' \\ 84 | '(-)*:: :->arg' && return 85 | case $state in 86 | (command) 87 | local commands 88 | commands=( 89 | \(commandList) 90 | ) 91 | _describe 'command' commands 92 | ;; 93 | (arg) 94 | case ${words[1]} in 95 | \(subcommands) 96 | esac 97 | ;; 98 | esac 99 | } 100 | """ 101 | 102 | group.bottom.children.forEach { (routable) in 103 | if let subGroup = routable as? CommandGroup { 104 | self.writeGroup(for: group.appending(subGroup), into: stream) 105 | } else if let command = routable as? Command { 106 | self.writeCommand(for: group.appending(command), into: stream) 107 | } 108 | } 109 | } 110 | 111 | func writeCompletion(_ completion: ShellCompletion) -> String { 112 | switch completion { 113 | case .filename: 114 | return "_files" 115 | case .none: 116 | return " " 117 | case .values(let vals): 118 | let valPortion = vals.map { (value) in 119 | var line = "'\(value.name)" 120 | if !value.description.isEmpty { 121 | line += "[\(value.description)]" 122 | } 123 | line += "'" 124 | return line 125 | }.joined(separator: " ") 126 | return "{_values '' \(valPortion)}" 127 | case .function(let function): 128 | return function 129 | } 130 | } 131 | 132 | func writeCommand(for command: CommandPath, into stream: WritableStream) { 133 | let optionArgs = genOptionArgs(for: command.command).joined(separator: " \\\n") 134 | let paramArgs = command.command.parameters.map { (namedParam) -> String in 135 | var line = " \"" 136 | if namedParam.param is AnyCollectedParameter { 137 | line += "*" 138 | } 139 | line += ":" 140 | if !namedParam.param.required { 141 | line += ":" 142 | } 143 | line += "\(namedParam.name):\(writeCompletion(namedParam.param.completion))" 144 | line += "\"" 145 | return line 146 | }.joined(separator: " \\\n") 147 | 148 | let name = functionName(for: command) 149 | stream <<< """ 150 | _\(name)() { 151 | _arguments -C\(optionArgs.isEmpty && paramArgs.isEmpty ? "" : " \\") 152 | """ 153 | if !optionArgs.isEmpty { 154 | stream <<< optionArgs + (paramArgs.isEmpty ? "" : " \\") 155 | } 156 | if !paramArgs.isEmpty { 157 | stream <<< paramArgs 158 | } 159 | stream <<< "}" 160 | } 161 | 162 | func writeFunction(name: String, body: String, into stream: WritableStream) { 163 | let lines = body.components(separatedBy: "\n").map { " " + $0 }.joined(separator: "\n") 164 | stream <<< """ 165 | \(name)() { 166 | \(lines) 167 | } 168 | """ 169 | } 170 | 171 | // MARK: - Helpers 172 | 173 | enum OptionWritingMode { 174 | case normal 175 | case additionalExclusive([String]) 176 | case variadic 177 | } 178 | 179 | private func genOptionLine(names: [String], mode: OptionWritingMode, description: String, completion: ShellCompletion?) -> String { 180 | precondition(names.count > 0) 181 | 182 | var line = " " 183 | 184 | let mutuallyExclusive: [String] 185 | switch mode { 186 | case .normal: 187 | mutuallyExclusive = names.count > 1 ? names : [] 188 | case .additionalExclusive(let exclusive): mutuallyExclusive = exclusive 189 | case .variadic: 190 | precondition(names.count == 1) 191 | mutuallyExclusive = [] 192 | } 193 | 194 | if !mutuallyExclusive.isEmpty { 195 | line += "'(\(mutuallyExclusive.joined(separator: " ")))'" 196 | } 197 | 198 | if names.count > 1 { 199 | line += "{\(names.joined(separator: ","))}\"[" 200 | } else { 201 | line += "\"" 202 | if case .variadic = mode { 203 | line += "*" 204 | } 205 | line += "\(names[0])[" 206 | } 207 | 208 | line += escapeDescription(description) + "]" 209 | 210 | if let completion = completion { 211 | line += ": :\(writeCompletion(completion))" 212 | } 213 | 214 | line += "\"" 215 | return line 216 | } 217 | 218 | private func genOptionArgs(for routable: Routable) -> [String] { 219 | let lines = routable.options.map { (option) -> [String] in 220 | let completion: ShellCompletion? 221 | if let key = option as? AnyKey { 222 | completion = key.completion 223 | } else { 224 | completion = nil 225 | } 226 | 227 | if option.variadic { 228 | return option.names.map { (name) in 229 | return genOptionLine(names: [name], mode: .variadic, description: option.shortDescription, completion: completion) 230 | } 231 | } 232 | for group in routable.optionGroups where group.restriction != .atLeastOne { 233 | if group.options.contains(where: { $0 === option }) { 234 | let exclusive = Array(group.options.map({ $0.names }).joined()) 235 | return [genOptionLine(names: option.names, mode: .additionalExclusive(exclusive), description: option.shortDescription, completion: completion)] 236 | } 237 | } 238 | return [genOptionLine(names: option.names, mode: .normal, description: option.shortDescription, completion: completion)] 239 | } 240 | return Array(lines.joined()) 241 | } 242 | 243 | private func escapeDescription(_ description: String) -> String { 244 | return description.replacingOccurrences(of: "\"", with: "\\\"") 245 | } 246 | 247 | private func functionName(for routable: RoutablePath) -> String { 248 | return routable.joined(separator: "_") 249 | } 250 | 251 | } 252 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/Error.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Error.swift 3 | // SwiftCLIPackageDescription 4 | // 5 | // Created by Jake Heiser on 8/30/17. 6 | // 7 | 8 | public protocol ProcessError: Swift.Error { 9 | var message: String? { get } 10 | var exitStatus: Int32 { get } 11 | } 12 | 13 | extension CLI { 14 | 15 | public struct Error: ProcessError { 16 | 17 | public let message: String? 18 | public let exitStatus: Int32 19 | 20 | public init() { 21 | self.init(exitStatus: 1) 22 | } 23 | 24 | public init(exitStatus: T) { 25 | self.init(message: nil, exitStatus: Int32(exitStatus)) 26 | } 27 | 28 | public init(message: String) { 29 | self.init(message: message, exitStatus: 1) 30 | } 31 | 32 | public init(message: String?, exitStatus: Int32) { 33 | self.message = message 34 | self.exitStatus = exitStatus 35 | } 36 | 37 | } 38 | 39 | } 40 | 41 | // MARK: - Parse errors 42 | 43 | public struct RouteError: Swift.Error { 44 | public let partialPath: CommandGroupPath 45 | public let notFound: String? 46 | 47 | public init(partialPath: CommandGroupPath, notFound: String?) { 48 | self.partialPath = partialPath 49 | self.notFound = notFound 50 | } 51 | } 52 | 53 | public struct OptionError: Swift.Error { 54 | 55 | public enum Kind { 56 | case expectedValueAfterKey(String) 57 | case unrecognizedOption(String) 58 | case optionGroupMisuse(OptionGroup) 59 | case invalidKeyValue(AnyKey, String, InvalidValueReason) 60 | case unexpectedValueAfterFlag(String) 61 | 62 | public var message: String { 63 | switch self { 64 | case let .expectedValueAfterKey(key): 65 | return "expected a value to follow '\(key)'" 66 | case let .unrecognizedOption(opt): 67 | return "unrecognized option '\(opt)'" 68 | case let .optionGroupMisuse(group): 69 | let condition: String 70 | if group.options.count == 1 { 71 | condition = "must pass the following option" 72 | } else { 73 | switch group.restriction { 74 | case .exactlyOne: 75 | condition = "must pass exactly one of the following" 76 | case .atLeastOne: 77 | condition = "must pass at least one of the following" 78 | case .atMostOne: 79 | condition = "must not pass more than one of the following" 80 | } 81 | } 82 | return condition + ": \(group.options.compactMap({ $0.names.last }).joined(separator: " "))" 83 | case let .invalidKeyValue(key, id, reason): 84 | return key.valueType.messageForInvalidValue(reason: reason, for: id) 85 | case let .unexpectedValueAfterFlag(flag): 86 | return "unexpected value following flag '\(flag)'" 87 | } 88 | } 89 | } 90 | 91 | public let command: CommandPath? 92 | public let kind: Kind 93 | 94 | public init(command: CommandPath?, kind: Kind) { 95 | self.command = command 96 | self.kind = kind 97 | } 98 | } 99 | 100 | public struct ParameterError: Swift.Error { 101 | 102 | public enum Kind { 103 | case wrongNumber(Int, Int?) 104 | case invalidValue(NamedParameter, InvalidValueReason) 105 | 106 | public var message: String { 107 | switch self { 108 | case let .wrongNumber(min, max): 109 | let plural = min == 1 ? "argument" : "arguments" 110 | switch max { 111 | case .none: 112 | return "command requires at least \(min) \(plural)" 113 | case let .some(max) where max == min: 114 | return "command requires exactly \(max) \(plural)" 115 | case let .some(max): 116 | return "command requires between \(min) and \(max) arguments" 117 | } 118 | case let .invalidValue(param, reason): 119 | return param.param.valueType.messageForInvalidValue(reason: reason, for: param.name) 120 | } 121 | } 122 | } 123 | 124 | public let command: CommandPath 125 | public let kind: Kind 126 | 127 | public init(command: CommandPath, kind: Kind) { 128 | self.command = command 129 | self.kind = kind 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/HelpCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HelpCommand.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 7/25/14. 6 | // Copyright (c) 2014 jakeheis. All rights reserved. 7 | // 8 | 9 | public class HelpCommand: Command { 10 | 11 | public let name = "help" 12 | public let shortDescription = "Prints help information" 13 | 14 | @CollectedParam(completion: .none) 15 | var command: [String] 16 | 17 | let cli: CLI 18 | 19 | init(cli: CLI) { 20 | self.cli = cli 21 | } 22 | 23 | public func execute() throws { 24 | var path = CommandGroupPath(top: cli) 25 | 26 | for pathSegment in command { 27 | let child = path.bottom.children.first(where: { $0.name == pathSegment }) 28 | if let group = child as? CommandGroup { 29 | path = path.appending(group) 30 | } else if let command = child as? Command { 31 | cli.helpMessageGenerator.writeUsageStatement(for: path.appending(command), to: stdout) 32 | return 33 | } else { 34 | break 35 | } 36 | } 37 | 38 | cli.helpMessageGenerator.writeCommandList(for: path, to: stdout) 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/HelpMessageGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HelpMessageGenerator.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 6/13/15. 6 | // Copyright © 2015 jakeheis. All rights reserved. 7 | // 8 | 9 | public protocol HelpMessageGenerator { 10 | func writeCommandList(for path: CommandGroupPath, to out: WritableStream) 11 | func writeUsageStatement(for path: CommandPath, to out: WritableStream) 12 | func writeRouteErrorMessage(for error: RouteError, to out: WritableStream) 13 | func writeMisusedOptionsStatement(for error: OptionError, to out: WritableStream) 14 | func writeParameterErrorMessage(for error: ParameterError, to out: WritableStream) 15 | func writeUnrecognizedErrorMessage(for error: Error, to out: WritableStream) 16 | func writeErrorLine(for errorMessage: String, to out: WritableStream) 17 | } 18 | 19 | extension HelpMessageGenerator { 20 | 21 | public func writeCommandList(for path: CommandGroupPath, to out: WritableStream) { 22 | out <<< "" 23 | out <<< "Usage: \(path.joined()) [options]" 24 | 25 | let bottom = path.bottom 26 | if !bottom.shortDescription.isEmpty { 27 | out <<< "" 28 | out <<< bottom.shortDescription 29 | } 30 | 31 | var commandGroups: [CommandGroup] = [] 32 | var commands: [Command] = [] 33 | var maxNameLength = 12 34 | for routable in bottom.children { 35 | if let commandGroup = routable as? CommandGroup { 36 | commandGroups.append(commandGroup) 37 | } else if let command = routable as? Command { 38 | commands.append(command) 39 | } 40 | if routable.name.count > maxNameLength { 41 | maxNameLength = routable.name.count 42 | } 43 | } 44 | 45 | func write(_ routable: Routable) { 46 | let spacing = String(repeating: " ", count: maxNameLength + 4 - routable.name.count) 47 | let multilineSpacing = String(repeating: " ", count: maxNameLength + 4 + 2) 48 | let description = routable.shortDescription.replacingOccurrences(of: "\n", with: "\n\(multilineSpacing)") 49 | out <<< " \(routable.name)\(spacing)\(description)" 50 | } 51 | 52 | if !commandGroups.isEmpty { 53 | out <<< "" 54 | out <<< "Groups:" 55 | commandGroups.forEach(write) 56 | } 57 | 58 | if !commands.isEmpty { 59 | out <<< "" 60 | out <<< "Commands:" 61 | commands.forEach(write) 62 | } 63 | out <<< "" 64 | } 65 | 66 | public func writeUsageStatement(for path: CommandPath, to out: WritableStream) { 67 | out <<< "" 68 | 69 | out <<< "Usage: \(path.usage)" 70 | 71 | if !path.command.longDescription.isEmpty { 72 | out <<< "" 73 | out <<< path.command.longDescription 74 | } else if !path.command.shortDescription.isEmpty { 75 | out <<< "" 76 | out <<< path.command.shortDescription 77 | } 78 | 79 | let options = path.options 80 | if !options.isEmpty { 81 | out <<< "" 82 | out <<< "Options:" 83 | let sortedOptions = options.sorted { $0.names.last!.lowercased() < $1.names.last!.lowercased() } 84 | let maxOptionLength = sortedOptions.reduce(12) { max($0, $1.indentedIdentifierLength) } 85 | sortedOptions.forEach { (option) in 86 | let usage = option.usage(padding: maxOptionLength + 4) 87 | out <<< " \(usage)".replacingOccurrences(of: "\n", with: "\n ") 88 | } 89 | } 90 | out <<< "" 91 | } 92 | 93 | public func writeRouteErrorMessage(for error: RouteError, to out: WritableStream) { 94 | writeCommandList(for: error.partialPath, to: out) 95 | if let notFound = error.notFound { 96 | writeErrorLine(for: "command '\(notFound)' not found", to: out) 97 | out <<< "" 98 | } 99 | } 100 | 101 | public func writeMisusedOptionsStatement(for error: OptionError, to out: WritableStream) { 102 | if let command = error.command { 103 | writeUsageStatement(for: command, to: out) 104 | } else { 105 | out <<< "" 106 | } 107 | writeErrorLine(for: error.kind.message, to: out) 108 | out <<< "" 109 | } 110 | 111 | public func writeParameterErrorMessage(for error: ParameterError, to out: WritableStream) { 112 | writeUsageStatement(for: error.command, to: out) 113 | writeErrorLine(for: error.kind.message, to: out) 114 | out <<< "" 115 | } 116 | 117 | public func writeUnrecognizedErrorMessage(for error: Error, to out: WritableStream) { 118 | writeErrorLine(for: error.localizedDescription, to: out) 119 | } 120 | 121 | public func writeErrorLine(for errorMessage: String, to out: WritableStream) { 122 | out <<< "Error: " + errorMessage 123 | } 124 | 125 | } 126 | 127 | public class DefaultHelpMessageGenerator: HelpMessageGenerator { 128 | 129 | private enum Colors { 130 | static let escape = "\u{001B}[" 131 | static let none = escape + "0m" 132 | static let red = escape + "31m" 133 | static let bold = escape + "1m" 134 | } 135 | 136 | public let colorError: Bool 137 | public let boldError: Bool 138 | 139 | public init(colorError: Bool = false, boldError: Bool = false) { 140 | self.colorError = colorError 141 | self.boldError = boldError 142 | } 143 | 144 | public func writeErrorLine(for errorMessage: String, to out: WritableStream) { 145 | var errorWord = "" 146 | if boldError { 147 | errorWord += Colors.bold 148 | } 149 | if colorError { 150 | errorWord += Colors.red 151 | } 152 | 153 | errorWord += "Error: " 154 | if colorError || boldError { 155 | errorWord += Colors.none 156 | } 157 | out <<< errorWord + errorMessage 158 | } 159 | 160 | } 161 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/Input.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Input.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 8/17/14. 6 | // Copyright (c) 2014 jakeheis. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum Input { 12 | 13 | /// Reads a line of input 14 | /// 15 | /// - Parameters: 16 | /// - prompt: prompt to be printed before accepting input (e.g. "Name: ") 17 | /// - secure: boolean defining that input should be hidden 18 | /// - validation: predicate defining whether the given input is valid 19 | /// - errorResponse: what to do if the input is invalid; default prints "Invalid input" 20 | /// - Returns: input 21 | public static func readLine(prompt: String? = nil, secure: Bool = false, validation: [Validation] = [], errorResponse: InputReader.ErrorResponse? = nil) -> String { 22 | return readObject(prompt: prompt, secure: secure, validation: validation, errorResponse: errorResponse) 23 | } 24 | 25 | /// Reads a line of input from stdin 26 | /// 27 | /// - Parameters: 28 | /// - prompt: prompt to be printed before accepting input (e.g. "Name: ") 29 | /// - defaultValue: value to fallback to for empty input; default is nil, meaning an error is shown 30 | /// - secure: boolean defining that input should be hidden 31 | /// - validation: predicate defining whether the given input is valid 32 | /// - errorResponse: what to do if the input is invalid; default prints "Invalid input" 33 | /// - Returns: input 34 | public static func readInt(prompt: String? = nil, defaultValue: Int? = nil, secure: Bool = false, validation: [Validation] = [], errorResponse: InputReader.ErrorResponse? = nil) -> Int { 35 | return readObject(prompt: prompt, defaultValue: defaultValue, secure: secure, validation: validation, errorResponse: errorResponse) 36 | } 37 | 38 | /// Reads a double from stdin 39 | /// 40 | /// - Parameters: 41 | /// - prompt: prompt to be printed before accepting input (e.g. "Name: ") 42 | /// - defaultValue: value to fallback to for empty input; default is nil, meaning an error is shown 43 | /// - secure: boolean defining that input should be hidden 44 | /// - validation: predicate defining whether the given input is valid 45 | /// - errorResponse: what to do if the input is invalid; default prints "Invalid input" 46 | /// - Returns: input 47 | public static func readDouble(prompt: String? = nil, defaultValue: Double? = nil, secure: Bool = false, validation: [Validation] = [], errorResponse: InputReader.ErrorResponse? = nil) -> Double { 48 | return readObject(prompt: prompt, defaultValue: defaultValue, secure: secure, validation: validation, errorResponse: errorResponse) 49 | } 50 | 51 | /// Reads a bool from stdin. "y", "yes", "t", and "true" are accepted as truthy values 52 | /// 53 | /// - Parameters: 54 | /// - prompt: prompt to be printed before accepting input (e.g. "Name: ") 55 | /// - defaultValue: value to fallback to for empty input; default is nil, meaning an error is shown 56 | /// - secure: boolean defining that input should be hidden 57 | /// - validation: predicate defining whether the given input is valid 58 | /// - errorResponse: what to do if the input is invalid; default prints "Invalid input" 59 | /// - Returns: input 60 | public static func readBool(prompt: String? = nil, defaultValue: Bool? = nil, secure: Bool = false, validation: [Validation] = [], errorResponse: InputReader.ErrorResponse? = nil) -> Bool { 61 | return readObject(prompt: prompt, defaultValue: defaultValue, secure: secure, validation: validation, errorResponse: errorResponse) 62 | } 63 | 64 | /// Reads an object which conforms to ConvertibleFromString from stdin 65 | /// 66 | /// - Parameters: 67 | /// - prompt: prompt to be printed before accepting input (e.g. "Name: ") 68 | /// - defaultValue: value to fallback to for empty input; default is nil, meaning an error is shown 69 | /// - secure: boolean defining that input should be hidden 70 | /// - validation: predicate defining whether the given input is valid 71 | /// - errorResponse: what to do if the input is invalid; default prints "Invalid input" 72 | /// - Returns: input 73 | public static func readObject(prompt: String? = nil, defaultValue: T? = nil, secure: Bool = false, validation: [Validation] = [], errorResponse: InputReader.ErrorResponse? = nil) -> T { 74 | return InputReader(prompt: prompt, defaultValue: defaultValue, secure: secure, validation: validation, errorResponse: errorResponse).read() 75 | } 76 | 77 | } 78 | 79 | // MARK: InputReader 80 | 81 | public class InputReader { 82 | 83 | public typealias ErrorResponse = (_ input: String, _ resaon: InvalidValueReason) -> () 84 | 85 | public let prompt: String? 86 | public let defaultValue: T? 87 | public let secure: Bool 88 | public let validation: [SwiftCLI.Validation] 89 | public let errorResponse: ErrorResponse 90 | 91 | public init(prompt: String?, defaultValue: T?, secure: Bool, validation: [SwiftCLI.Validation], errorResponse: ErrorResponse?) { 92 | self.prompt = prompt 93 | self.defaultValue = defaultValue 94 | self.secure = secure 95 | self.validation = validation 96 | self.errorResponse = errorResponse ?? { (_, reason) in 97 | let message = T.messageForInvalidValue(reason: reason, for: nil) 98 | Term.stderr <<< String(message[message.startIndex]).capitalized + message[message.index(after: message.startIndex)...] 99 | } 100 | } 101 | 102 | public func read() -> T { 103 | while true { 104 | printPrompt() 105 | 106 | var possibleInput: String? = nil 107 | if secure { 108 | if let chars = UnsafePointer(getpass("")) { 109 | possibleInput = String(cString: chars, encoding: .utf8) 110 | } 111 | } else { 112 | possibleInput = Term.read() 113 | } 114 | 115 | guard let input = possibleInput else { 116 | // Eof reached; no way forward 117 | exit(1) 118 | } 119 | 120 | guard let converted = convert(input: input) else { 121 | errorResponse(input, .conversionError) 122 | continue 123 | } 124 | 125 | if let failedValidation = validation.first(where: { $0.validate(converted) == false }) { 126 | errorResponse(input, .validationError(failedValidation)) 127 | continue 128 | } 129 | 130 | return converted 131 | } 132 | } 133 | 134 | private func convert(input: String) -> T? { 135 | if let defaultValue = defaultValue, input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { 136 | return defaultValue 137 | } 138 | return T(input: input) 139 | } 140 | 141 | private func printPrompt() { 142 | if var prompt = prompt { 143 | if !prompt.hasSuffix(" ") && !prompt.hasSuffix("\n") { 144 | prompt += " " 145 | } 146 | Term.stdout.write(prompt) 147 | fflush(Foundation.stdout) 148 | } 149 | } 150 | 151 | } 152 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/Option.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Option.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 3/28/17. 6 | // Copyright © 2017 jakeheis. All rights reserved. 7 | // 8 | 9 | public protocol Option: AnyObject, CustomStringConvertible { 10 | var names: [String] { get } 11 | var shortDescription: String { get } 12 | var identifier: String { get } 13 | var variadic: Bool { get } 14 | } 15 | 16 | extension Option { 17 | 18 | public var description: String { 19 | return "\(type(of: self))(\(identifier))" 20 | } 21 | 22 | public var indentedIdentifierLength: Int { 23 | if names.count == 1 && names[0].hasPrefix("--") { // no one letter shortcut; indent 24 | return identifier.count + 4 25 | } 26 | return identifier.count 27 | } 28 | 29 | public func usage(padding: Int) -> String { 30 | var id = identifier 31 | if names.count == 1 && names[0].hasPrefix("--") { // no one letter shortcut; indent 32 | id = " " + id 33 | } 34 | let spacing = String(repeating: " ", count: padding - id.count) 35 | let descriptionNewlineSpacing = String(repeating: " ", count: padding) 36 | let description = shortDescription.replacingOccurrences(of: "\n", with: "\n\(descriptionNewlineSpacing)") 37 | return "\(id)\(spacing)\(description)" 38 | } 39 | 40 | } 41 | 42 | // MARK: - Flags 43 | 44 | public protocol AnyFlag: Option { 45 | func update() 46 | } 47 | 48 | @propertyWrapper 49 | public class Flag: AnyFlag { 50 | 51 | public let names: [String] 52 | public let shortDescription: String 53 | public let variadic = false 54 | 55 | public private(set) var wrappedValue = false 56 | public var value: Bool { wrappedValue } 57 | public var projectedValue: Flag { self } 58 | 59 | public var identifier: String { 60 | return names.joined(separator: ", ") 61 | } 62 | 63 | /// Creates a new flag 64 | /// 65 | /// - Parameters: 66 | /// - names: the names for the flag; convention is to include a short name (-a) and a long name (--all) 67 | /// - description: A short description of what this flag does for usage statements 68 | public init(_ names: String..., description: String = "") { 69 | self.names = names.sorted(by: { $0.count < $1.count }) 70 | self.shortDescription = description 71 | } 72 | 73 | /// Toggles the flag's value; don't call directly 74 | public func update() { 75 | wrappedValue = true 76 | } 77 | 78 | } 79 | 80 | @propertyWrapper 81 | public class CounterFlag: AnyFlag { 82 | 83 | public let names: [String] 84 | public let shortDescription: String 85 | public let variadic = true 86 | 87 | public private(set) var wrappedValue: Int = 0 88 | public var value: Int { wrappedValue } 89 | public var projectedValue: CounterFlag { self } 90 | 91 | public var identifier: String { 92 | return names.joined(separator: ", ") 93 | } 94 | 95 | /// Creates a new counter flag 96 | /// 97 | /// - Parameters: 98 | /// - names: the names for the flag; convention is to include a short name (-a) and a long name (--all) 99 | /// - description: A short description of what this flag does for usage statements 100 | public init(_ names: String ..., description: String = "") { 101 | self.names = names.sorted(by: { $0.count < $1.count }) 102 | self.shortDescription = description 103 | } 104 | 105 | /// Increments the flag's value; don't call directly 106 | public func update() { 107 | wrappedValue += 1 108 | } 109 | 110 | } 111 | 112 | // MARK: - Keys 113 | 114 | public protocol AnyKey: Option, AnyValueBox {} 115 | 116 | public class _Key { 117 | 118 | public let names: [String] 119 | public let shortDescription: String 120 | public let completion: ShellCompletion 121 | public let validation: [Validation] 122 | 123 | public var identifier: String { 124 | return names.joined(separator: ", ") + " " 125 | } 126 | 127 | /// Creates a new key 128 | /// 129 | /// - Parameters: 130 | /// - names: the names for the key; convention is to include a short name (-m) and a long name (--message) 131 | /// - description: A short description of what this key does for usage statements 132 | public init(names: [String], description: String, completion: ShellCompletion, validation: [Validation] = []) { 133 | self.names = names.sorted(by: { $0.count < $1.count }) 134 | self.shortDescription = description 135 | self.completion = completion 136 | self.validation = validation 137 | } 138 | 139 | } 140 | 141 | @propertyWrapper 142 | public class Key: _Key, AnyKey, ValueBox { 143 | 144 | public let variadic = false 145 | 146 | public private(set) var wrappedValue: Value? 147 | public var value: Value? { wrappedValue } 148 | public var projectedValue: Key { self } 149 | 150 | public init(_ names: String ..., description: String = "", completion: ShellCompletion = .filename, validation: [Validation] = []) { 151 | super.init(names: names, description: description, completion: completion, validation: validation) 152 | } 153 | 154 | public func update(to value: Value) { 155 | self.wrappedValue = value 156 | } 157 | 158 | } 159 | 160 | @propertyWrapper 161 | public class VariadicKey: _Key, AnyKey, ValueBox { 162 | 163 | public let variadic = true 164 | 165 | public private(set) var wrappedValue: [Value] = [] 166 | public var value: [Value] { wrappedValue } 167 | public var projectedValue: VariadicKey { self } 168 | 169 | public init(_ names: String ..., description: String = "", completion: ShellCompletion = .filename, validation: [Validation] = []) { 170 | super.init(names: names, description: description, completion: completion, validation: validation) 171 | } 172 | 173 | public func update(to value: Value) { 174 | self.wrappedValue.append(value) 175 | } 176 | 177 | } 178 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/OptionGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OptionGroup.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 3/31/17. 6 | // Copyright © 2017 jakeheis. All rights reserved. 7 | // 8 | 9 | public class OptionGroup: CustomStringConvertible { 10 | 11 | public enum Restriction { 12 | case atMostOne // 0 or 1 13 | case exactlyOne // 1 14 | case atLeastOne // 1 or more 15 | } 16 | 17 | public static func atMostOne(_ options: Option...) -> OptionGroup { 18 | return .atMostOne(options) 19 | } 20 | 21 | public static func atMostOne(_ options: [Option]) -> OptionGroup { 22 | return .init(options: options, restriction: .atMostOne) 23 | } 24 | 25 | public static func exactlyOne(_ options: Option...) -> OptionGroup { 26 | return .exactlyOne(options) 27 | } 28 | 29 | public static func exactlyOne(_ options: [Option]) -> OptionGroup { 30 | return .init(options: options, restriction: .exactlyOne) 31 | } 32 | 33 | public static func atLeastOne(_ options: Option...) -> OptionGroup { 34 | return .atLeastOne(options) 35 | } 36 | 37 | public static func atLeastOne(_ options: [Option]) -> OptionGroup { 38 | return .init(options: options, restriction: .atLeastOne) 39 | } 40 | 41 | public let options: [Option] 42 | public let restriction: Restriction 43 | internal(set) public var count: Int = 0 44 | 45 | public var description: String { 46 | return "OptionGroup.\(restriction)(\(options))" 47 | } 48 | 49 | public init(options: [Option], restriction: Restriction) { 50 | precondition(!options.isEmpty, "must pass one or more options") 51 | if options.count == 1 { 52 | precondition(restriction == .exactlyOne, "cannot use atMostOne or atLeastOne when passing one option") 53 | } 54 | 55 | self.options = options 56 | self.restriction = restriction 57 | } 58 | 59 | public func check() -> Bool { 60 | if count == 0 && restriction != .atMostOne { 61 | return false 62 | } 63 | if count > 1 && restriction != .atLeastOne { 64 | return false 65 | } 66 | return true 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/OptionRegistry.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Options.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 7/11/14. 6 | // Copyright (c) 2014 jakeheis. All rights reserved. 7 | // 8 | 9 | public class OptionRegistry { 10 | 11 | private var flags: [String: AnyFlag] 12 | private var keys: [String: AnyKey] 13 | public private(set) var groups: [OptionGroup] 14 | 15 | public init(routable: Routable) { 16 | self.flags = [:] 17 | self.keys = [:] 18 | self.groups = [] 19 | 20 | register(routable) 21 | } 22 | 23 | public func register(_ routable: Routable) { 24 | for option in routable.options { 25 | if let flag = option as? AnyFlag { 26 | for name in flag.names { 27 | flags[name] = flag 28 | } 29 | } else if let key = option as? AnyKey { 30 | for name in key.names { 31 | keys[name] = key 32 | } 33 | } 34 | } 35 | 36 | groups += routable.optionGroups 37 | } 38 | 39 | public func recognizesOption(_ opt: String) -> Bool { 40 | return flags[opt] != nil || keys[opt] != nil 41 | } 42 | 43 | // MARK: - Helpers 44 | 45 | public func flag(for key: String) -> AnyFlag? { 46 | if let flag = flags[key] { 47 | incrementCount(for: flag) 48 | return flag 49 | } 50 | return nil 51 | } 52 | 53 | public func key(for key: String) -> AnyKey? { 54 | if let key = keys[key] { 55 | incrementCount(for: key) 56 | return key 57 | } 58 | return nil 59 | } 60 | 61 | private func incrementCount(for option: Option) { 62 | for group in groups { 63 | if group.options.contains(where: { $0 === option }) { 64 | group.count += 1 65 | } 66 | } 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/Parameter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Parameter.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 3/7/15. 6 | // Copyright (c) 2015 jakeheis. All rights reserved. 7 | // 8 | 9 | public protocol AnyParameter: AnyValueBox { 10 | var required: Bool { get } 11 | var satisfied: Bool { get } 12 | } 13 | 14 | public class _Param { 15 | 16 | public let completion: ShellCompletion 17 | public let validation: [Validation] 18 | 19 | /// Creates a new parameter 20 | /// 21 | /// - Parameter completion: the completion type for use in ZshCompletionGenerator; default .filename 22 | fileprivate init(designatedCompletion: ShellCompletion = .filename, validation: [Validation] = []) { 23 | self.completion = designatedCompletion 24 | self.validation = validation 25 | } 26 | 27 | } 28 | 29 | @propertyWrapper 30 | public class Param : _Param, AnyParameter, ValueBox { 31 | 32 | public private(set) var required = true 33 | public var satisfied: Bool { privValue != nil } 34 | 35 | private var privValue: Value? 36 | public var wrappedValue: Value { 37 | guard let val = privValue else { 38 | fatalError("cannot access parameter value outside of 'execute' func") 39 | } 40 | return val 41 | } 42 | public var value: Value { wrappedValue } 43 | public var projectedValue: Param { self } 44 | 45 | fileprivate override init(designatedCompletion: ShellCompletion, validation: [Validation]) { 46 | super.init(designatedCompletion: designatedCompletion, validation: validation) 47 | } 48 | 49 | public init() { 50 | super.init() 51 | } 52 | 53 | public init(completion: ShellCompletion = .filename, validation: Validation...) { 54 | super.init(designatedCompletion: completion, validation: validation) 55 | } 56 | 57 | public func update(to value: Value) { 58 | self.privValue = value 59 | } 60 | 61 | } 62 | 63 | extension Param where Value: OptionType { 64 | 65 | public convenience init() { 66 | self.init(designatedCompletion: .filename, validation: []) 67 | setup() 68 | } 69 | 70 | public convenience init(completion: ShellCompletion) { 71 | self.init(designatedCompletion: completion, validation: []) 72 | setup() 73 | } 74 | 75 | public convenience init(completion: ShellCompletion = .filename, validation: Validation...) { 76 | let optValidations = validation.map { (valueValidation) in 77 | return Validation.custom(valueValidation.message) { (option) in 78 | if let value = option.swiftcli_Value { 79 | return valueValidation.validate(value) 80 | } 81 | return false 82 | } 83 | } 84 | self.init(designatedCompletion: completion, validation: optValidations) 85 | setup() 86 | } 87 | 88 | private func setup() { 89 | privValue = .some(.swiftcli_Empty) 90 | required = false 91 | } 92 | 93 | } 94 | 95 | public protocol AnyCollectedParameter: AnyParameter { 96 | var minCount: Int { get } 97 | } 98 | 99 | @propertyWrapper 100 | public class CollectedParam : _Param, AnyCollectedParameter, ValueBox { 101 | 102 | public var required: Bool { minCount > 0 } 103 | public var satisfied: Bool { value.count >= minCount } 104 | 105 | public private(set) var wrappedValue: [Value] = [] 106 | public var value: [Value] { wrappedValue } 107 | public var projectedValue: CollectedParam { 108 | return self 109 | } 110 | 111 | public let minCount: Int 112 | 113 | public init() { 114 | self.minCount = 0 115 | super.init() 116 | } 117 | 118 | public init(minCount: Int = 0, completion: ShellCompletion = .filename, validation: [Validation] = []) { 119 | self.minCount = minCount 120 | super.init(designatedCompletion: completion, validation: validation) 121 | } 122 | 123 | public init(minCount: Int = 0, completion: ShellCompletion = .filename, validation: Validation...) { 124 | self.minCount = minCount 125 | super.init(designatedCompletion: completion, validation: validation) 126 | } 127 | 128 | public func update(to value: Value) { 129 | self.wrappedValue.append(value) 130 | } 131 | 132 | } 133 | 134 | // MARK: - NamedParameter 135 | 136 | public struct NamedParameter { 137 | public let name: String 138 | public let param: AnyParameter 139 | 140 | public var signature: String { 141 | var sig = "<\(name)>" 142 | if param.required == false { 143 | sig = "[\(sig)]" 144 | } 145 | if param is AnyCollectedParameter { 146 | sig += " ..." 147 | } 148 | return sig 149 | } 150 | 151 | public init(name: String, param: AnyParameter) { 152 | self.name = name 153 | self.param = param 154 | } 155 | } 156 | 157 | // MARK: - ParameterIterator 158 | 159 | public class ParameterIterator { 160 | 161 | private var params: [NamedParameter] 162 | private let collected: NamedParameter? 163 | 164 | public let minCount: Int 165 | public let maxCount: Int? 166 | 167 | public init(command: Command) { 168 | var all = command.parameters 169 | 170 | assert(all.firstIndex(where: { !$0.param.required }) ?? all.endIndex >= all.filter({ $0.param.required }).count, "optional parameters must come after all required parameters") 171 | 172 | var minCount = 0 173 | 174 | if let last = all.last, let collected = last.param as? AnyCollectedParameter { 175 | all.removeLast() 176 | assert(!all.contains(where: { $0.param is AnyCollectedParameter }), "can only have one collected parameter") 177 | 178 | self.collected = last 179 | self.maxCount = nil 180 | minCount = collected.minCount 181 | } else { 182 | assert(!all.contains(where: { $0.param is AnyCollectedParameter }), "the collected parameter must be the last parameter") 183 | self.collected = nil 184 | self.maxCount = all.count 185 | } 186 | 187 | self.minCount = all.filter({ $0.param.required }).count + minCount 188 | self.params = all 189 | } 190 | 191 | public func nextIsCollection() -> Bool { 192 | return params.isEmpty && collected != nil 193 | } 194 | 195 | public func next() -> NamedParameter? { 196 | if let individual = params.first { 197 | params.removeFirst() 198 | return individual 199 | } 200 | return collected 201 | } 202 | 203 | } 204 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/Parser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Parser.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 6/28/16. 6 | // Copyright (c) 2016 jakeheis. All rights reserved. 7 | // 8 | 9 | // MARK: - Parser 10 | 11 | @dynamicMemberLookup 12 | public struct Parser { 13 | 14 | public struct Configuration { 15 | public enum RouteBehavior { 16 | case search 17 | case searchWithFallback(Command) 18 | case automatically(Command) 19 | } 20 | 21 | /// The route behavior to use to find the specified command; default .search 22 | public var routeBehavior: RouteBehavior = .search 23 | 24 | /// Continue parsing options after a collected parameter is encountered; default false 25 | public var parseOptionsAfterCollectedParameter = false 26 | 27 | /// Split options joined in one argument, e.g. split '-am' into '-a' and '-m'; default true 28 | public var splitJoinedOptions = true 29 | 30 | public var fallback: Command? { 31 | switch routeBehavior { 32 | case .search: return nil 33 | case .searchWithFallback(let cmd): return cmd 34 | case .automatically(let cmd): return cmd 35 | } 36 | } 37 | } 38 | 39 | public struct State { 40 | 41 | public enum RouteState { 42 | case routing(CommandGroupPath) 43 | case routed(CommandPath, ParameterIterator) 44 | } 45 | 46 | public var routeState: RouteState 47 | public let optionRegistry: OptionRegistry 48 | public let configuration: Configuration 49 | 50 | public var command: CommandPath? { 51 | if case let .routed(path, _) = routeState { 52 | return path 53 | } 54 | return nil 55 | } 56 | 57 | public mutating func appendToPath(_ group: CommandGroup) { 58 | guard case let .routing(current) = routeState else { 59 | assertionFailure() 60 | return 61 | } 62 | 63 | optionRegistry.register(group) 64 | routeState = .routing(current.appending(group)) 65 | } 66 | 67 | public mutating func appendToPath(_ cmd: Command, ignoreName: Bool = false) { 68 | guard case let .routing(current) = routeState else { 69 | assertionFailure() 70 | return 71 | } 72 | 73 | optionRegistry.register(cmd) 74 | var commandPath = current.appending(cmd) 75 | commandPath.ignoreName = ignoreName 76 | routeState = .routed(commandPath, ParameterIterator(command: cmd)) 77 | } 78 | 79 | } 80 | 81 | public var responders: [ParserResponse] = [AliasResponse(), OptionResponse(), RouteResponse(), ParameterResponse()] 82 | private var configuration = Configuration() 83 | 84 | public init() {} 85 | 86 | public subscript(dynamicMember keyPath: WritableKeyPath) -> U { 87 | get { configuration[keyPath: keyPath] } 88 | set { configuration[keyPath: keyPath] = newValue } 89 | } 90 | 91 | public func parse(cli: CLI, arguments: ArgumentList) throws -> CommandPath { 92 | var state = Parser.State( 93 | routeState: .routing(CommandGroupPath(top: cli)), 94 | optionRegistry: OptionRegistry(routable: cli), 95 | configuration: configuration 96 | ) 97 | 98 | while arguments.hasNext() { 99 | if let responder = responders.first(where: { $0.canRespond(to: arguments, state: state) }) { 100 | state = try responder.respond(to: arguments, state: state) 101 | } else { 102 | preconditionFailure() 103 | } 104 | } 105 | 106 | try responders.forEach { (responder) in 107 | state = try responder.cleanUp(arguments: arguments, state: state ) 108 | } 109 | 110 | if let command = state.command { 111 | return command 112 | } else { 113 | preconditionFailure() 114 | } 115 | } 116 | 117 | } 118 | 119 | // MARK: - ParserResponse 120 | 121 | public protocol ParserResponse { 122 | func canRespond(to arguments: ArgumentList, state: Parser.State) -> Bool 123 | func respond(to arguments: ArgumentList, state: Parser.State) throws -> Parser.State 124 | func cleanUp(arguments: ArgumentList, state: Parser.State) throws -> Parser.State 125 | } 126 | 127 | public struct AliasResponse: ParserResponse { 128 | 129 | public func canRespond(to arguments: ArgumentList, state: Parser.State) -> Bool { 130 | if case let .routing(groupPath) = state.routeState, 131 | groupPath.bottom.aliases[arguments.peek()] != nil { 132 | return true 133 | } 134 | return false 135 | } 136 | 137 | public func respond(to arguments: ArgumentList, state: Parser.State) throws -> Parser.State { 138 | guard case let .routing(groupPath) = state.routeState else { 139 | return state 140 | } 141 | 142 | arguments.manipulate { (args) in 143 | var copy = args 144 | if let alias = groupPath.bottom.aliases[copy[0]] { 145 | copy[0] = alias 146 | } 147 | return copy 148 | } 149 | 150 | return state 151 | } 152 | 153 | public func cleanUp(arguments: ArgumentList, state: Parser.State) throws -> Parser.State { state } 154 | 155 | } 156 | 157 | public struct OptionResponse: ParserResponse { 158 | 159 | public func canRespond(to arguments: ArgumentList, state: Parser.State) -> Bool { 160 | guard arguments.nextIsOption() else { 161 | return false 162 | } 163 | 164 | switch state.routeState { 165 | case .routing(_): 166 | return state.optionRegistry.recognizesOption(arguments.peek()) || state.configuration.fallback == nil 167 | case .routed(_, let params): 168 | return !params.nextIsCollection() || state.configuration.parseOptionsAfterCollectedParameter 169 | } 170 | } 171 | 172 | public func respond(to arguments: ArgumentList, state: Parser.State) throws -> Parser.State { 173 | let firstArg = arguments.pop() 174 | 175 | if firstArg.hasPrefix("--"), let equalsIndex = firstArg.firstIndex(of: "=") { 176 | let optName = String(firstArg[.. Parser.State { 229 | if let misused = state.optionRegistry.groups.first(where: { !$0.check() }) { 230 | throw OptionError(command: state.command, kind: .optionGroupMisuse(misused)) 231 | } 232 | return state 233 | } 234 | 235 | } 236 | 237 | public struct RouteResponse: ParserResponse { 238 | 239 | public func canRespond(to arguments: ArgumentList, state: Parser.State) -> Bool { 240 | if case .routing = state.routeState { 241 | return true 242 | } 243 | return false 244 | } 245 | 246 | public func respond(to arguments: ArgumentList, state: Parser.State) throws -> Parser.State { 247 | guard case let .routing(groupPath) = state.routeState else { 248 | return state 249 | } 250 | 251 | var newState = state 252 | 253 | switch state.configuration.routeBehavior { 254 | case .automatically(let cmd): 255 | newState.appendToPath(cmd, ignoreName: true) 256 | case .search, .searchWithFallback(_): 257 | let name = arguments.peek() 258 | 259 | if let matching = groupPath.bottom.children.first(where: { $0.name == name }) { 260 | arguments.pop() 261 | 262 | if let group = matching as? CommandGroup { 263 | newState.appendToPath(group) 264 | } else if let cmd = matching as? Command { 265 | newState.appendToPath(cmd) 266 | } else { 267 | preconditionFailure("Routables must be either CommandGroups or Commands") 268 | } 269 | } else if let fallback = state.configuration.fallback { 270 | newState.appendToPath(fallback, ignoreName: true) 271 | } else { 272 | throw RouteError(partialPath: groupPath, notFound: name) 273 | } 274 | } 275 | 276 | return newState 277 | } 278 | 279 | public func cleanUp(arguments: ArgumentList, state: Parser.State) throws -> Parser.State { 280 | guard case let .routing(group) = state.routeState else { 281 | return state 282 | } 283 | 284 | var newState = state 285 | 286 | if let fallback = state.configuration.fallback { 287 | newState.appendToPath(fallback, ignoreName: true) 288 | } else if let command = group.bottom as? Command & CommandGroup { 289 | let commandPath = group.droppingLast().appending(command as Command) 290 | newState.routeState = .routed(commandPath, ParameterIterator(command: command)) 291 | } else { 292 | throw RouteError(partialPath: group, notFound: nil) 293 | } 294 | 295 | return newState 296 | } 297 | 298 | } 299 | 300 | public struct ParameterResponse: ParserResponse { 301 | 302 | public func canRespond(to arguments: ArgumentList, state: Parser.State) -> Bool { 303 | if case .routed = state.routeState { 304 | return true 305 | } 306 | return false 307 | } 308 | 309 | public func respond(to arguments: ArgumentList, state: Parser.State) throws -> Parser.State { 310 | guard case let .routed(command, params) = state.routeState else { 311 | return state 312 | } 313 | 314 | if let namedParam = params.next() { 315 | let result = namedParam.param.update(to: arguments.pop()) 316 | if case let .failure(error) = result { 317 | throw ParameterError(command: command, kind: .invalidValue(namedParam, error)) 318 | } 319 | } else { 320 | throw ParameterError(command: command, kind: .wrongNumber(params.minCount, params.maxCount)) 321 | } 322 | 323 | return state 324 | } 325 | 326 | public func cleanUp(arguments: ArgumentList, state: Parser.State) throws -> Parser.State { 327 | guard case let .routed(command, params) = state.routeState else { 328 | return state 329 | } 330 | 331 | if let namedParam = params.next(), !namedParam.param.satisfied { 332 | throw ParameterError(command: command, kind: .wrongNumber(params.minCount, params.maxCount)) 333 | } 334 | 335 | return state 336 | } 337 | 338 | } 339 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/Path.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Path.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 3/22/18. 6 | // 7 | 8 | public protocol RoutablePath: CustomStringConvertible { 9 | func joined(separator: String) -> String 10 | } 11 | 12 | extension RoutablePath { 13 | public var description: String { 14 | return "\(type(of: self))(\(joined(separator: " ")))" 15 | } 16 | } 17 | 18 | public struct CommandGroupPath: RoutablePath { 19 | 20 | public let groups: [CommandGroup] 21 | 22 | public var bottom: CommandGroup { 23 | return groups.last! 24 | } 25 | 26 | public init(top: CommandGroup, groups: [CommandGroup] = []) { 27 | self.init(groups: [top] + groups) 28 | } 29 | 30 | private init(groups: [CommandGroup]) { 31 | self.groups = groups 32 | } 33 | 34 | public func appending(_ group: CommandGroup) -> CommandGroupPath { 35 | return CommandGroupPath(groups: groups + [group]) 36 | } 37 | 38 | public func appending(_ command: Command) -> CommandPath { 39 | return CommandPath(groupPath: self, command: command) 40 | } 41 | 42 | public func appending(_ routable: Routable) -> RoutablePath { 43 | if let cmd = routable as? Command { 44 | return CommandPath(groupPath: self, command: cmd) 45 | } else if let group = routable as? CommandGroup { 46 | return CommandGroupPath(groups: groups + [group]) 47 | } 48 | fatalError() 49 | } 50 | 51 | public func droppingLast() -> CommandGroupPath { 52 | return CommandGroupPath(groups: Array(groups.dropLast())) 53 | } 54 | 55 | public func joined(separator: String = " ") -> String { 56 | return groups.map({ $0.name }).joined(separator: separator) 57 | } 58 | 59 | } 60 | 61 | public struct CommandPath: RoutablePath { 62 | 63 | public let groupPath: CommandGroupPath 64 | public let command: Command 65 | public var ignoreName = false 66 | 67 | public var options: [Option] { 68 | return command.options + groupPath.groups.map({ $0.options }).joined() 69 | } 70 | 71 | public var usage: String { 72 | var message = joined() 73 | 74 | if !command.parameters.isEmpty { 75 | let signature = command.parameters.map({ $0.signature }).joined(separator: " ") 76 | message += " \(signature)" 77 | } 78 | 79 | if !options.isEmpty { 80 | message += " [options]" 81 | } 82 | 83 | return message 84 | } 85 | 86 | public init(groupPath: CommandGroupPath, command: Command) { 87 | self.groupPath = groupPath 88 | self.command = command 89 | } 90 | 91 | public func joined(separator: String = " ") -> String { 92 | var str = groupPath.joined(separator: separator) 93 | if !ignoreName { 94 | str += separator + command.name 95 | } 96 | return str 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/Term.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Term.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 9/14/17. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum Term { 11 | 12 | public static let isTTY = isatty(fileno(Foundation.stdout)) != 0 13 | 14 | public static var stdout: WritableStream = WriteStream.stdout 15 | public static var stderr: WritableStream = WriteStream.stderr 16 | public static var stdin: ReadableStream = ReadStream.stdin 17 | public static var read: () -> String? = { readLine() } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/Validation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Validation.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 11/21/18. 6 | // 7 | 8 | public protocol AnyValidation { 9 | var message: String { get } 10 | } 11 | 12 | public struct Validation: AnyValidation { 13 | 14 | public enum Result { 15 | case success 16 | case failure(String) 17 | } 18 | 19 | public typealias ValidatorBlock = (T) -> Bool 20 | 21 | public static func custom(_ message: String, _ validator: @escaping ValidatorBlock) -> Validation { 22 | return .init(message, validator) 23 | } 24 | 25 | public let block: ValidatorBlock 26 | public let message: String 27 | 28 | init(_ message: String, _ block: @escaping ValidatorBlock) { 29 | self.block = block 30 | self.message = message 31 | } 32 | 33 | public func validate(_ value: T) -> Bool { 34 | guard block(value) else { 35 | return false 36 | } 37 | return true 38 | } 39 | 40 | } 41 | 42 | extension Validation where T: Equatable { 43 | 44 | public static func allowing(_ values: T..., message: String? = nil) -> Validation { 45 | let commaSeparated = values.map({ String(describing: $0) }).joined(separator: ", ") 46 | return .init(message ?? "must be one of: \(commaSeparated)") { values.contains($0) } 47 | } 48 | 49 | public static func rejecting(_ values: T..., message: String? = nil) -> Validation { 50 | let commaSeparated = values.map({ String(describing: $0) }).joined(separator: ", ") 51 | return .init(message ?? "must not be: \(commaSeparated)") { !values.contains($0) } 52 | } 53 | 54 | } 55 | 56 | extension Validation where T: Comparable { 57 | 58 | public static func greaterThan(_ value: T, message: String? = nil) -> Validation { 59 | return .init(message ?? "must be greater than \(value)") { $0 > value } 60 | } 61 | 62 | public static func lessThan(_ value: T, message: String? = nil) -> Validation { 63 | return .init(message ?? "must be less than \(value)") { $0 < value } 64 | } 65 | 66 | public static func within(_ range: ClosedRange, message: String? = nil) -> Validation { 67 | return .init(message ?? "must be greater than or equal to \(range.lowerBound) and less than or equal to \(range.upperBound)") { range.contains($0) } 68 | } 69 | 70 | public static func within(_ range: Range, message: String? = nil) -> Validation { 71 | return .init(message ?? "must be greater than or equal to \(range.lowerBound) and less than \(range.upperBound)") { range.contains($0) } 72 | } 73 | 74 | } 75 | 76 | extension Validation where T == String { 77 | 78 | public static func contains(_ substring: String, message: String? = nil) -> Validation { 79 | return .init(message ?? "must contain '\(substring)'") { $0.contains(substring) } 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/ValueBox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ValueBox.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 12/27/18. 6 | // 7 | 8 | public enum UpdateResult { 9 | case success 10 | case failure(InvalidValueReason) 11 | } 12 | 13 | public enum InvalidValueReason { 14 | case conversionError 15 | case validationError(AnyValidation) 16 | } 17 | 18 | // MARK: - 19 | 20 | public protocol AnyValueBox: AnyObject { 21 | var completion: ShellCompletion { get } 22 | var valueType: ConvertibleFromString.Type { get } 23 | 24 | func update(to value: String) -> UpdateResult 25 | } 26 | 27 | public protocol ValueBox: AnyValueBox { 28 | associatedtype Value: ConvertibleFromString 29 | 30 | var validation: [Validation] { get } 31 | 32 | func update(to value: Value) 33 | } 34 | 35 | extension ValueBox { 36 | 37 | public var valueType: ConvertibleFromString.Type { return Value.self } 38 | 39 | public func update(to value: String) -> UpdateResult { 40 | guard let converted = Value(input: value) else { 41 | return .failure(.conversionError) 42 | } 43 | 44 | if let failedValidation = validation.first(where: { $0.validate(converted) == false }) { 45 | return .failure(.validationError(failedValidation)) 46 | } 47 | 48 | update(to: converted) 49 | 50 | return .success 51 | } 52 | 53 | } 54 | 55 | // MARK: - ConvertibleFromString 56 | 57 | /// A type that can be created from a string 58 | public protocol ConvertibleFromString { 59 | 60 | /// Returns an instance of the conforming type from a string representation 61 | init?(input: String) 62 | 63 | static var explanationForConversionFailure: String { get } 64 | 65 | static func messageForInvalidValue(reason: InvalidValueReason, for id: String?) -> String 66 | } 67 | 68 | extension ConvertibleFromString { 69 | public static var explanationForConversionFailure: String { 70 | return "expected \(self)" 71 | } 72 | 73 | public static func messageForInvalidValue(reason: InvalidValueReason, for id: String?) -> String { 74 | var message = "invalid value" 75 | if let id = id { 76 | message += " passed to '\(id)'" 77 | } 78 | 79 | message += "; " 80 | 81 | switch reason { 82 | case .conversionError: message += explanationForConversionFailure 83 | case let .validationError(validation): message += validation.message 84 | } 85 | 86 | return message 87 | } 88 | 89 | } 90 | 91 | extension CaseIterable where Self: ConvertibleFromString { 92 | public static var explanationForConversionFailure: String { 93 | let options = allCases.map({ String(describing: $0) }).joined(separator: ", ") 94 | return "expected one of: \(options)" 95 | } 96 | } 97 | 98 | extension LosslessStringConvertible where Self: ConvertibleFromString { 99 | public init?(input: String) { 100 | guard let val = Self(input) else { 101 | return nil 102 | } 103 | self = val 104 | } 105 | } 106 | 107 | extension RawRepresentable where Self: ConvertibleFromString, Self.RawValue: ConvertibleFromString { 108 | public init?(input: String) { 109 | guard let raw = RawValue(input: input), let val = Self(rawValue: raw) else { 110 | return nil 111 | } 112 | self = val 113 | } 114 | } 115 | 116 | extension Optional: ConvertibleFromString where Wrapped: ConvertibleFromString { 117 | 118 | public static var explanationForConversionFailure: String { 119 | return Wrapped.explanationForConversionFailure 120 | } 121 | 122 | public static func messageForInvalidValue(reason: InvalidValueReason, for id: String?) -> String { 123 | return Wrapped.messageForInvalidValue(reason: reason, for: id) 124 | } 125 | 126 | public init?(input: String) { 127 | if let wrapped = Wrapped(input: input) { 128 | self = .some(wrapped) 129 | } else { 130 | return nil 131 | } 132 | } 133 | } 134 | 135 | public protocol OptionType { 136 | associatedtype Wrapped 137 | static var swiftcli_Empty: Self { get } 138 | 139 | var swiftcli_Value: Wrapped? { get } 140 | } 141 | extension Optional: OptionType { 142 | public static var swiftcli_Empty: Self { .none } 143 | 144 | public var swiftcli_Value: Wrapped? { 145 | if case .some(let value) = self { 146 | return value 147 | } 148 | return nil 149 | } 150 | } 151 | 152 | extension String: ConvertibleFromString {} 153 | extension Int: ConvertibleFromString {} 154 | extension Float: ConvertibleFromString {} 155 | extension Double: ConvertibleFromString {} 156 | 157 | extension Bool: ConvertibleFromString { 158 | 159 | /// Returns a bool from a string representation 160 | /// 161 | /// - parameter input: A string representation of a bool value 162 | /// 163 | /// This is case insensitive and recognizes several representations: 164 | /// 165 | /// - true/false 166 | /// - t/f 167 | /// - yes/no 168 | /// - y/n 169 | public init?(input: String) { 170 | let lowercased = input.lowercased() 171 | 172 | if ["y", "yes", "t", "true"].contains(lowercased) { 173 | self = true 174 | } else if ["n", "no", "f", "false"].contains(lowercased) { 175 | self = false 176 | } else { 177 | return nil 178 | } 179 | } 180 | 181 | } 182 | -------------------------------------------------------------------------------- /Sources/SwiftCLI/VersionCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VersionCommand.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 8/2/14. 6 | // Copyright (c) 2014 jakeheis. All rights reserved. 7 | // 8 | 9 | public class VersionCommand: Command { 10 | 11 | public let name = "version" 12 | public let shortDescription = "Prints the current version of this app" 13 | 14 | let version: String 15 | 16 | init(version: String) { 17 | self.version = version 18 | } 19 | 20 | public func execute() throws { 21 | stdout <<< "Version: \(version)" 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /SwiftCLI.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | 3 | s.name = "SwiftCLI" 4 | s.version = "6.0.0" 5 | s.summary = "A powerful framework that can be used to develop a CLI in Swift" 6 | 7 | s.description = <<-DESC 8 | A powerful framework that can be used to develop a CLI, from the simplest to the most complex, in Swift. 9 | DESC 10 | 11 | s.homepage = "https://github.com/jakeheis/SwiftCLI" 12 | s.license = "MIT" 13 | s.author = { "Jake Heiser" => "email@address.com" } 14 | s.source = { :git => "https://github.com/jakeheis/SwiftCLI.git", :tag => "#{s.version}" } 15 | 16 | s.platform = :osx 17 | s.osx.deployment_target = "10.9" 18 | 19 | s.source_files = "Sources", "Sources/**/*.{swift}" 20 | 21 | s.swift_version = "5.1.1" 22 | 23 | end 24 | -------------------------------------------------------------------------------- /Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | ${EXECUTABLE_NAME} 9 | CFBundleIdentifier 10 | jakeheis.${PRODUCT_NAME:rfc1034identifier} 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | ${PRODUCT_NAME} 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import SwiftCLITests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += SwiftCLITests.__allTests() 7 | 8 | XCTMain(tests) 9 | -------------------------------------------------------------------------------- /Tests/SwiftCLITests/ArgumentListTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ArgumentListTests.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 3/29/17. 6 | // 7 | // 8 | 9 | import XCTest 10 | @testable import SwiftCLI 11 | 12 | class ArgumentListTests: XCTestCase { 13 | 14 | func testManipulate() { 15 | let args = ArgumentList(arguments: ["tester", "test", "thisCase"]) 16 | args.manipulate { (args) in 17 | return args.map { $0.uppercased() } + ["last"] 18 | } 19 | 20 | XCTAssertEqual(args.pop(), "TESTER") 21 | XCTAssertEqual(args.pop(), "TEST") 22 | XCTAssertEqual(args.pop(), "THISCASE") 23 | XCTAssertEqual(args.pop(), "last") 24 | XCTAssertFalse(args.hasNext()) 25 | } 26 | 27 | } 28 | 29 | -------------------------------------------------------------------------------- /Tests/SwiftCLITests/CompletionGeneratorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompletionGeneratorTests.swift 3 | // SwiftCLITests 4 | // 5 | // Created by Jake Heiser on 9/10/17. 6 | // 7 | 8 | import XCTest 9 | @testable import SwiftCLI 10 | 11 | class CompletionGeneratorTests: XCTestCase { 12 | 13 | func testGroup() { 14 | let cli = CLI.createTester(commands: [alphaCmd, betaCmd]) 15 | 16 | let generator = ZshCompletionGenerator(cli: cli) 17 | let capture = CaptureStream() 18 | generator.writeGroup(for: CommandGroupPath(top: cli), into: capture) 19 | capture.closeWrite() 20 | 21 | XCTAssertEqual(capture.readAll(), """ 22 | _tester() { 23 | _arguments -C \\ 24 | '(-h --help)'{-h,--help}"[Show help information]" \\ 25 | '(-): :->command' \\ 26 | '(-)*:: :->arg' && return 27 | case $state in 28 | (command) 29 | local commands 30 | commands=( 31 | "alpha:The alpha command" 32 | "beta:A beta command" 33 | "help:Prints help information" 34 | ) 35 | _describe 'command' commands 36 | ;; 37 | (arg) 38 | case ${words[1]} in 39 | (alpha) 40 | _tester_alpha 41 | ;; 42 | (beta) 43 | _tester_beta 44 | ;; 45 | (help) 46 | _tester_help 47 | ;; 48 | esac 49 | ;; 50 | esac 51 | } 52 | _tester_alpha() { 53 | _arguments -C 54 | } 55 | _tester_beta() { 56 | _arguments -C 57 | } 58 | _tester_help() { 59 | _arguments -C \\ 60 | "*::command: " 61 | } 62 | 63 | """) 64 | } 65 | 66 | func testBasicOptions() { 67 | let cmd = DoubleFlagCmd() 68 | let cli = CLI.createTester(commands: [cmd]) 69 | 70 | let generator = ZshCompletionGenerator(cli: cli) 71 | let capture = CaptureStream() 72 | let path = CommandGroupPath(top: cli).appending(cmd) 73 | generator.writeCommand(for: path, into: capture) 74 | capture.closeWrite() 75 | 76 | XCTAssertEqual(capture.readAll(), """ 77 | _tester_cmd() { 78 | _arguments -C \\ 79 | '(-a --alpha)'{-a,--alpha}"[The alpha flag]" \\ 80 | '(-b --beta)'{-b,--beta}"[The beta flag]" 81 | } 82 | 83 | """) 84 | } 85 | 86 | func testSepcialCaseOptionCompletion() { 87 | let variadicKey = VariadicKeyCmd() 88 | let exactlyOne = ExactlyOneCmd() 89 | let counterFlag = CounterFlagCmd() 90 | 91 | let cli = CLI.createTester(commands: [variadicKey, exactlyOne, counterFlag]) 92 | let generator = ZshCompletionGenerator(cli: cli) 93 | 94 | let variadicKeyCapture = CaptureStream() 95 | generator.writeCommand(for: CommandGroupPath(top: cli).appending(variadicKey), into: variadicKeyCapture) 96 | variadicKeyCapture.closeWrite() 97 | XCTAssertEqual(variadicKeyCapture.readAll(), """ 98 | _tester_cmd() { 99 | _arguments -C \\ 100 | "*-f[a file]: :_files" \\ 101 | "*--file[a file]: :_files" 102 | } 103 | 104 | """) 105 | 106 | let exactlyOneCapture = CaptureStream() 107 | generator.writeCommand(for: CommandGroupPath(top: cli).appending(exactlyOne), into: exactlyOneCapture) 108 | exactlyOneCapture.closeWrite() 109 | XCTAssertEqual(exactlyOneCapture.readAll(), """ 110 | _tester_cmd() { 111 | _arguments -C \\ 112 | '(-a --alpha -b --beta)'{-a,--alpha}"[the alpha flag]" \\ 113 | '(-a --alpha -b --beta)'{-b,--beta}"[the beta flag]" 114 | } 115 | 116 | """) 117 | 118 | let counterFlagCapture = CaptureStream() 119 | generator.writeCommand(for: CommandGroupPath(top: cli).appending(counterFlag), into: counterFlagCapture) 120 | counterFlagCapture.closeWrite() 121 | XCTAssertEqual(counterFlagCapture.readAll(), """ 122 | _tester_cmd() { 123 | _arguments -C \\ 124 | "*-v[Increase the verbosity]" \\ 125 | "*--verbose[Increase the verbosity]" 126 | } 127 | 128 | """) 129 | } 130 | 131 | func testParameterCompletion() { 132 | let req2 = Req2Cmd() 133 | let req2Collected = Req2CollectedCmd() 134 | let req2opt2 = Req2Opt2Cmd() 135 | 136 | let cli = CLI.createTester(commands: [req2, req2Collected, req2opt2]) 137 | let generator = ZshCompletionGenerator(cli: cli) 138 | 139 | let req2Capture = CaptureStream() 140 | generator.writeCommand(for: CommandGroupPath(top: cli).appending(req2), into: req2Capture) 141 | req2Capture.closeWrite() 142 | 143 | XCTAssertEqual(req2Capture.readAll(), """ 144 | _tester_cmd() { 145 | _arguments -C \\ 146 | ":req1:_files" \\ 147 | ":req2:{_values '' 'executable[generates a project for a cli executable]' 'library[generates project for a dynamic library]' 'other'}" 148 | } 149 | 150 | """) 151 | 152 | let req2CollectedCapture = CaptureStream() 153 | generator.writeCommand(for: CommandGroupPath(top: cli).appending(req2Collected), into: req2CollectedCapture) 154 | req2CollectedCapture.closeWrite() 155 | 156 | XCTAssertEqual(req2CollectedCapture.readAll(), """ 157 | _tester_cmd() { 158 | _arguments -C \\ 159 | ":req1:{_values '' 'executable[generates a project for a cli executable]' 'library[generates project for a dynamic library]'}" \\ 160 | "*:req2:_files" 161 | } 162 | 163 | """) 164 | 165 | let req2opt2Capture = CaptureStream() 166 | generator.writeCommand(for: CommandGroupPath(top: cli).appending(req2opt2), into: req2opt2Capture) 167 | req2opt2Capture.closeWrite() 168 | 169 | XCTAssertEqual(req2opt2Capture.readAll(), """ 170 | _tester_cmd() { 171 | _arguments -C \\ 172 | ":req1:_files" \\ 173 | ":req2:_swift_dependency" \\ 174 | "::opt1: " \\ 175 | "::opt2:_files" 176 | } 177 | 178 | """) 179 | } 180 | 181 | func testLayered() { 182 | let cli = CLI.createTester(commands: [alphaCmd, betaCmd, intraGroup]) 183 | 184 | let generator = ZshCompletionGenerator(cli: cli) 185 | let capture = CaptureStream() 186 | generator.writeCompletions(into: capture) 187 | capture.closeWrite() 188 | 189 | XCTAssertEqual(capture.readAll(), """ 190 | #compdef tester 191 | local context state state_descr line 192 | typeset -A opt_args 193 | 194 | _tester() { 195 | _arguments -C \\ 196 | '(-h --help)'{-h,--help}"[Show help information]" \\ 197 | '(-): :->command' \\ 198 | '(-)*:: :->arg' && return 199 | case $state in 200 | (command) 201 | local commands 202 | commands=( 203 | "alpha:The alpha command" 204 | "beta:A beta command" 205 | "intra:The intra level of commands" 206 | "help:Prints help information" 207 | ) 208 | _describe 'command' commands 209 | ;; 210 | (arg) 211 | case ${words[1]} in 212 | (alpha) 213 | _tester_alpha 214 | ;; 215 | (beta) 216 | _tester_beta 217 | ;; 218 | (intra) 219 | _tester_intra 220 | ;; 221 | (help) 222 | _tester_help 223 | ;; 224 | esac 225 | ;; 226 | esac 227 | } 228 | _tester_alpha() { 229 | _arguments -C 230 | } 231 | _tester_beta() { 232 | _arguments -C 233 | } 234 | _tester_intra() { 235 | _arguments -C \\ 236 | '(-): :->command' \\ 237 | '(-)*:: :->arg' && return 238 | case $state in 239 | (command) 240 | local commands 241 | commands=( 242 | "charlie:A beta command" 243 | "delta:A beta command" 244 | ) 245 | _describe 'command' commands 246 | ;; 247 | (arg) 248 | case ${words[1]} in 249 | (charlie) 250 | _tester_intra_charlie 251 | ;; 252 | (delta) 253 | _tester_intra_delta 254 | ;; 255 | esac 256 | ;; 257 | esac 258 | } 259 | _tester_intra_charlie() { 260 | _arguments -C 261 | } 262 | _tester_intra_delta() { 263 | _arguments -C 264 | } 265 | _tester_help() { 266 | _arguments -C \\ 267 | "*::command: " 268 | } 269 | _tester 270 | 271 | """) 272 | } 273 | 274 | func testEscaping() { 275 | let cmd = QuoteDesciptionCmd() 276 | 277 | let cli = CLI.createTester(commands: [cmd]) 278 | let generator = ZshCompletionGenerator(cli: cli) 279 | 280 | let capture = CaptureStream() 281 | generator.writeGroup(for: CommandGroupPath(top: cli), into: capture) 282 | capture.closeWrite() 283 | 284 | XCTAssertEqual(capture.readAll(), """ 285 | _tester() { 286 | _arguments -C \\ 287 | '(-h --help)'{-h,--help}"[Show help information]" \\ 288 | '(-): :->command' \\ 289 | '(-)*:: :->arg' && return 290 | case $state in 291 | (command) 292 | local commands 293 | commands=( 294 | "cmd:this description has a \\"quoted section\\"" 295 | "help:Prints help information" 296 | ) 297 | _describe 'command' commands 298 | ;; 299 | (arg) 300 | case ${words[1]} in 301 | (cmd) 302 | _tester_cmd 303 | ;; 304 | (help) 305 | _tester_help 306 | ;; 307 | esac 308 | ;; 309 | esac 310 | } 311 | _tester_cmd() { 312 | _arguments -C \\ 313 | '(-q --quoted)'{-q,--quoted}"[also has \\"quotes\\"]" 314 | } 315 | _tester_help() { 316 | _arguments -C \\ 317 | "*::command: " 318 | } 319 | 320 | """) 321 | } 322 | 323 | func testFunction() { 324 | let body = """ 325 | echo wassup 326 | """ 327 | 328 | let cmd = Req1Cmd() 329 | 330 | let cli = CLI.createTester(commands: [cmd]) 331 | let generator = ZshCompletionGenerator(cli: cli, functions: [ 332 | "_ice_targets": body 333 | ]) 334 | 335 | let justFunctionCapture = CaptureStream() 336 | generator.writeFunction(name: "_ice_targets", body: body, into: justFunctionCapture) 337 | justFunctionCapture.closeWrite() 338 | 339 | XCTAssertEqual(justFunctionCapture.readAll(), """ 340 | _ice_targets() { 341 | echo wassup 342 | } 343 | 344 | """) 345 | 346 | let fullCapture = CaptureStream() 347 | generator.writeCompletions(into: fullCapture) 348 | fullCapture.closeWrite() 349 | 350 | XCTAssertEqual(fullCapture.readAll(), """ 351 | #compdef tester 352 | local context state state_descr line 353 | typeset -A opt_args 354 | 355 | _tester() { 356 | _arguments -C \\ 357 | '(-h --help)'{-h,--help}"[Show help information]" \\ 358 | '(-): :->command' \\ 359 | '(-)*:: :->arg' && return 360 | case $state in 361 | (command) 362 | local commands 363 | commands=( 364 | "cmd:" 365 | "help:Prints help information" 366 | ) 367 | _describe 'command' commands 368 | ;; 369 | (arg) 370 | case ${words[1]} in 371 | (cmd) 372 | _tester_cmd 373 | ;; 374 | (help) 375 | _tester_help 376 | ;; 377 | esac 378 | ;; 379 | esac 380 | } 381 | _tester_cmd() { 382 | _arguments -C \\ 383 | ":req1:_ice_targets" 384 | } 385 | _tester_help() { 386 | _arguments -C \\ 387 | "*::command: " 388 | } 389 | _ice_targets() { 390 | echo wassup 391 | } 392 | _tester 393 | 394 | """) 395 | } 396 | 397 | func testOptionCompletion() { 398 | let cmd = CompletionOptionCmd() 399 | 400 | let cli = CLI.createTester(commands: [cmd]) 401 | let generator = ZshCompletionGenerator(cli: cli) 402 | 403 | let capture = CaptureStream() 404 | generator.writeCommand(for: CommandGroupPath(top: cli).appending(cmd), into: capture) 405 | capture.closeWrite() 406 | 407 | XCTAssertEqual(capture.readAll(), """ 408 | _tester_cmd() { 409 | _arguments -C \\ 410 | '(-v --values)'{-v,--values}"[]: :{_values '' 'opt1[first option]' 'opt2[second option]'}" \\ 411 | '(-f --function)'{-f,--function}"[]: :_a_func" \\ 412 | '(-n --name)'{-n,--name}"[]: :_files" \\ 413 | '(-z --zero)'{-z,--zero}"[]: : " \\ 414 | '(-d --default)'{-d,--default}"[]: :_files" \\ 415 | '(-f --flag)'{-f,--flag}"[]" 416 | } 417 | 418 | """) 419 | } 420 | 421 | } 422 | 423 | -------------------------------------------------------------------------------- /Tests/SwiftCLITests/Fixtures.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftCLITests.swift 3 | // SwiftCLITests 4 | // 5 | // Created by Jake Heiser on 6/28/16. 6 | // Copyright (c) 2016 jakeheis. All rights reserved. 7 | // 8 | 9 | import SwiftCLI 10 | import XCTest 11 | 12 | extension CLI { 13 | static func createTester(commands: [Routable], description: String? = nil) -> CLI { 14 | return CLI(name: "tester", description: description, commands: commands) 15 | } 16 | } 17 | 18 | class TestCommand: Command { 19 | 20 | let name = "test" 21 | let shortDescription = "A command to test stuff" 22 | 23 | var executionString = "" 24 | 25 | @Param var testName: String 26 | @Param var testerName: String? 27 | 28 | @Flag("-s", "--silent", description: "Silence all test output") 29 | var silent: Bool 30 | 31 | @Key("-t", "--times", description: "Number of times to run the test") 32 | var times: Int? 33 | 34 | let completion: ((_ executionString: String) -> ())? 35 | 36 | init(completion: ((_ executionString: String) -> ())? = nil) { 37 | self.completion = completion 38 | } 39 | 40 | func execute() throws { 41 | executionString = "\(testerName ?? "defaultTester") will test \(testName), \(times ?? 1) times" 42 | if silent { 43 | executionString += ", silently" 44 | } 45 | 46 | completion?(executionString) 47 | } 48 | 49 | } 50 | 51 | class TestCommandWithLongDescription: Command { 52 | let name = "test" 53 | let shortDescription = "A command to test stuff" 54 | let longDescription = "This is a long\nmultiline description" 55 | 56 | func execute() throws {} 57 | } 58 | 59 | class MultilineCommand: Command { 60 | 61 | let name = "test" 62 | let shortDescription = "A command that has multiline comments.\nNew line" 63 | 64 | @Flag("-s", "--silent", description: "Silence all test output\nNewline") 65 | var silent: Bool 66 | 67 | @Key("-t", "--times", description: "Number of times to run the test") 68 | var times: Int? 69 | 70 | func execute() throws {} 71 | 72 | } 73 | 74 | class ReversedOrderCommand: Command { 75 | 76 | let name = "test" 77 | let shortDescription = "A command" 78 | 79 | @Flag("--silent", "-s", description: "Silence all test output\nNewline") 80 | var silent: Bool 81 | 82 | @Key("--times", "-t", description: "Number of times to run the test") 83 | var times: Int? 84 | 85 | func execute() throws {} 86 | 87 | } 88 | 89 | class TestInheritedCommand: TestCommand { 90 | @Flag("-v", "--verbose", description: "Show more output information") 91 | var verbose: Bool 92 | } 93 | 94 | // MARK: - 95 | 96 | let alphaCmd = AlphaCmd() 97 | let betaCmd = BetaCmd() 98 | let charlieCmd = CharlieCmd() 99 | let deltaCmd = DeltaCmd() 100 | 101 | class AlphaCmd: Command { 102 | let name = "alpha" 103 | let shortDescription = "The alpha command" 104 | fileprivate init() {} 105 | func execute() throws {} 106 | } 107 | 108 | class BetaCmd: Command { 109 | let name = "beta" 110 | let shortDescription = "A beta command" 111 | fileprivate init() {} 112 | func execute() throws {} 113 | } 114 | 115 | class CharlieCmd: Command { 116 | let name = "charlie" 117 | let shortDescription = "A beta command" 118 | fileprivate init() {} 119 | func execute() throws {} 120 | } 121 | 122 | class DeltaCmd: Command { 123 | let name = "delta" 124 | let shortDescription = "A beta command" 125 | fileprivate init() {} 126 | func execute() throws {} 127 | } 128 | 129 | class EmptyCmd: Command { 130 | let name = "cmd" 131 | func execute() throws {} 132 | } 133 | 134 | class Req1Cmd: EmptyCmd { 135 | @Param(completion: .function("_ice_targets")) 136 | var req1: String 137 | } 138 | 139 | class Opt1Cmd: EmptyCmd { 140 | @Param var opt1: String? 141 | } 142 | 143 | class Req2Cmd: EmptyCmd { 144 | @Param(completion: .filename) 145 | var req1: String 146 | 147 | @Param(completion: .values([ 148 | ("executable", "generates a project for a cli executable"), 149 | ("library", "generates project for a dynamic library"), 150 | ("other", "") 151 | ])) 152 | var req2: String 153 | } 154 | 155 | class Opt2Cmd: EmptyCmd { 156 | @Param(completion: .filename) 157 | var opt1: String? 158 | 159 | @Param(completion: .values([ 160 | ("executable", "generates a project for a cli executable"), 161 | ("library", "generates project for a dynamic library") 162 | ])) 163 | var opt2: String? 164 | } 165 | 166 | class Opt2InhCmd: Opt2Cmd { 167 | @Param var opt3: String? 168 | } 169 | 170 | class ReqCollectedCmd: EmptyCmd { 171 | @CollectedParam(minCount: 1) var req1: [String] 172 | } 173 | 174 | class OptCollectedCmd: EmptyCmd { 175 | @CollectedParam var opt1: [String] 176 | } 177 | 178 | class Req2CollectedCmd: EmptyCmd { 179 | @Param(completion: .values([ 180 | ("executable", "generates a project for a cli executable"), 181 | ("library", "generates project for a dynamic library") 182 | ])) 183 | var req1: String 184 | 185 | @CollectedParam(minCount: 1, completion: .filename) 186 | var req2: [String] 187 | } 188 | 189 | class TriReqCollectedCmd: EmptyCmd { 190 | @CollectedParam(minCount: 3) 191 | var triReq: [String] 192 | } 193 | 194 | class Opt2CollectedCmd: EmptyCmd { 195 | @Param var opt1: String? 196 | @CollectedParam var opt2: [String] 197 | } 198 | 199 | class Req2Opt2Cmd: EmptyCmd { 200 | @Param(completion: .filename) 201 | var req1: String 202 | 203 | @Param(completion: .function("_swift_dependency")) 204 | var req2: String 205 | 206 | @Param(completion: .none) 207 | var opt1: String? 208 | 209 | @Param(completion: .filename) 210 | var opt2: String? 211 | } 212 | 213 | // MARK: - 214 | 215 | let midGroup = MidGroup() 216 | let intraGroup = IntraGroup() 217 | 218 | class MidGroup: CommandGroup { 219 | let name = "mid" 220 | let shortDescription = "The mid level of commands" 221 | let children: [Routable] = [alphaCmd, betaCmd] 222 | fileprivate init() {} 223 | } 224 | 225 | class IntraGroup: CommandGroup { 226 | let name = "intra" 227 | let shortDescription = "The intra level of commands" 228 | let children: [Routable] = [charlieCmd, deltaCmd] 229 | fileprivate init() {} 230 | } 231 | 232 | // MARK: - 233 | 234 | class OptionCmd: Command { 235 | let name = "cmd" 236 | let shortDescription = "" 237 | func execute() throws {} 238 | } 239 | 240 | class FlagCmd: OptionCmd { 241 | @Flag("-a", "--alpha") 242 | var flag: Bool 243 | } 244 | 245 | class KeyCmd: OptionCmd { 246 | @Key("-a", "--alpha") 247 | var key: String? 248 | } 249 | 250 | class DoubleFlagCmd: OptionCmd { 251 | @Flag("-a", "--alpha", description: "The alpha flag") 252 | var alpha: Bool 253 | 254 | @Flag("-b", "--beta", description: "The beta flag") 255 | var beta: Bool 256 | } 257 | 258 | class DoubleKeyCmd: OptionCmd { 259 | @Key("-a", "--alpha") 260 | var alpha: String? 261 | 262 | @Key("-b", "--beta") 263 | var beta: String? 264 | } 265 | 266 | class FlagKeyCmd: OptionCmd { 267 | @Flag("-a", "--alpha") 268 | var alpha: Bool 269 | 270 | @Key("-b", "--beta") 271 | var beta: String? 272 | } 273 | 274 | class FlagKeyParamCmd: OptionCmd { 275 | @Flag("-a", "--alpha") 276 | var alpha: Bool 277 | 278 | @Key("-b", "--beta") 279 | var beta: String? 280 | 281 | @Param var param: String 282 | } 283 | 284 | class IntKeyCmd: OptionCmd { 285 | @Key("-a", "--alpha") 286 | var alpha: Int? 287 | } 288 | 289 | class ExactlyOneCmd: Command { 290 | let name = "cmd" 291 | let shortDescription = "" 292 | var helpFlag: Flag? = nil 293 | func execute() throws {} 294 | 295 | @Flag("-a", "--alpha", description: "the alpha flag") 296 | var alpha: Bool 297 | 298 | @Flag("-b", "--beta", description: "the beta flag") 299 | var beta: Bool 300 | 301 | lazy var optionGroups: [OptionGroup] = [.exactlyOne($alpha, $beta)] 302 | } 303 | 304 | class MultipleRestrictionsCmd: Command { 305 | let name = "cmd" 306 | 307 | @Flag("-a", "--alpha", description: "the alpha flag") 308 | var alpha: Bool 309 | 310 | @Flag("-b", "--beta", description: "the beta flag") 311 | var beta: Bool 312 | 313 | lazy var atMostOne: OptionGroup = .atMostOne($alpha, $beta) 314 | lazy var atMostOneAgain: OptionGroup = .atMostOne($alpha, $beta) 315 | 316 | var optionGroups: [OptionGroup] { 317 | return [atMostOne, atMostOneAgain] 318 | } 319 | 320 | func execute() throws {} 321 | } 322 | 323 | class VariadicKeyCmd: OptionCmd { 324 | @VariadicKey("-f", "--file", description: "a file") 325 | var files: [String] 326 | } 327 | 328 | class CounterFlagCmd: OptionCmd { 329 | @CounterFlag("-v", "--verbose", description: "Increase the verbosity") 330 | var verbosity: Int 331 | } 332 | 333 | class ValidatedKeyCmd: OptionCmd { 334 | 335 | static let capitalizedFirstName = Validation.custom("Must be a capitalized first name") { $0.capitalized == $0 } 336 | 337 | @Key("-n", "--name", validation: [capitalizedFirstName]) 338 | var firstName: String? 339 | 340 | @Key("-a", "--age", validation: [.greaterThan(18)]) 341 | var age: Int? 342 | 343 | @Key("-l", "--location", validation: [.rejecting("Chicago", "Boston")]) 344 | var location: String? 345 | 346 | @Key("--holiday", validation: [.allowing("Thanksgiving", "Halloween")]) 347 | var holiday: String? 348 | 349 | } 350 | 351 | class QuoteDesciptionCmd: Command { 352 | let name = "cmd" 353 | let shortDescription = "this description has a \"quoted section\"" 354 | 355 | @Flag("-q", "--quoted", description: "also has \"quotes\"") 356 | var flag: Bool 357 | 358 | func execute() throws {} 359 | } 360 | 361 | class CompletionOptionCmd: OptionCmd { 362 | @Key("-v", "--values", completion: .values([("opt1", "first option"), ("opt2", "second option")])) 363 | var values: String? 364 | 365 | @Key("-f", "--function", completion: .function("_a_func")) 366 | var function: String? 367 | 368 | @Key("-n", "--name", completion: .filename) 369 | var filename: String? 370 | 371 | @Key("-z", "--zero", completion: .none) 372 | var none: String? 373 | 374 | @Key("-d", "--default") 375 | var def: String? 376 | 377 | @Flag("-f", "--flag") 378 | var flag: Bool 379 | } 380 | 381 | class EnumCmd: Command { 382 | 383 | enum Speed: String, ConvertibleFromString { 384 | case slow 385 | case fast 386 | } 387 | 388 | enum Single: String, ConvertibleFromString { 389 | case value 390 | 391 | static let explanationForConversionFailure = "only can be 'value'" 392 | } 393 | 394 | let name = "cmd" 395 | let shortDescription = "Limits param values to enum" 396 | 397 | @Param var speed: Speed 398 | @Param var single: Single? 399 | @Param var int: Int? 400 | 401 | func execute() throws {} 402 | 403 | } 404 | 405 | #if swift(>=4.1.50) 406 | extension EnumCmd.Speed: CaseIterable {} 407 | #endif 408 | 409 | class ValidatedParamCmd: Command { 410 | 411 | let name = "cmd" 412 | let shortDescription = "Validates param values" 413 | 414 | @Param(validation: .greaterThan(18)) 415 | var age: Int? 416 | 417 | func execute() throws {} 418 | 419 | } 420 | 421 | class RememberExecutionCmd: Command { 422 | 423 | let name = "cmd" 424 | let shortDescription = "Remembers execution" 425 | 426 | @Param var param: String? 427 | 428 | var executed = false 429 | 430 | func execute() throws { 431 | executed = true 432 | } 433 | 434 | } 435 | 436 | class ParamInitCmd: Command { 437 | let name = "cmd" 438 | 439 | @Param(completion: .filename) 440 | var reqComp: String 441 | 442 | @Param(completion: .filename) 443 | var optComp: String? 444 | 445 | @Param(validation: .allowing("hi")) 446 | var reqVal: String 447 | 448 | @Param(validation: .allowing("yo")) 449 | var optVal: String? 450 | 451 | @Param(completion: .filename, validation: .allowing("hi")) 452 | var reqCompVal: String 453 | 454 | @Param(completion: .filename, validation: .allowing("yo")) 455 | var optCompVal: String? 456 | 457 | @Param 458 | var reqNone: String 459 | 460 | @Param 461 | var optNone: String? 462 | 463 | func execute() {} 464 | } 465 | 466 | // MARK: - 467 | 468 | func XCTAssertThrowsSpecificError( 469 | expression: @escaping @autoclosure () throws -> T, 470 | file: StaticString = #file, 471 | line: UInt = #line, 472 | error errorHandler: @escaping (E) -> Void) { 473 | XCTAssertThrowsError(try expression(), file: file, line: line) { (error) in 474 | guard let specificError = error as? E else { 475 | XCTFail("Error must be type \(String(describing: E.self)), is \(String(describing: type(of: error)))", file: file, line: line) 476 | return 477 | } 478 | errorHandler(specificError) 479 | } 480 | } 481 | 482 | func XCTAssertEqualLineByLine(_ s1: String, _ s2: String, file: StaticString = #file, line: UInt = #line) { 483 | let lines1 = s1.components(separatedBy: "\n") 484 | let lines2 = s2.components(separatedBy: "\n") 485 | 486 | XCTAssertEqual(lines1.count, lines2.count, "line count should be equal", file: file, line: line) 487 | 488 | for (l1, l2) in zip(lines1, lines2) { 489 | XCTAssertEqual(l1, l2, file: file, line: line) 490 | } 491 | } 492 | 493 | extension CLI { 494 | 495 | static func capture(_ block: () -> ()) -> (String, String) { 496 | let out = CaptureStream() 497 | let err = CaptureStream() 498 | 499 | Term.stdout = out 500 | Term.stderr = err 501 | block() 502 | Term.stdout = WriteStream.stdout 503 | Term.stderr = WriteStream.stderr 504 | 505 | out.closeWrite() 506 | err.closeWrite() 507 | 508 | return (out.readAll(), err.readAll()) 509 | } 510 | 511 | } 512 | -------------------------------------------------------------------------------- /Tests/SwiftCLITests/InputTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InputTests.swift 3 | // SwiftCLITests 4 | // 5 | // Created by Jake Heiser on 3/15/18. 6 | // 7 | 8 | import XCTest 9 | @testable import SwiftCLI 10 | 11 | class InputTests: XCTestCase { 12 | 13 | private var input: [String] = [] 14 | 15 | override func setUp() { 16 | super.setUp() 17 | 18 | Term.read = { 19 | return self.input.removeFirst() 20 | } 21 | } 22 | 23 | func testInt() { 24 | input = ["", "asdf", "3.4", "5"] 25 | let (out, err) = CLI.capture { 26 | let int = Input.readInt() 27 | XCTAssertEqual(int, 5) 28 | } 29 | XCTAssertEqual(out, "") 30 | XCTAssertEqual(err, """ 31 | Invalid value; expected Int 32 | Invalid value; expected Int 33 | Invalid value; expected Int 34 | 35 | """) 36 | } 37 | 38 | func testDouble() { 39 | input = ["", "asdf", "3.4", "5"] 40 | let (out, err) = CLI.capture { 41 | let double = Input.readDouble() 42 | XCTAssertEqual(double, 3.4) 43 | } 44 | XCTAssertEqual(out, "") 45 | XCTAssertEqual(err, """ 46 | Invalid value; expected Double 47 | Invalid value; expected Double 48 | 49 | """) 50 | } 51 | 52 | func testBool() { 53 | input = ["", "asdf", "5", "false"] 54 | let (out, err) = CLI.capture { 55 | let bool = Input.readBool() 56 | XCTAssertEqual(bool, false) 57 | } 58 | XCTAssertEqual(out, "") 59 | XCTAssertEqual(err, """ 60 | Invalid value; expected Bool 61 | Invalid value; expected Bool 62 | Invalid value; expected Bool 63 | 64 | """) 65 | 66 | input = ["asdf", "5", "T"] 67 | let (out2, err2) = CLI.capture { 68 | let bool = Input.readBool() 69 | XCTAssertEqual(bool, true) 70 | } 71 | XCTAssertEqual(out2, "") 72 | XCTAssertEqual(err2, """ 73 | Invalid value; expected Bool 74 | Invalid value; expected Bool 75 | 76 | """) 77 | 78 | input = ["asdf", "yeppp", "YES"] 79 | let (out3, err3) = CLI.capture { 80 | let bool = Input.readBool() 81 | XCTAssertEqual(bool, true) 82 | } 83 | XCTAssertEqual(out3, "") 84 | XCTAssertEqual(err3, """ 85 | Invalid value; expected Bool 86 | Invalid value; expected Bool 87 | 88 | """) 89 | } 90 | 91 | func testDefaultValue() { 92 | runTestCases(valid: [("5", 5), ("\n", 5)], invalid: ["invalid", "3.4"], expectedErrorOutput: "Invalid value; expected Int") { 93 | Input.readInt(defaultValue: 5) 94 | } 95 | runTestCases(valid: [("3.4", 3.4), ("5", 5), ("", 3.4)], invalid: ["invalid"], expectedErrorOutput: "Invalid value; expected Double") { 96 | Input.readDouble(defaultValue: 3.4) 97 | } 98 | runTestCases(valid: [("false", false), ("true", true), ("", false)], invalid: ["invalid", "3.4", "5"], expectedErrorOutput: "Invalid value; expected Bool") { 99 | Input.readBool(defaultValue: false) 100 | } 101 | runTestCases(valid: [("false", false), ("true", true), ("", true)], invalid: ["invalid", "3.4", "5"], expectedErrorOutput: "Invalid value; expected Bool") { 102 | Input.readBool(defaultValue: true) 103 | } 104 | } 105 | 106 | func testValidation() { 107 | input = ["", "asdf", "3.4", "5", "9", "11"] 108 | let (out, err) = CLI.capture { 109 | let int = Input.readInt(validation: [.greaterThan(10)]) 110 | XCTAssertEqual(int, 11) 111 | } 112 | XCTAssertEqual(out, "") 113 | XCTAssertEqual(err, """ 114 | Invalid value; expected Int 115 | Invalid value; expected Int 116 | Invalid value; expected Int 117 | Invalid value; must be greater than 10 118 | Invalid value; must be greater than 10 119 | 120 | """) 121 | 122 | input = ["", "asdf", "5", "false", "SwiftCLI"] 123 | let (out2, err2) = CLI.capture { 124 | let str = Input.readLine(validation: [.contains("ift")]) 125 | XCTAssertEqual(str, "SwiftCLI") 126 | } 127 | XCTAssertEqual(out2, "") 128 | XCTAssertEqual(err2, """ 129 | Invalid value; must contain 'ift' 130 | Invalid value; must contain 'ift' 131 | Invalid value; must contain 'ift' 132 | Invalid value; must contain 'ift' 133 | 134 | """) 135 | } 136 | 137 | private func runTestCases(valid validCases: [(String, T)], invalid invalidCases: [String], expectedErrorOutput: String, block: () -> T) { 138 | precondition(!validCases.isEmpty) 139 | 140 | for (validCase, expectedValue) in validCases { 141 | input = [validCase] 142 | let (out, err) = CLI.capture { 143 | XCTAssertEqual(block(), expectedValue) 144 | } 145 | XCTAssertEqual(out, "") 146 | XCTAssertEqual(err, "") 147 | } 148 | 149 | // NOTE: Input needs to end in a valid entry so it completes. 150 | input = invalidCases + [validCases[0].0] 151 | 152 | let (out, err) = CLI.capture { 153 | _ = block() 154 | } 155 | XCTAssertEqual(out, "") 156 | XCTAssertEqual(err, String(repeating: expectedErrorOutput + "\n", count: invalidCases.count)) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Tests/SwiftCLITests/OptionRegistryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OptionRegistryTests.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 3/29/17. 6 | // 7 | // 8 | 9 | import XCTest 10 | @testable import SwiftCLI 11 | 12 | class OptionRegistryTests: XCTestCase { 13 | 14 | func testFlagDetection() { 15 | let cmd = FlagCmd() 16 | let options = OptionRegistry(routable: cmd) 17 | XCTAssert(options.flag(for: "-a") != nil, "Options should expect flags after a call to onFlags") 18 | XCTAssert(options.flag(for: "--alpha") != nil, "Options should expect flags after a call to onFlags") 19 | XCTAssert(options.key(for: "-a") == nil, "Options should parse no keys from only flags") 20 | } 21 | 22 | func testKeyDetection() { 23 | let cmd = KeyCmd() 24 | let options = OptionRegistry(routable: cmd) 25 | XCTAssert(options.key(for: "-a") != nil, "Options should expect keys after a call to onKeys") 26 | XCTAssert(options.key(for: "--alpha") != nil, "Options should expect keys after a call to onKeys") 27 | XCTAssert(options.flag(for: "-a") == nil, "Options should parse no flags from only keys") 28 | } 29 | 30 | func testVariadicDetection() { 31 | let cmd = VariadicKeyCmd() 32 | let options = OptionRegistry(routable: cmd) 33 | XCTAssertNotNil(options.key(for: "-f")) 34 | XCTAssertNotNil(options.key(for: "--file")) 35 | } 36 | 37 | func testMultipleRestrictions() { 38 | let cmd = MultipleRestrictionsCmd() 39 | let registry = OptionRegistry(routable: cmd) 40 | _ = registry.flag(for: "-a") 41 | _ = registry.flag(for: "-b") 42 | 43 | XCTAssertFalse(cmd.atMostOne.check()) 44 | XCTAssertFalse(cmd.atMostOneAgain.check()) 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /Tests/SwiftCLITests/ParameterFillerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParameterFillerTests.swift 3 | // SwiftCLITests 4 | // 5 | // Created by Jake Heiser on 4/18/18. 6 | // 7 | 8 | import XCTest 9 | import SwiftCLI 10 | 11 | class ParameterFillerTests: XCTestCase { 12 | 13 | func testEmptySignature() throws { 14 | try parse(command: EmptyCmd(), args: []) 15 | 16 | assertParseNumberError(command: EmptyCmd(), args: ["arg"], min: 0, max: 0) 17 | } 18 | 19 | func testRequiredParameters() throws { 20 | assertParseNumberError(command: Req2Cmd(), args: ["arg"], min: 2, max: 2) 21 | 22 | let req2 = try parse(command: Req2Cmd(), args: ["arg1", "arg2"]) 23 | XCTAssertEqual(req2.req1, "arg1") 24 | XCTAssertEqual(req2.req2, "arg2") 25 | 26 | assertParseNumberError(command: Req2Cmd(), args: ["arg1", "arg2", "arg3"], min: 2, max: 2) 27 | } 28 | 29 | func testOptionalParameters() throws { 30 | let cmd1 = try parse(command: Opt2Cmd(), args: []) 31 | XCTAssertEqual(cmd1.opt1, nil) 32 | XCTAssertEqual(cmd1.opt2, nil) 33 | 34 | let cmd2 = try parse(command: Opt2Cmd(), args: ["arg1"]) 35 | XCTAssertEqual(cmd2.opt1, "arg1") 36 | XCTAssertEqual(cmd2.opt2, nil) 37 | 38 | let cmd3 = try parse(command: Opt2Cmd(), args: ["arg1", "arg2"]) 39 | XCTAssertEqual(cmd3.opt1, "arg1") 40 | XCTAssertEqual(cmd3.opt2, "arg2") 41 | 42 | assertParseNumberError(command: Opt2Cmd(), args: ["arg1", "arg2", "arg3"], min: 0, max: 2) 43 | } 44 | 45 | func testOptionalParametersWithInheritance() throws { 46 | let cmd1 = try parse(command: Opt2InhCmd(), args: []) 47 | XCTAssertEqual(cmd1.opt1, nil) 48 | XCTAssertEqual(cmd1.opt2, nil) 49 | XCTAssertEqual(cmd1.opt3, nil) 50 | 51 | let cmd2 = try parse(command: Opt2InhCmd(), args: ["arg1"]) 52 | XCTAssertEqual(cmd2.opt1, "arg1") 53 | XCTAssertEqual(cmd2.opt2, nil) 54 | XCTAssertEqual(cmd2.opt3, nil) 55 | 56 | let cmd3 = try parse(command: Opt2InhCmd(), args: ["arg1", "arg2"]) 57 | XCTAssertEqual(cmd3.opt1, "arg1") 58 | XCTAssertEqual(cmd3.opt2, "arg2") 59 | XCTAssertEqual(cmd3.opt3, nil) 60 | 61 | let cmd4 = try parse(command: Opt2InhCmd(), args: ["arg1", "arg2", "arg3"]) 62 | XCTAssertEqual(cmd4.opt1, "arg1") 63 | XCTAssertEqual(cmd4.opt2, "arg2") 64 | XCTAssertEqual(cmd4.opt3, "arg3") 65 | 66 | assertParseNumberError(command: Opt2InhCmd(), args: ["arg1", "arg2", "arg3", "arg4"], min: 0, max: 3) 67 | } 68 | 69 | func testCollectedRequiredParameters() throws { 70 | assertParseNumberError(command: ReqCollectedCmd(), args: [], min: 1, max: nil) 71 | 72 | assertParseNumberError(command: Req2CollectedCmd(), args: ["arg1"], min: 2, max: nil) 73 | 74 | let cmd1 = try parse(command: Req2CollectedCmd(), args: ["arg1", "arg2"]) 75 | XCTAssertEqual(cmd1.req1, "arg1") 76 | XCTAssertEqual(cmd1.req2, ["arg2"]) 77 | 78 | let cmd2 = try parse(command: Req2CollectedCmd(), args: ["arg1", "arg2", "arg3"]) 79 | XCTAssertEqual(cmd2.req1, "arg1") 80 | XCTAssertEqual(cmd2.req2, ["arg2", "arg3"]) 81 | 82 | assertParseNumberError(command: TriReqCollectedCmd(), args: ["arg1"], min: 3, max: nil) 83 | assertParseNumberError(command: TriReqCollectedCmd(), args: ["arg1", "arg2"], min: 3, max: nil) 84 | 85 | let cmd3 = try parse(command: TriReqCollectedCmd(), args: ["arg1", "arg2", "arg3"]) 86 | XCTAssertEqual(cmd3.triReq, ["arg1", "arg2", "arg3"]) 87 | } 88 | 89 | func testCollectedOptionalParameters() throws { 90 | let cmd1 = try parse(command: Opt2CollectedCmd(), args: []) 91 | XCTAssertEqual(cmd1.opt1, nil) 92 | XCTAssertEqual(cmd1.opt2, []) 93 | 94 | let cmd2 = try parse(command: Opt2CollectedCmd(), args: ["arg1"]) 95 | XCTAssertEqual(cmd2.opt1, "arg1") 96 | XCTAssertEqual(cmd2.opt2, []) 97 | 98 | let cmd3 = try parse(command: Opt2CollectedCmd(), args: ["arg1", "arg2"]) 99 | XCTAssertEqual(cmd3.opt1, "arg1") 100 | XCTAssertEqual(cmd3.opt2, ["arg2"]) 101 | 102 | let cmd4 = try parse(command: Opt2CollectedCmd(), args: ["arg1", "arg2", "arg3"]) 103 | XCTAssertEqual(cmd4.opt1, "arg1") 104 | XCTAssertEqual(cmd4.opt2, ["arg2", "arg3"]) 105 | } 106 | 107 | func testCombinedRequiredAndOptionalParameters() throws { 108 | assertParseNumberError(command: Req2Opt2Cmd(), args: ["arg1"], min: 2, max: 4) 109 | 110 | let cmd1 = try parse(command: Req2Opt2Cmd(), args: ["arg1", "arg2"]) 111 | XCTAssertEqual(cmd1.req1, "arg1") 112 | XCTAssertEqual(cmd1.req2, "arg2") 113 | XCTAssertNil(cmd1.opt1) 114 | XCTAssertNil(cmd1.opt2) 115 | 116 | let cmd2 = try parse(command: Req2Opt2Cmd(), args: ["arg1", "arg2", "arg3"]) 117 | XCTAssertEqual(cmd2.req1, "arg1") 118 | XCTAssertEqual(cmd2.req2, "arg2") 119 | XCTAssertEqual(cmd2.opt1, "arg3") 120 | XCTAssertNil(cmd2.opt2) 121 | 122 | let cmd3 = try parse(command: Req2Opt2Cmd(), args: ["arg1", "arg2", "arg3", "arg4"]) 123 | XCTAssertEqual(cmd3.req1, "arg1") 124 | XCTAssertEqual(cmd3.req2, "arg2") 125 | XCTAssertEqual(cmd3.opt1, "arg3") 126 | XCTAssertEqual(cmd3.opt2, "arg4") 127 | 128 | assertParseNumberError(command: Req2Opt2Cmd(), args: ["arg1", "arg2", "arg3", "arg4", "arg5"], min: 2, max: 4) 129 | } 130 | 131 | func testEmptyOptionalCollectedParameter() throws { // Tests regression 132 | let cmd = try parse(command: OptCollectedCmd(), args: []) 133 | XCTAssertEqual(cmd.opt1, []) 134 | } 135 | 136 | func testCustomParameter() throws { 137 | assertParseNumberError(command: EnumCmd(), args: [], min: 1, max: 3) 138 | 139 | let cmd = EnumCmd() 140 | XCTAssertThrowsSpecificError( 141 | expression: try self.parse(command: cmd, args: ["value"]), 142 | error: { (error: ParameterError) in 143 | guard case .invalidValue(let namedParam, .conversionError) = error.kind else { 144 | XCTFail() 145 | return 146 | } 147 | 148 | XCTAssertEqual(namedParam.name, "speed") 149 | XCTAssert(namedParam.param === cmd.$speed) 150 | }) 151 | 152 | let fast = try parse(command: EnumCmd(), args: ["fast"]) 153 | XCTAssertEqual(fast.speed.rawValue, "fast") 154 | 155 | let slow = try parse(command: EnumCmd(), args: ["slow"]) 156 | XCTAssertEqual(slow.speed.rawValue, "slow") 157 | 158 | assertParseNumberError(command: EnumCmd(), args: ["slow", "value", "3", "fourth"], min: 1, max: 3) 159 | 160 | let cmd2 = EnumCmd() 161 | XCTAssertThrowsSpecificError( 162 | expression: try self.parse(command: cmd2, args: ["slow", "other"]), 163 | error: { (error: ParameterError) in 164 | guard case .invalidValue(let namedParam, .conversionError) = error.kind else { 165 | XCTFail() 166 | return 167 | } 168 | 169 | XCTAssertEqual(namedParam.name, "single") 170 | XCTAssert(ObjectIdentifier(namedParam.param) == ObjectIdentifier(cmd2.$single)) 171 | }) 172 | } 173 | 174 | func testValidatedParameter() throws { 175 | let cmd1 = try parse(command: ValidatedParamCmd(), args: []) 176 | XCTAssertNil(cmd1.age) 177 | 178 | let cmd2 = ValidatedParamCmd() 179 | XCTAssertThrowsSpecificError( 180 | expression: try self.parse(command: cmd2, args: ["16"]), 181 | error: { (error: ParameterError) in 182 | guard case .invalidValue(let namedParam, .validationError(let validation)) = error.kind else { 183 | XCTFail() 184 | return 185 | } 186 | 187 | XCTAssertEqual(namedParam.name, "age") 188 | XCTAssertEqual(ObjectIdentifier(namedParam.param), ObjectIdentifier(cmd2.$age)) 189 | XCTAssertEqual(validation.message, "must be greater than 18") 190 | }) 191 | 192 | let cmd3 = try parse(command: ValidatedParamCmd(), args: ["20"]) 193 | XCTAssertEqual(cmd3.age, 20) 194 | } 195 | 196 | func testParameterInit() { 197 | let cmd = ParamInitCmd() 198 | 199 | XCTAssertTrue(cmd.$reqComp.required) 200 | XCTAssertTrue(cmd.$reqVal.required) 201 | XCTAssertTrue(cmd.$reqCompVal.required) 202 | XCTAssertTrue(cmd.$reqNone.required) 203 | 204 | XCTAssertFalse(cmd.$optComp.required) 205 | XCTAssertFalse(cmd.$optVal.required) 206 | XCTAssertFalse(cmd.$optCompVal.required) 207 | XCTAssertFalse(cmd.$optNone.required) 208 | 209 | XCTAssertNil(cmd.optComp) 210 | XCTAssertNil(cmd.optVal) 211 | XCTAssertNil(cmd.optCompVal) 212 | XCTAssertNil(cmd.optNone) 213 | } 214 | 215 | // MARK: - 216 | 217 | @discardableResult 218 | private func parse(command: T, args: [String]) throws -> T { 219 | let cli = CLI(name: "tester", commands: [command]) 220 | let arguments = ArgumentList(arguments: [command.name] + args) 221 | let routed = try Parser().parse(cli: cli, arguments: arguments) 222 | XCTAssert(routed.command === command) 223 | 224 | return command 225 | } 226 | 227 | private func assertParseNumberError(command: T, args: [String], min: Int, max: Int?, file: StaticString = #file, line: UInt = #line) { 228 | XCTAssertThrowsSpecificError( 229 | expression: try self.parse(command: command, args: args), file: file, line: line, 230 | error: { (error: ParameterError) in 231 | guard case let .wrongNumber(aMin, aMax) = error.kind else { 232 | XCTFail("Expected error to be .wrongNumber(\(min), \(max as Any)); got .\(error.kind)", file: file, line: line) 233 | return 234 | } 235 | XCTAssertEqual(aMin, min, file: file, line: line) 236 | XCTAssertEqual(aMax, max, file: file, line: line) 237 | }) 238 | } 239 | 240 | } 241 | -------------------------------------------------------------------------------- /Tests/SwiftCLITests/ParserTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ParserTests.swift 3 | // SwiftCLI 4 | // 5 | // Created by Jake Heiser on 1/7/15. 6 | // Copyright (c) 2015 jakeheis. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import SwiftCLI 11 | 12 | class ParserTests: XCTestCase { 13 | 14 | // MARK: - Option parsing tests 15 | 16 | func testSimpleFlagParsing() throws { 17 | let cmd = DoubleFlagCmd() 18 | let arguments = ArgumentList(arguments: ["cmd", "-a", "-b"]) 19 | let cli = CLI.createTester(commands: [cmd]) 20 | 21 | _ = try Parser().parse(cli: cli, arguments: arguments) 22 | XCTAssertTrue(cmd.alpha) 23 | XCTAssertTrue(cmd.beta) 24 | } 25 | 26 | func testSimpleKeyParsing() throws { 27 | let cmd = DoubleKeyCmd() 28 | let arguments = ArgumentList(arguments: ["cmd", "-a", "apple", "-b", "banana"]) 29 | let cli = CLI.createTester(commands: [cmd]) 30 | 31 | _ = try Parser().parse(cli: cli, arguments: arguments) 32 | 33 | XCTAssertEqual(cmd.alpha, "apple", "Options should update the values of passed keys") 34 | XCTAssertEqual(cmd.beta, "banana", "Options should update the values of passed keys") 35 | } 36 | 37 | func testKeyValueParsing() throws { 38 | let cmd = IntKeyCmd() 39 | let arguments = ArgumentList(arguments: ["cmd", "-a", "7"]) 40 | let cli = CLI.createTester(commands: [cmd]) 41 | 42 | _ = try Parser().parse(cli: cli, arguments: arguments) 43 | 44 | XCTAssertEqual(cmd.alpha, 7, "Options should parse int") 45 | } 46 | 47 | func testCombinedFlagsAndKeysParsing() throws { 48 | let cmd = FlagKeyCmd() 49 | let arguments = ArgumentList(arguments: ["cmd", "-a", "-b", "banana"]) 50 | let cli = CLI.createTester(commands: [cmd]) 51 | 52 | _ = try Parser().parse(cli: cli, arguments: arguments) 53 | 54 | XCTAssertTrue(cmd.alpha, "Options should execute the closures of passed flags") 55 | XCTAssertEqual(cmd.beta, "banana", "Options should execute the closures of passed keys") 56 | 57 | let cmd2 = FlagKeyCmd() 58 | let arguments2 = ArgumentList(arguments: ["cmd", "-ab", "banana"]) 59 | let cli2 = CLI.createTester(commands: [cmd2]) 60 | 61 | _ = try Parser().parse(cli: cli2, arguments: arguments2) 62 | 63 | XCTAssertTrue(cmd2.alpha) 64 | XCTAssertEqual(cmd2.beta, "banana") 65 | } 66 | 67 | func testCombinedFlagsAndKeysAndArgumentsParsing() throws { 68 | let cmd = FlagKeyParamCmd() 69 | let arguments = ArgumentList(arguments: ["cmd", "-a", "argument", "-b", "banana"]) 70 | let cli = CLI.createTester(commands: [cmd]) 71 | 72 | _ = try Parser().parse(cli: cli, arguments: arguments) 73 | 74 | XCTAssert(cmd.alpha, "Options should execute the closures of passed flags") 75 | XCTAssertEqual(cmd.beta, "banana", "Options should execute the closures of passed keys") 76 | XCTAssertEqual(cmd.param, "argument") 77 | } 78 | 79 | func testUnrecognizedOptions() throws { 80 | let cmd = FlagCmd() 81 | let arguments = ArgumentList(arguments: ["cmd", "-a", "-b"]) 82 | let cli = CLI.createTester(commands: [cmd]) 83 | 84 | do { 85 | _ = try Parser().parse(cli: cli, arguments: arguments) 86 | XCTFail() 87 | } catch let error as OptionError { 88 | guard case let .unrecognizedOption(key) = error.kind, key == "-b" else { 89 | XCTFail() 90 | return 91 | } 92 | } 93 | } 94 | 95 | func testKeysNotGivenValues() throws { 96 | let cmd = FlagKeyCmd() 97 | let arguments = ArgumentList(arguments: ["cmd", "-b", "-a"]) 98 | let cli = CLI.createTester(commands: [cmd]) 99 | 100 | do { 101 | _ = try Parser().parse(cli: cli, arguments: arguments) 102 | XCTFail() 103 | } catch let error as OptionError { 104 | guard case let .expectedValueAfterKey(key) = error.kind else { 105 | XCTFail() 106 | return 107 | } 108 | XCTAssertEqual(key, "-b") 109 | } 110 | 111 | let cmd2 = FlagKeyCmd() 112 | let arguments2 = ArgumentList(arguments: ["cmd", "-ba"]) 113 | let cli2 = CLI.createTester(commands: [cmd2]) 114 | 115 | do { 116 | _ = try Parser().parse(cli: cli2, arguments: arguments2) 117 | XCTFail() 118 | } catch let error as OptionError { 119 | guard case let .expectedValueAfterKey(key) = error.kind else { 120 | XCTFail() 121 | return 122 | } 123 | XCTAssertEqual(key, "-b") 124 | } 125 | } 126 | 127 | func testFlagGivenValue() throws { 128 | let cmd = FlagKeyCmd() 129 | let arguments = ArgumentList(arguments: ["cmd", "--alpha=value"]) 130 | let cli = CLI.createTester(commands: [cmd]) 131 | 132 | do { 133 | _ = try Parser().parse(cli: cli, arguments: arguments) 134 | XCTFail() 135 | } catch let error as OptionError { 136 | guard case let .unexpectedValueAfterFlag(flag) = error.kind else { 137 | XCTFail() 138 | return 139 | } 140 | XCTAssertEqual(flag, "--alpha") 141 | } 142 | } 143 | 144 | func testIllegalOptionFormat() throws { 145 | let cmd = IntKeyCmd() 146 | let arguments = ArgumentList(arguments: ["cmd", "-a", "val"]) 147 | let cli = CLI.createTester(commands: [cmd]) 148 | 149 | do { 150 | _ = try Parser().parse(cli: cli, arguments: arguments) 151 | XCTFail() 152 | } catch let error as OptionError { 153 | guard case .invalidKeyValue(let key, "-a", .conversionError) = error.kind, ObjectIdentifier(key) == ObjectIdentifier(cmd.$alpha) else { 154 | XCTFail() 155 | return 156 | } 157 | } 158 | } 159 | 160 | func testFlagSplitting() throws { 161 | let cmd = DoubleFlagCmd() 162 | let arguments = ArgumentList(arguments: ["cmd", "-ab"]) 163 | let cli = CLI.createTester(commands: [cmd]) 164 | 165 | _ = try Parser().parse(cli: cli, arguments: arguments) 166 | 167 | XCTAssertTrue(cmd.alpha) 168 | XCTAssertTrue(cmd.beta) 169 | } 170 | 171 | func testGroupRestriction() throws { 172 | let cmd1 = ExactlyOneCmd() 173 | let arguments1 = ArgumentList(arguments: ["cmd", "-a", "-b"]) 174 | 175 | do { 176 | _ = try Parser().parse(cli: CLI.createTester(commands: [cmd1]), arguments: arguments1) 177 | XCTFail() 178 | return 179 | } catch let error as OptionError { 180 | guard case let .optionGroupMisuse(group) = error.kind else { 181 | XCTFail() 182 | return 183 | } 184 | XCTAssert(group === cmd1.optionGroups[0]) 185 | } 186 | 187 | let cmd2 = ExactlyOneCmd() 188 | let arguments2 = ArgumentList(arguments: ["cmd", "-a"]) 189 | _ = try Parser().parse(cli: CLI.createTester(commands: [cmd2]), arguments: arguments2) 190 | XCTAssertTrue(cmd2.alpha) 191 | XCTAssertFalse(cmd2.beta) 192 | 193 | let cmd3 = ExactlyOneCmd() 194 | let arguments3 = ArgumentList(arguments: ["cmd", "-b"]) 195 | _ = try Parser().parse(cli: CLI.createTester(commands: [cmd3]), arguments: arguments3) 196 | XCTAssertTrue(cmd3.beta) 197 | XCTAssertFalse(cmd3.alpha) 198 | 199 | let cmd4 = ExactlyOneCmd() 200 | let arguments4 = ArgumentList(arguments: ["cmd"]) 201 | do { 202 | _ = try Parser().parse(cli: CLI.createTester(commands: [cmd4]), arguments: arguments4) 203 | XCTFail() 204 | } catch let error as OptionError { 205 | guard case let .optionGroupMisuse(group) = error.kind else { 206 | XCTFail() 207 | return 208 | } 209 | XCTAssert(group === cmd4.optionGroups[0]) 210 | } 211 | } 212 | 213 | func testVaridadicParse() throws { 214 | let cmd = VariadicKeyCmd() 215 | let cli = CLI.createTester(commands: [cmd]) 216 | let arguments = ArgumentList(arguments: ["cmd", "-f", "firstFile", "--file", "secondFile"]) 217 | 218 | _ = try Parser().parse(cli: cli, arguments: arguments) 219 | XCTAssertEqual(cmd.files, ["firstFile", "secondFile"]) 220 | } 221 | 222 | func testCounterParse() throws { 223 | let counterCmd = CounterFlagCmd() 224 | let counterCli = CLI.createTester(commands: [counterCmd]) 225 | _ = try Parser().parse(cli: counterCli, arguments: ArgumentList(arguments: ["cmd", "-v", "-v"])) 226 | XCTAssertEqual(counterCmd.verbosity, 2) 227 | 228 | let flagCmd = FlagCmd() 229 | let flagCli = CLI.createTester(commands: [flagCmd]) 230 | _ = try Parser().parse(cli: flagCli, arguments: ArgumentList(arguments: ["cmd", "-a", "-a"])) 231 | XCTAssertTrue(flagCmd.flag) 232 | } 233 | 234 | func testBeforeCommand() throws { 235 | let cmd = EmptyCmd() 236 | let yes = Flag("-y") 237 | 238 | let cli = CLI.createTester(commands: [cmd]) 239 | cli.globalOptions = [yes] 240 | let arguments = ArgumentList(arguments: ["-y", "cmd"]) 241 | 242 | _ = try Parser().parse(cli: cli, arguments: arguments) 243 | XCTAssertTrue(yes.wrappedValue) 244 | } 245 | 246 | func testValidation() throws { 247 | let cmd1 = ValidatedKeyCmd() 248 | let arguments1 = ArgumentList(arguments: ["cmd", "-n", "jake"]) 249 | 250 | do { 251 | _ = try Parser().parse(cli: CLI.createTester(commands: [cmd1]), arguments: arguments1) 252 | XCTFail() 253 | } catch let error as OptionError { 254 | guard case .invalidKeyValue(let key, "-n", .validationError(let validator)) = error.kind else { 255 | XCTFail() 256 | return 257 | } 258 | XCTAssert(ObjectIdentifier(key) == ObjectIdentifier(cmd1.$firstName)) 259 | XCTAssertEqual(validator.message, "Must be a capitalized first name") 260 | } 261 | 262 | let cmd2 = ValidatedKeyCmd() 263 | let arguments2 = ArgumentList(arguments: ["cmd", "-n", "Jake"]) 264 | _ = try Parser().parse(cli: CLI.createTester(commands: [cmd2]), arguments: arguments2) 265 | XCTAssertEqual(cmd2.firstName, "Jake") 266 | 267 | let cmd3 = ValidatedKeyCmd() 268 | let arguments3 = ArgumentList(arguments: ["cmd", "-a", "15"]) 269 | 270 | do { 271 | _ = try Parser().parse(cli: CLI.createTester(commands: [cmd3]), arguments: arguments3) 272 | XCTFail() 273 | } catch let error as OptionError { 274 | guard case .invalidKeyValue(let key, "-a", .validationError(let validator)) = error.kind else { 275 | XCTFail(String(describing: error)) 276 | return 277 | } 278 | XCTAssert(ObjectIdentifier(key) == ObjectIdentifier(cmd3.$age)) 279 | XCTAssertEqual(validator.message, "must be greater than 18") 280 | } 281 | 282 | let cmd4 = ValidatedKeyCmd() 283 | let arguments4 = ArgumentList(arguments: ["cmd", "-a", "19"]) 284 | _ = try Parser().parse(cli: CLI.createTester(commands: [cmd4]), arguments: arguments4) 285 | XCTAssertEqual(cmd4.age, 19) 286 | 287 | let cmd5 = ValidatedKeyCmd() 288 | let arguments5 = ArgumentList(arguments: ["cmd", "-l", "Chicago"]) 289 | 290 | do { 291 | _ = try Parser().parse(cli: CLI.createTester(commands: [cmd5]), arguments: arguments5) 292 | XCTFail() 293 | } catch let error as OptionError { 294 | guard case .invalidKeyValue(let key, "-l", .validationError(let validator)) = error.kind else { 295 | XCTFail() 296 | return 297 | } 298 | 299 | XCTAssert(ObjectIdentifier(key) == ObjectIdentifier(cmd5.$location)) 300 | XCTAssertEqual(validator.message, "must not be: Chicago, Boston") 301 | } 302 | 303 | let cmd6 = ValidatedKeyCmd() 304 | let arguments6 = ArgumentList(arguments: ["cmd", "-l", "Denver"]) 305 | _ = try Parser().parse(cli: CLI.createTester(commands: [cmd6]), arguments: arguments6) 306 | XCTAssertEqual(cmd6.location, "Denver") 307 | 308 | let cmd7 = ValidatedKeyCmd() 309 | let arguments7 = ArgumentList(arguments: ["cmd", "--holiday", "4th"]) 310 | 311 | do { 312 | _ = try Parser().parse(cli: CLI.createTester(commands: [cmd7]), arguments: arguments7) 313 | XCTFail() 314 | } catch let error as OptionError { 315 | guard case .invalidKeyValue(let key, "--holiday", .validationError(let validator)) = error.kind else { 316 | XCTFail(String(describing: error)) 317 | return 318 | } 319 | XCTAssert(ObjectIdentifier(key) == ObjectIdentifier(cmd7.$holiday)) 320 | XCTAssertEqual(validator.message, "must be one of: Thanksgiving, Halloween") 321 | } 322 | 323 | let cmd8 = ValidatedKeyCmd() 324 | let arguments8 = ArgumentList(arguments: ["cmd", "--holiday", "Thanksgiving"]) 325 | _ = try Parser().parse(cli: CLI.createTester(commands: [cmd8]), arguments: arguments8) 326 | XCTAssertEqual(cmd8.holiday, "Thanksgiving") 327 | } 328 | 329 | // MARK: - Combined test 330 | 331 | func testFullParse() throws { 332 | let cmd = TestCommand() 333 | let cli = CLI.createTester(commands: [cmd]) 334 | 335 | let args = ArgumentList(arguments: ["test", "-s", "favTest", "-t", "3", "SwiftCLI"]) 336 | let result = try Parser().parse(cli: cli, arguments: args) 337 | 338 | XCTAssertTrue(result.command === cmd) 339 | 340 | XCTAssertEqual(cmd.testName, "favTest") 341 | XCTAssertEqual(cmd.testerName, "SwiftCLI") 342 | XCTAssertTrue(cmd.silent) 343 | XCTAssertEqual(cmd.times, 3) 344 | } 345 | 346 | func testCollectedOptions() throws { 347 | class RunCmd: Command { 348 | let name = "run" 349 | @Param var executable: String 350 | @CollectedParam var args: [String] 351 | @Flag("-v") var verbose: Bool 352 | func execute() throws {} 353 | } 354 | 355 | let cmd = RunCmd() 356 | let cli = CLI.createTester(commands: [cmd]) 357 | let args = ArgumentList(arguments: ["run", "cli", "-v", "arg"]) 358 | 359 | let result = try Parser().parse(cli: cli, arguments: args) 360 | XCTAssertTrue(result.command === cmd) 361 | 362 | XCTAssertEqual(cmd.executable, "cli") 363 | XCTAssertEqual(cmd.args, ["-v", "arg"]) 364 | XCTAssertFalse(cmd.verbose) 365 | 366 | let cmd2 = RunCmd() 367 | let cli2 = CLI.createTester(commands: [cmd2]) 368 | let args2 = ArgumentList(arguments: ["run", "-v", "cli", "arg"]) 369 | 370 | let result2 = try Parser().parse(cli: cli2, arguments: args2) 371 | XCTAssertTrue(result2.command === cmd2) 372 | 373 | XCTAssertEqual(cmd2.executable, "cli") 374 | XCTAssertEqual(cmd2.args, ["arg"]) 375 | XCTAssertTrue(cmd2.verbose) 376 | 377 | let cmd3 = RunCmd() 378 | let cli3 = CLI.createTester(commands: [cmd3]) 379 | let args3 = ArgumentList(arguments: ["run", "cli", "-v", "arg"]) 380 | 381 | var parser = Parser() 382 | parser.parseOptionsAfterCollectedParameter = true 383 | let result3 = try parser.parse(cli: cli3, arguments: args3) 384 | XCTAssertTrue(result3.command === cmd3) 385 | 386 | XCTAssertEqual(cmd3.executable, "cli") 387 | XCTAssertEqual(cmd3.args, ["arg"]) 388 | XCTAssertTrue(cmd3.verbose) 389 | } 390 | 391 | } 392 | -------------------------------------------------------------------------------- /Tests/SwiftCLITests/RouterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RouterTests.swift 3 | // SwiftCLITests 4 | // 5 | // Created by Jake Heiser on 4/18/18. 6 | // 7 | 8 | import XCTest 9 | import SwiftCLI 10 | 11 | class RouterTests: XCTestCase { 12 | 13 | func testNameRoute() throws { 14 | let args = ArgumentList(arguments: ["alpha"]) 15 | let cli = CLI.createTester(commands: [alphaCmd, betaCmd]) 16 | 17 | let command = try Parser().parse(cli: cli, arguments: args) 18 | XCTAssert(command.groupPath.bottom === cli, "Router should generate correct group path") 19 | XCTAssertEqual(command.groupPath.groups.count, 1, "Router should generate correct group path") 20 | XCTAssert(command.command === alphaCmd, "Router should route to the command with the given name") 21 | XCTAssertFalse(args.hasNext()) 22 | } 23 | 24 | func testAliasRoute() throws { 25 | let args = ArgumentList(arguments: ["-b"]) 26 | 27 | let cli = CLI.createTester(commands: [alphaCmd, betaCmd]) 28 | cli.aliases["-b"] = betaCmd.name 29 | 30 | let command = try Parser().parse(cli: cli, arguments: args) 31 | XCTAssertEqual(command.groupPath.bottom.name, cli.name, "Router should generate correct group path") 32 | XCTAssertEqual(command.groupPath.groups.count, 1, "Router should generate correct group path") 33 | XCTAssertEqual(command.command.name, betaCmd.name, "Router with enabled shortcut routing should route to the command with the given shortcut") 34 | XCTAssertFalse(args.hasNext()) 35 | } 36 | 37 | func testSingleRouter() throws { 38 | var parser = Parser() 39 | 40 | let cmd = FlagCmd() 41 | let cli = CLI.createTester(commands: [cmd]) 42 | parser.routeBehavior = .automatically(cmd) 43 | let path = try parser.parse(cli: cli, arguments: ArgumentList(arguments: ["-a"])) 44 | 45 | XCTAssert(path.groupPath.bottom === cli, "Router should generate correct group path") 46 | XCTAssertTrue(path.ignoreName) 47 | XCTAssert(path.command === cmd, "Router should route to the single command") 48 | XCTAssertTrue(cmd.flag) 49 | 50 | let cmd2 = FlagCmd() 51 | let cli2 = CLI.createTester(commands: [cmd2]) 52 | parser.routeBehavior = .automatically(cmd2) 53 | let path2 = try parser.parse(cli: cli2, arguments: ArgumentList(arguments: [])) 54 | 55 | XCTAssert(path2.groupPath.bottom === cli2, "Router should generate correct group path") 56 | XCTAssertTrue(path2.ignoreName) 57 | XCTAssert(path2.command === cmd2, "Router should route to the single command") 58 | XCTAssertFalse(cmd2.flag) 59 | } 60 | 61 | func testFallbackOption() throws { 62 | var parser = Parser() 63 | 64 | let cmd = FlagCmd() 65 | let cli = CLI.createTester(commands: [cmd]) 66 | parser.routeBehavior = .searchWithFallback(cmd) 67 | let path = try parser.parse(cli: cli, arguments: ArgumentList(arguments: ["-a"])) 68 | 69 | XCTAssert(path.groupPath.bottom === cli, "Router should generate correct group path") 70 | XCTAssertTrue(path.ignoreName) 71 | XCTAssert(path.command === cmd, "Router should route to the single command") 72 | XCTAssertTrue(cmd.flag) 73 | 74 | let cmd2 = FlagCmd() 75 | let cli2 = CLI.createTester(commands: [cmd2]) 76 | parser.routeBehavior = .searchWithFallback(cmd2) 77 | let path2 = try parser.parse(cli: cli2, arguments: ArgumentList(arguments: [])) 78 | 79 | XCTAssert(path2.groupPath.bottom === cli2, "Router should generate correct group path") 80 | XCTAssertTrue(path2.ignoreName) 81 | XCTAssert(path2.command === cmd2, "Router should route to the single command") 82 | XCTAssertFalse(cmd2.flag) 83 | } 84 | 85 | func testFailedRoute() throws { 86 | let args = ArgumentList(arguments: ["charlie"]) 87 | let cli = CLI.createTester(commands: [alphaCmd, betaCmd]) 88 | 89 | do { 90 | _ = try Parser().parse(cli: cli, arguments: args) 91 | XCTFail() 92 | } catch let error as RouteError { 93 | XCTAssert(error.partialPath.bottom === cli, "Router should generate correct group path") 94 | XCTAssertEqual(error.partialPath.groups.count, 1, "Router should generate correct group path") 95 | XCTAssertEqual(error.notFound, "charlie") 96 | } 97 | } 98 | 99 | func testGroupPartialRoute() throws { 100 | let arguments = ArgumentList(arguments: ["mid"]) 101 | let cli = CLI.createTester(commands: [midGroup, intraGroup, Req2Cmd(), Opt2Cmd()]) 102 | 103 | do { 104 | _ = try Parser().parse(cli: cli, arguments: arguments) 105 | XCTFail() 106 | } catch let error as RouteError { 107 | XCTAssert(error.partialPath.groups[0] === cli, "Router should generate correct group path") 108 | XCTAssert(error.partialPath.groups[1] === midGroup, "Router should generate correct group path") 109 | XCTAssertEqual(error.partialPath.groups.count, 2, "Router should generate correct group path") 110 | XCTAssertNil(error.notFound) 111 | } 112 | } 113 | 114 | func testGroupFailedRoute() throws { 115 | let arguments = ArgumentList(arguments: ["mid", "charlie"]) 116 | let cli = CLI.createTester(commands: [midGroup, intraGroup, Req2Cmd(), Opt2Cmd()]) 117 | 118 | do { 119 | _ = try Parser().parse(cli: cli, arguments: arguments) 120 | XCTFail() 121 | } catch let error as RouteError { 122 | XCTAssert(error.partialPath.groups[0] === cli, "Router should generate correct group path") 123 | XCTAssert(error.partialPath.groups[1] === midGroup, "Router should generate correct group path") 124 | XCTAssertEqual(error.partialPath.groups.count, 2, "Router should generate correct group path") 125 | XCTAssertEqual(error.notFound, "charlie") 126 | } 127 | } 128 | 129 | func testGroupSuccessRoute() throws { 130 | let arguments = ArgumentList(arguments: ["mid", "beta"]) 131 | let cli = CLI.createTester(commands: [midGroup, intraGroup, Req2Cmd(), Opt2Cmd()]) 132 | 133 | let cmd = try Parser().parse(cli: cli, arguments: arguments) 134 | XCTAssert(cmd.groupPath.groups[0] === cli, "Router should generate correct group path") 135 | XCTAssert(cmd.groupPath.bottom === midGroup, "Router should generate correct group path") 136 | XCTAssertEqual(cmd.groupPath.groups.count, 2, "Router should generate correct group path") 137 | XCTAssert(cmd.command === betaCmd) 138 | XCTAssertFalse(arguments.hasNext()) 139 | } 140 | 141 | func testNestedGroup() throws { 142 | class Nested: CommandGroup { 143 | let name = "nested" 144 | let shortDescription = "Nested group" 145 | let children: [Routable] = [midGroup, intraGroup] 146 | } 147 | 148 | let nested = Nested() 149 | 150 | var arguments = ArgumentList(arguments: ["nested"]) 151 | let cli = CLI.createTester(commands: [nested]) 152 | 153 | do { 154 | _ = try Parser().parse(cli: cli, arguments: arguments) 155 | XCTFail() 156 | } catch let error as RouteError { 157 | XCTAssert(error.partialPath.groups[0] === cli, "Router should generate correct group path") 158 | XCTAssert(error.partialPath.bottom === nested, "Router should generate correct group path") 159 | XCTAssertEqual(error.partialPath.groups.count, 2, "Router should generate correct group path") 160 | XCTAssertNil(error.notFound) 161 | } 162 | 163 | arguments = ArgumentList(arguments: ["nested", "intra"]) 164 | do { 165 | _ = try Parser().parse(cli: cli, arguments: arguments) 166 | XCTFail() 167 | } catch let error as RouteError { 168 | XCTAssert(error.partialPath.groups[0] === cli, "Router should generate correct group path") 169 | XCTAssert(error.partialPath.groups[1] === nested, "Router should generate correct group path") 170 | XCTAssert(error.partialPath.bottom === intraGroup, "Router should generate correct group path") 171 | XCTAssertEqual(error.partialPath.groups.count, 3, "Router should generate correct group path") 172 | XCTAssertNil(error.notFound) 173 | } 174 | 175 | arguments = ArgumentList(arguments: ["nested", "intra", "delta"]) 176 | let cmd = try Parser().parse(cli: cli, arguments: arguments) 177 | 178 | XCTAssert(cmd.groupPath.groups[0] === cli, "Router should generate correct group path") 179 | XCTAssert(cmd.groupPath.groups[1] === nested, "Router should generate correct group path") 180 | XCTAssert(cmd.groupPath.bottom === intraGroup, "Router should generate correct group path") 181 | XCTAssertEqual(cmd.groupPath.groups.count, 3, "Router should generate correct group path") 182 | XCTAssert(cmd.command === deltaCmd) 183 | XCTAssertFalse(arguments.hasNext()) 184 | } 185 | 186 | func testFallback() throws { 187 | func setup() -> (Opt1Cmd, CLI, Parser) { 188 | let opt1 = Opt1Cmd() 189 | let cli = CLI.createTester(commands: [opt1]) 190 | var parser = Parser() 191 | parser.routeBehavior = .searchWithFallback(opt1) 192 | return (opt1, cli, parser) 193 | } 194 | 195 | let (opt1, cli1, parser1) = setup() 196 | 197 | let firstResult = try parser1.parse(cli: cli1, arguments: ArgumentList(arguments: ["cmd", "value"])) 198 | XCTAssert(opt1 === firstResult.command) 199 | XCTAssertFalse(firstResult.ignoreName) 200 | XCTAssertEqual(opt1.opt1, "value") 201 | 202 | let (opt2, cli2, parser2) = setup() 203 | 204 | let secondResult = try parser2.parse(cli: cli2, arguments: ArgumentList(arguments: ["value2"])) 205 | XCTAssert(opt2 === secondResult.command) 206 | XCTAssertTrue(secondResult.ignoreName) 207 | XCTAssertEqual(opt2.opt1, "value2") 208 | 209 | let (opt3, cli3, parser3) = setup() 210 | 211 | let thirdResult = try parser3.parse(cli: cli3, arguments: ArgumentList(arguments: [])) 212 | XCTAssert(opt3 === thirdResult.command) 213 | XCTAssertTrue(thirdResult.ignoreName) 214 | XCTAssertNil(opt3.opt1) 215 | } 216 | 217 | } 218 | 219 | -------------------------------------------------------------------------------- /Tests/SwiftCLITests/StreamTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StreamTests.swift 3 | // SwiftCLITests 4 | // 5 | // Created by Jake Heiser on 4/2/18. 6 | // 7 | 8 | import XCTest 9 | import Dispatch 10 | @testable import SwiftCLI 11 | 12 | class StreamTests: XCTestCase { 13 | 14 | // MARK: - Write 15 | 16 | func testWrite() { 17 | let text = "first line\nsecond line" 18 | 19 | let pipe = Pipe() 20 | let write = WriteStream.for(fileHandle: pipe.fileHandleForWriting) 21 | 22 | write.write(text) 23 | write.closeWrite() 24 | 25 | let data = pipe.fileHandleForReading.readDataToEndOfFile() 26 | XCTAssertEqual(String(data: data, encoding: .utf8), text) 27 | } 28 | 29 | func testWriteData() { 30 | let data = "someString".data(using: .utf8)! 31 | 32 | let pipe = Pipe() 33 | let write = WriteStream.for(fileHandle: pipe.fileHandleForWriting) 34 | write.writeData(data) 35 | write.closeWrite() 36 | 37 | XCTAssertEqual(pipe.fileHandleForReading.readDataToEndOfFile(), data) 38 | } 39 | 40 | // MARK: - Read 41 | 42 | func testRead() { 43 | let pipe = Pipe() 44 | let read = ReadStream.for(fileHandle: pipe.fileHandleForReading) 45 | 46 | let first = "first line\n" 47 | pipe.fileHandleForWriting.write(first.data(using: .utf8)!) 48 | XCTAssertEqual(read.read(), first) 49 | 50 | let second = "second line\n" 51 | pipe.fileHandleForWriting.write(second.data(using: .utf8)!) 52 | XCTAssertEqual(read.read(), second) 53 | } 54 | 55 | func testReadData() { 56 | let data = "someString".data(using: .utf8)! 57 | 58 | let pipe = Pipe() 59 | let read = ReadStream.for(fileHandle: pipe.fileHandleForReading) 60 | pipe.fileHandleForWriting.write(data) 61 | XCTAssertEqual(read.readData(), data) 62 | } 63 | 64 | func testReadAll() { 65 | let pipe = PipeStream() 66 | 67 | pipe <<< "first line" 68 | pipe <<< "second line" 69 | pipe.closeWrite() 70 | 71 | XCTAssertEqual(pipe.readAll(), "first line\nsecond line\n") 72 | } 73 | 74 | func testReadLine() { 75 | let pipe = PipeStream() 76 | 77 | pipe <<< """ 78 | first line 79 | 80 | second line 81 | 82 | """ 83 | pipe.closeWrite() 84 | 85 | XCTAssertEqual(pipe.readLine(), "first line") 86 | XCTAssertEqual(pipe.readLine(), "") 87 | XCTAssertEqual(pipe.readLine(), "second line") 88 | XCTAssertEqual(pipe.readLine(), "") 89 | XCTAssertEqual(pipe.readLine(), nil) 90 | 91 | let pipe2 = PipeStream() 92 | 93 | pipe2.write("first ") 94 | 95 | DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(1)) { 96 | pipe2.write("line\nlast ") 97 | } 98 | 99 | DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(1500)) { 100 | pipe2.write("line") 101 | pipe2.closeWrite() 102 | } 103 | 104 | XCTAssertEqual(pipe2.readLine(), "first line") 105 | XCTAssertEqual(pipe2.readLine(), "last line") 106 | XCTAssertEqual(pipe2.readLine(), nil) 107 | } 108 | 109 | func testReadLines() { 110 | let pipe = PipeStream() 111 | 112 | pipe <<< """ 113 | first line 114 | 115 | second line 116 | 117 | """ 118 | pipe.closeWrite() 119 | 120 | XCTAssertEqual(Array(pipe.readLines()), ["first line", "", "second line", ""]) 121 | } 122 | 123 | func testLineStream() { 124 | let firstLine = expectation(description: "first line") 125 | let stream = LineStream { (line) in 126 | if line == "first" { 127 | firstLine.fulfill() 128 | } 129 | } 130 | 131 | stream <<< "first" 132 | waitForExpectations(timeout: 1) 133 | 134 | stream.closeWrite() 135 | stream.waitToFinishProcessing() 136 | } 137 | 138 | func testCaptureStream() { 139 | let capture = CaptureStream() 140 | 141 | capture <<< "first" 142 | capture <<< "" 143 | capture <<< "second" 144 | capture.closeWrite() 145 | 146 | XCTAssertEqual(capture.readAll(), """ 147 | first 148 | 149 | second 150 | 151 | """) 152 | } 153 | 154 | func testSplitStream() { 155 | let captureA = CaptureStream() 156 | let captureB = CaptureStream() 157 | let stream = SplitStream(captureA, captureB) 158 | 159 | stream <<< "first" 160 | stream <<< "" 161 | stream <<< "second" 162 | stream.closeWrite() 163 | 164 | XCTAssertEqual(captureA.readAll(), """ 165 | first 166 | 167 | second 168 | 169 | """) 170 | XCTAssertEqual(captureB.readAll(), """ 171 | first 172 | 173 | second 174 | 175 | """) 176 | } 177 | 178 | func testNullStream() { 179 | let nullWrite = WriteStream.null 180 | 181 | nullWrite <<< "into" 182 | nullWrite <<< "the" 183 | nullWrite <<< "void" 184 | } 185 | 186 | func testReadFile() { 187 | let stream = ReadStream.for(path: #file) 188 | XCTAssertEqual(stream?.readLine(), "//") 189 | XCTAssertEqual(stream?.readLine(), "// StreamTests.swift") 190 | 191 | stream?.seek(to: 0) 192 | XCTAssertEqual(stream?.readLine(), "//") 193 | XCTAssertEqual(stream?.readLine(), "// StreamTests.swift") 194 | 195 | stream?.seekToEnd() 196 | XCTAssertNil(stream?.readLine()) 197 | } 198 | 199 | func testWriteFile() { 200 | let path = "/tmp/SwiftCLI.test" 201 | defer { try? FileManager.default.removeItem(atPath: path) } 202 | 203 | guard let stream = WriteStream.for(path: path) else { 204 | XCTFail() 205 | return 206 | } 207 | 208 | stream <<< "first line" 209 | stream <<< "second line" 210 | 211 | XCTAssertEqual(String(data: FileManager.default.contents(atPath: path)!, encoding: .utf8), """ 212 | first line 213 | second line 214 | 215 | """) 216 | 217 | guard let secondStream = WriteStream.for(path: path, appending: false) else { 218 | XCTFail() 219 | return 220 | } 221 | secondStream.write("newww text") 222 | 223 | XCTAssertEqual(String(data: FileManager.default.contents(atPath: path)!, encoding: .utf8), """ 224 | newww text 225 | second line 226 | 227 | """) 228 | 229 | secondStream.truncateRemaining() 230 | 231 | XCTAssertEqual(String(data: FileManager.default.contents(atPath: path)!, encoding: .utf8), """ 232 | newww text 233 | """) 234 | } 235 | 236 | } 237 | -------------------------------------------------------------------------------- /Tests/SwiftCLITests/SwiftCLITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftCLITests.swift 3 | // SwiftCLITests 4 | // 5 | // Created by Jake Heiser on 8/2/14. 6 | // Copyright (c) 2014 jakeheis. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import SwiftCLI 11 | 12 | class SwiftCLITests: XCTestCase { 13 | 14 | private var executionString = "" 15 | 16 | // Integration tests 17 | 18 | func testGoWithArguments() { 19 | let (result, out, err) = runCLI { $0.go(with: ["test", "firstTest", "MyTester", "-t", "5", "-s"]) } 20 | XCTAssertEqual(result, 0, "Command should have succeeded") 21 | XCTAssertEqual(executionString, "MyTester will test firstTest, 5 times, silently", "Command should have produced accurate output") 22 | XCTAssertEqual(out, "") 23 | XCTAssertEqual(err, "") 24 | } 25 | 26 | func testCLIHelp() { 27 | let (result, out, err) = runCLI { $0.go(with: ["help"]) } 28 | XCTAssertEqual(result, 0, "Command should have succeeded") 29 | XCTAssertEqual(out, """ 30 | 31 | Usage: tester [options] 32 | 33 | Commands: 34 | test A command to test stuff 35 | help Prints help information 36 | 37 | 38 | """) 39 | XCTAssertEqual(err, "") 40 | 41 | let (result2, out2, err2) = runCLI { $0.go(with: ["-h"]) } 42 | XCTAssertEqual(result2, 1, "Command should have failed") 43 | XCTAssertEqual(err2, """ 44 | 45 | Usage: tester [options] 46 | 47 | Commands: 48 | test A command to test stuff 49 | help Prints help information 50 | 51 | 52 | """) 53 | XCTAssertEqual(out2, "") 54 | } 55 | 56 | func testGlobalOptions() { 57 | let verboseFlag = Flag("-v") 58 | 59 | let (result, out, err) = runCLI { 60 | $0.globalOptions.append(verboseFlag) 61 | return $0.go(with: ["test", "myTest", "-v"]) 62 | } 63 | XCTAssertEqual(result, 0, "Command should have succeeded") 64 | XCTAssertEqual(executionString, "defaultTester will test myTest, 1 times", "Command should have produced accurate output") 65 | XCTAssertTrue(verboseFlag.wrappedValue) 66 | XCTAssertEqual(out, "") 67 | XCTAssertEqual(err, "") 68 | } 69 | 70 | func testOptionSplit() { 71 | let (result, out, err) = runCLI { $0.go(with: ["test", "firstTest", "MyTester", "-st", "5"]) } 72 | XCTAssertEqual(result, 0, "Command should have succeeded") 73 | XCTAssertEqual(executionString, "MyTester will test firstTest, 5 times, silently", "Command should have produced accurate output") 74 | XCTAssertEqual(out, "") 75 | XCTAssertEqual(err, "") 76 | 77 | executionString = "" 78 | 79 | let (result2, out2, err2) = runCLI { $0.go(with: ["test", "firstTest", "MyTester", "--times=5"]) } 80 | XCTAssertEqual(result2, 0, "Command should have succeeded") 81 | XCTAssertEqual(executionString, "MyTester will test firstTest, 5 times", "Command should have produced accurate output") 82 | XCTAssertEqual(out2, "") 83 | XCTAssertEqual(err2, "") 84 | } 85 | 86 | func testCommandHelp() { 87 | let (result, out, err) = runCLI { $0.go(with: ["test", "aTest", "-h"]) } 88 | XCTAssertEqual(result, 0) 89 | XCTAssertEqual(executionString, "") 90 | XCTAssertEqual(out, """ 91 | 92 | Usage: tester test [] [options] 93 | 94 | A command to test stuff 95 | 96 | Options: 97 | -h, --help Show help information 98 | -s, --silent Silence all test output 99 | -t, --times Number of times to run the test 100 | 101 | 102 | """) 103 | XCTAssertEqual(err, "") 104 | } 105 | 106 | func testSingleCommand() { 107 | let cmd = RememberExecutionCmd() 108 | let cli = CLI(singleCommand: cmd) 109 | 110 | let (out, err) = CLI.capture { 111 | let result = cli.go(with: ["aTest"]) 112 | XCTAssertEqual(result, 0) 113 | } 114 | XCTAssertEqual(out, "") 115 | XCTAssertEqual(err, "") 116 | XCTAssertTrue(cmd.executed) 117 | XCTAssertEqual(cmd.param, "aTest") 118 | 119 | let cmd2 = RememberExecutionCmd() 120 | let cli2 = CLI(singleCommand: cmd2) 121 | 122 | let (out2, err2) = CLI.capture { 123 | let result = cli2.go(with: []) 124 | XCTAssertEqual(result, 0) 125 | } 126 | XCTAssertEqual(out2, "") 127 | XCTAssertEqual(err2, "") 128 | XCTAssertTrue(cmd2.executed) 129 | XCTAssertNil(cmd2.param) 130 | 131 | let cmd3 = RememberExecutionCmd() 132 | let cli3 = CLI(singleCommand: cmd3) 133 | 134 | let (out3, err3) = CLI.capture { 135 | let result = cli3.go(with: ["-h"]) 136 | XCTAssertEqual(result, 0) 137 | } 138 | XCTAssertEqual(out3, """ 139 | 140 | Usage: cmd [] [options] 141 | 142 | Remembers execution 143 | 144 | Options: 145 | -h, --help Show help information 146 | 147 | 148 | """) 149 | XCTAssertEqual(err3, "") 150 | } 151 | 152 | func testFallback() { 153 | class Execute: Command { 154 | let name = "execute" 155 | @Param var file: String? 156 | var executed = false 157 | func execute() throws { executed = true } 158 | } 159 | 160 | class Build: Command { 161 | let name = "build" 162 | var built = false 163 | func execute() throws { built = true } 164 | } 165 | 166 | let execute1 = Execute() 167 | let build1 = Build() 168 | 169 | let (out, err) = CLI.capture { 170 | let cli = CLI(name: "swift", commands: [build1]) 171 | cli.parser.routeBehavior = .searchWithFallback(execute1) 172 | let result = cli.go(with: ["build"]) 173 | XCTAssertEqual(result, 0) 174 | } 175 | XCTAssertEqual(out, "") 176 | XCTAssertEqual(err, "") 177 | XCTAssertTrue(build1.built) 178 | XCTAssertFalse(execute1.executed) 179 | XCTAssertNil(execute1.file) 180 | 181 | let execute2 = Execute() 182 | let build2 = Build() 183 | 184 | let (out2, err2) = CLI.capture { 185 | let cli = CLI(name: "swift", commands: [build2]) 186 | cli.parser.routeBehavior = .searchWithFallback(execute2) 187 | let result = cli.go(with: ["file.swift"]) 188 | XCTAssertEqual(result, 0) 189 | } 190 | XCTAssertEqual(out2, "") 191 | XCTAssertEqual(err2, "") 192 | XCTAssertFalse(build2.built) 193 | XCTAssertTrue(execute2.executed) 194 | XCTAssertEqual(execute2.file, "file.swift") 195 | 196 | let execute3 = Execute() 197 | let build3 = Build() 198 | 199 | let (out3, err3) = CLI.capture { 200 | let cli = CLI(name: "swift", commands: [build3]) 201 | cli.parser.routeBehavior = .searchWithFallback(execute3) 202 | let result = cli.go(with: []) 203 | XCTAssertEqual(result, 0) 204 | } 205 | XCTAssertEqual(out3, "") 206 | XCTAssertEqual(err3, "") 207 | XCTAssertFalse(build3.built) 208 | XCTAssertTrue(execute3.executed) 209 | XCTAssertNil(execute3.file) 210 | 211 | let execute4 = Execute() 212 | let build4 = Build() 213 | 214 | let (out4, err4) = CLI.capture { 215 | let cli = CLI(name: "swift", commands: [build4]) 216 | cli.parser.routeBehavior = .searchWithFallback(execute4) 217 | let result = cli.go(with: ["-h"]) 218 | XCTAssertEqual(result, 0) 219 | } 220 | XCTAssertEqual(out4, """ 221 | 222 | Usage: swift [] [options] 223 | 224 | Options: 225 | -h, --help Show help information 226 | 227 | 228 | """) 229 | XCTAssertEqual(err4, "") 230 | 231 | let execute5 = Execute() 232 | let build5 = Build() 233 | 234 | let (out5, err5) = CLI.capture { 235 | let cli = CLI(name: "swift", commands: [build5]) 236 | cli.parser.routeBehavior = .searchWithFallback(execute5) 237 | let result = cli.go(with: ["hi.swift", "this.swift"]) 238 | XCTAssertEqual(result, 1) 239 | } 240 | XCTAssertEqual(err5, """ 241 | 242 | Usage: swift [] [options] 243 | 244 | Options: 245 | -h, --help Show help information 246 | 247 | Error: command requires between 0 and 1 arguments 248 | 249 | 250 | """) 251 | XCTAssertEqual(out5, "") 252 | } 253 | 254 | private func runCLI(_ run: (CLI) -> Int32) -> (Int32, String, String) { 255 | let cmd = TestCommand { (executionString) in 256 | self.executionString = executionString 257 | } 258 | 259 | var result: Int32 = 0 260 | let (out, err) = CLI.capture { 261 | let cli = CLI.createTester(commands: [cmd]) 262 | result = run(cli) 263 | } 264 | 265 | return (result, out, err) 266 | } 267 | 268 | // Tear down 269 | 270 | override func tearDown() { 271 | super.tearDown() 272 | 273 | executionString = "" 274 | } 275 | 276 | } 277 | -------------------------------------------------------------------------------- /Tests/SwiftCLITests/TaskTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TaskTests.swift 3 | // SwiftCLITests 4 | // 5 | // Created by Jake Heiser on 4/1/18. 6 | // 7 | 8 | import Foundation 9 | import SwiftCLI 10 | import XCTest 11 | 12 | class TaskTests: XCTestCase { 13 | #if !os(iOS) 14 | 15 | func testRun() throws { 16 | let file = "file.txt" 17 | try Task.run("/usr/bin/touch", file) 18 | 19 | XCTAssertTrue(FileManager.default.fileExists(atPath: file)) 20 | try FileManager.default.removeItem(atPath: file) 21 | } 22 | 23 | func testCapture() throws { 24 | let path = "/tmp/_swiftcli" 25 | try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: false, attributes: nil) 26 | _ = FileManager.default.createFile(atPath: path + "/SwiftCLI", contents: nil, attributes: nil) 27 | defer { try! FileManager.default.removeItem(atPath: path) } 28 | 29 | let output = try Task.capture("/bin/ls", path) 30 | XCTAssertEqual(output.stdout, "SwiftCLI") 31 | XCTAssertEqual(output.stderr, "") 32 | } 33 | 34 | func testBashRun() throws { 35 | let file = "file.txt" 36 | try Task.run(bash: "touch \(file)") 37 | 38 | XCTAssertTrue(FileManager.default.fileExists(atPath: file)) 39 | try FileManager.default.removeItem(atPath: file) 40 | } 41 | 42 | func testBashCapture() throws { 43 | let path = "/tmp/_swiftcli" 44 | try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: false, attributes: nil) 45 | _ = FileManager.default.createFile(atPath: path + "/SwiftCLI", contents: nil, attributes: nil) 46 | defer { try! FileManager.default.removeItem(atPath: path) } 47 | 48 | let output = try Task.capture(bash: "ls \(path)") 49 | XCTAssertEqual(output.stdout, "SwiftCLI") 50 | XCTAssertEqual(output.stderr, "") 51 | } 52 | 53 | func testRunDirectory() throws { 54 | let path = "/tmp/_swiftcli" 55 | try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: false, attributes: nil) 56 | defer { try! FileManager.default.removeItem(atPath: path) } 57 | 58 | try Task.run("touch", arguments: ["SwiftCLI"], directory: path) 59 | 60 | XCTAssertTrue(FileManager.default.fileExists(atPath: path + "/SwiftCLI")) 61 | } 62 | 63 | func testCaptureDirectory() throws { 64 | let path = "/tmp/_swiftcli" 65 | try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: false, attributes: nil) 66 | _ = FileManager.default.createFile(atPath: path + "/SwiftCLI", contents: nil, attributes: nil) 67 | defer { try! FileManager.default.removeItem(atPath: path) } 68 | 69 | let output = try Task.capture("ls", arguments: [], directory: path) 70 | XCTAssertEqual(output.stdout, "SwiftCLI") 71 | XCTAssertEqual(output.stderr, "") 72 | } 73 | 74 | func testIn() throws { 75 | let input = PipeStream() 76 | 77 | let output = CaptureStream() 78 | let task = Task(executable: "/usr/bin/sort", stdout: output, stdin: input) 79 | task.runAsync() 80 | 81 | input <<< "beta" 82 | input <<< "alpha" 83 | input.closeWrite() 84 | 85 | let code = task.finish() 86 | XCTAssertEqual(code, 0) 87 | XCTAssertEqual(output.readAll(), "alpha\nbeta\n") 88 | } 89 | 90 | func testPipe() throws { 91 | // Travis errors on Linux for unknown reason 92 | #if os(macOS) 93 | 94 | let path = "/tmp/_swiftcli" 95 | try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: false, attributes: nil) 96 | _ = FileManager.default.createFile(atPath: path + "/Info.plist", contents: nil, attributes: nil) 97 | _ = FileManager.default.createFile(atPath: path + "/LinuxMain.swift", contents: nil, attributes: nil) 98 | _ = FileManager.default.createFile(atPath: path + "/SwiftCLITests", contents: nil, attributes: nil) 99 | defer { try! FileManager.default.removeItem(atPath: path) } 100 | 101 | let connector = PipeStream() 102 | let output = CaptureStream() 103 | 104 | let ls = Task(executable: "ls", arguments: [path], stdout: connector) 105 | let grep = Task(executable: "grep", arguments: ["Swift"], stdout: output, stdin: connector) 106 | 107 | ls.runAsync() 108 | grep.runAsync() 109 | 110 | XCTAssertEqual(output.readAll(), "SwiftCLITests\n") 111 | 112 | #endif 113 | } 114 | 115 | func testCurrentDirectory() throws { 116 | let path = "/tmp/_swiftcli" 117 | try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: false, attributes: nil) 118 | _ = FileManager.default.createFile(atPath: path + "/SwiftCLI", contents: nil, attributes: nil) 119 | defer { try! FileManager.default.removeItem(atPath: path) } 120 | 121 | let capture = CaptureStream() 122 | 123 | let ls = Task(executable: "ls", directory: path, stdout: capture) 124 | ls.runSync() 125 | 126 | XCTAssertEqual(capture.readAll(), "SwiftCLI\n") 127 | } 128 | 129 | func testEnv() { 130 | let capture = CaptureStream() 131 | 132 | let echo = Task(executable: "bash", arguments: ["-c", "echo $MY_VAR"], stdout: capture) 133 | echo.env["MY_VAR"] = "aVal" 134 | echo.runSync() 135 | 136 | XCTAssertEqual(capture.readAll(), "aVal\n") 137 | } 138 | 139 | func testSignals() { 140 | let task = Task(executable: "/bin/sleep", arguments: ["1"]) 141 | task.runAsync() 142 | 143 | XCTAssertTrue(task.suspend()) 144 | sleep(2) 145 | XCTAssertTrue(task.isRunning) 146 | XCTAssertTrue(task.resume()) 147 | sleep(2) 148 | XCTAssertFalse(task.isRunning) 149 | 150 | // Travis errors when calling interrupt on Linux for unknown reason 151 | #if os(macOS) 152 | let task2 = Task(executable: "/bin/sleep", arguments: ["3"]) 153 | task2.runAsync() 154 | task2.interrupt() 155 | XCTAssertEqual(task2.finish(), 2) 156 | #endif 157 | 158 | let task3 = Task(executable: "/bin/sleep", arguments: ["3"]) 159 | task3.runAsync() 160 | task3.terminate() 161 | XCTAssertEqual(task3.finish(), 15) 162 | } 163 | 164 | func testTaskLineStream() throws { 165 | let path = "/tmp/_swiftcli" 166 | try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: false, attributes: nil) 167 | _ = FileManager.default.createFile(atPath: path + "/Info.plist", contents: nil, attributes: nil) 168 | _ = FileManager.default.createFile(atPath: path + "/LinuxMain.swift", contents: nil, attributes: nil) 169 | _ = FileManager.default.createFile(atPath: path + "/SwiftCLITests", contents: nil, attributes: nil) 170 | defer { try! FileManager.default.removeItem(atPath: path) } 171 | 172 | var count = 0 173 | let lineStream = LineStream { (line) in 174 | count += 1 175 | } 176 | let task = Task(executable: "ls", arguments: [path], stdout: lineStream) 177 | XCTAssertEqual(task.runSync(), 0) 178 | 179 | XCTAssertEqual(count, 3) 180 | } 181 | 182 | func testTaskNullStream() throws { 183 | let task = Task(executable: "ls", stdout: WriteStream.null) 184 | task.runSync() 185 | } 186 | 187 | #endif 188 | } 189 | -------------------------------------------------------------------------------- /Tests/SwiftCLITests/ValidationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ValidationTests.swift 3 | // SwiftCLITests 4 | // 5 | // Created by Jake Heiser on 11/23/18. 6 | // 7 | 8 | import XCTest 9 | import SwiftCLI 10 | 11 | class ValidationTests: XCTestCase { 12 | 13 | func testEquatable() { 14 | let allow = Validation.allowing("this", "that") 15 | assertFailure(of: allow, with: "something", message: "must be one of: this, that") 16 | assertSuccess(of: allow, with: "this") 17 | assertSuccess(of: allow, with: "that") 18 | 19 | let reject = Validation.rejecting("this", "that") 20 | assertFailure(of: reject, with: "this", message: "must not be: this, that") 21 | assertFailure(of: reject, with: "that", message: "must not be: this, that") 22 | assertSuccess(of: reject, with: "something") 23 | } 24 | 25 | func testComparable() { 26 | let greaterThan = Validation.greaterThan(18) 27 | assertFailure(of: greaterThan, with: 15, message: "must be greater than 18") 28 | assertSuccess(of: greaterThan, with: 19) 29 | 30 | let lessThan = Validation.lessThan(18) 31 | assertFailure(of: lessThan, with: 19, message: "must be less than 18") 32 | assertSuccess(of: lessThan, with: 15) 33 | 34 | let withinClosed = Validation.within(18...30) 35 | assertFailure(of: withinClosed, with: 15, message: "must be greater than or equal to 18 and less than or equal to 30") 36 | assertFailure(of: withinClosed, with: 31, message: "must be greater than or equal to 18 and less than or equal to 30") 37 | assertSuccess(of: withinClosed, with: 18) 38 | assertSuccess(of: withinClosed, with: 24) 39 | assertSuccess(of: withinClosed, with: 30) 40 | 41 | let withinHalfOpen = Validation.within(18..<30) 42 | assertFailure(of: withinHalfOpen, with: 15, message: "must be greater than or equal to 18 and less than 30") 43 | assertFailure(of: withinHalfOpen, with: 30, message: "must be greater than or equal to 18 and less than 30") 44 | assertFailure(of: withinHalfOpen, with: 31, message: "must be greater than or equal to 18 and less than 30") 45 | assertSuccess(of: withinHalfOpen, with: 18) 46 | assertSuccess(of: withinHalfOpen, with: 24) 47 | } 48 | 49 | func testString() { 50 | let contains = Validation.contains("hi") 51 | assertFailure(of: contains, with: "that", message: "must contain 'hi'") 52 | assertSuccess(of: contains, with: "this") 53 | } 54 | 55 | private func assertSuccess(of validation: Validation, with input: T, file: StaticString = #file, line: UInt = #line) { 56 | XCTAssertTrue(validation.validate(input), file: file, line: line) 57 | } 58 | 59 | private func assertFailure(of validation: Validation, with input: T, message expectedMessage: String, file: StaticString = #file, line: UInt = #line) { 60 | XCTAssertFalse(validation.validate(input), file: file, line: line) 61 | XCTAssertEqual(validation.message, expectedMessage, file: file, line: line) 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /Tests/SwiftCLITests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | #if !canImport(ObjectiveC) 2 | import XCTest 3 | 4 | extension ArgumentListTests { 5 | // DO NOT MODIFY: This is autogenerated, use: 6 | // `swift test --generate-linuxmain` 7 | // to regenerate. 8 | static let __allTests__ArgumentListTests = [ 9 | ("testManipulate", testManipulate), 10 | ] 11 | } 12 | 13 | extension CompletionGeneratorTests { 14 | // DO NOT MODIFY: This is autogenerated, use: 15 | // `swift test --generate-linuxmain` 16 | // to regenerate. 17 | static let __allTests__CompletionGeneratorTests = [ 18 | ("testBasicOptions", testBasicOptions), 19 | ("testEscaping", testEscaping), 20 | ("testFunction", testFunction), 21 | ("testGroup", testGroup), 22 | ("testLayered", testLayered), 23 | ("testOptionCompletion", testOptionCompletion), 24 | ("testParameterCompletion", testParameterCompletion), 25 | ("testSepcialCaseOptionCompletion", testSepcialCaseOptionCompletion), 26 | ] 27 | } 28 | 29 | extension HelpMessageGeneratorTests { 30 | // DO NOT MODIFY: This is autogenerated, use: 31 | // `swift test --generate-linuxmain` 32 | // to regenerate. 33 | static let __allTests__HelpMessageGeneratorTests = [ 34 | ("testColoredError", testColoredError), 35 | ("testCommandListGeneration", testCommandListGeneration), 36 | ("testCommandNotFound", testCommandNotFound), 37 | ("testCommandNotSpecified", testCommandNotSpecified), 38 | ("testExpectedValueAfterKey", testExpectedValueAfterKey), 39 | ("testIllegalOptionType", testIllegalOptionType), 40 | ("testInheritedUsageStatementGeneration", testInheritedUsageStatementGeneration), 41 | ("testInvalidOptionValue", testInvalidOptionValue), 42 | ("testInvalidParameterValue", testInvalidParameterValue), 43 | ("testLongDescriptionGeneration", testLongDescriptionGeneration), 44 | ("testMisusedOptionsStatementGeneration", testMisusedOptionsStatementGeneration), 45 | ("testMutlineCommandListGeneration", testMutlineCommandListGeneration), 46 | ("testMutlineUsageStatementGeneration", testMutlineUsageStatementGeneration), 47 | ("testNoCommandMisusedOption", testNoCommandMisusedOption), 48 | ("testOptionGroupMisuse", testOptionGroupMisuse), 49 | ("testParameterCountError", testParameterCountError), 50 | ("testParameterTypeError", testParameterTypeError), 51 | ("testReversedOrderUsageStatementGeneration", testReversedOrderUsageStatementGeneration), 52 | ("testUsageStatementGeneration", testUsageStatementGeneration), 53 | ] 54 | } 55 | 56 | extension InputTests { 57 | // DO NOT MODIFY: This is autogenerated, use: 58 | // `swift test --generate-linuxmain` 59 | // to regenerate. 60 | static let __allTests__InputTests = [ 61 | ("testBool", testBool), 62 | ("testDouble", testDouble), 63 | ("testInt", testInt), 64 | ("testValidation", testValidation), 65 | ] 66 | } 67 | 68 | extension OptionRegistryTests { 69 | // DO NOT MODIFY: This is autogenerated, use: 70 | // `swift test --generate-linuxmain` 71 | // to regenerate. 72 | static let __allTests__OptionRegistryTests = [ 73 | ("testFlagDetection", testFlagDetection), 74 | ("testKeyDetection", testKeyDetection), 75 | ("testMultipleRestrictions", testMultipleRestrictions), 76 | ("testVariadicDetection", testVariadicDetection), 77 | ] 78 | } 79 | 80 | extension ParameterFillerTests { 81 | // DO NOT MODIFY: This is autogenerated, use: 82 | // `swift test --generate-linuxmain` 83 | // to regenerate. 84 | static let __allTests__ParameterFillerTests = [ 85 | ("testCollectedOptionalParameters", testCollectedOptionalParameters), 86 | ("testCollectedRequiredParameters", testCollectedRequiredParameters), 87 | ("testCombinedRequiredAndOptionalParameters", testCombinedRequiredAndOptionalParameters), 88 | ("testCustomParameter", testCustomParameter), 89 | ("testEmptyOptionalCollectedParameter", testEmptyOptionalCollectedParameter), 90 | ("testEmptySignature", testEmptySignature), 91 | ("testOptionalParameters", testOptionalParameters), 92 | ("testOptionalParametersWithInheritance", testOptionalParametersWithInheritance), 93 | ("testParameterInit", testParameterInit), 94 | ("testRequiredParameters", testRequiredParameters), 95 | ("testValidatedParameter", testValidatedParameter), 96 | ] 97 | } 98 | 99 | extension ParserTests { 100 | // DO NOT MODIFY: This is autogenerated, use: 101 | // `swift test --generate-linuxmain` 102 | // to regenerate. 103 | static let __allTests__ParserTests = [ 104 | ("testBeforeCommand", testBeforeCommand), 105 | ("testCollectedOptions", testCollectedOptions), 106 | ("testCombinedFlagsAndKeysAndArgumentsParsing", testCombinedFlagsAndKeysAndArgumentsParsing), 107 | ("testCombinedFlagsAndKeysParsing", testCombinedFlagsAndKeysParsing), 108 | ("testCounterParse", testCounterParse), 109 | ("testFlagGivenValue", testFlagGivenValue), 110 | ("testFlagSplitting", testFlagSplitting), 111 | ("testFullParse", testFullParse), 112 | ("testGroupRestriction", testGroupRestriction), 113 | ("testIllegalOptionFormat", testIllegalOptionFormat), 114 | ("testKeysNotGivenValues", testKeysNotGivenValues), 115 | ("testKeyValueParsing", testKeyValueParsing), 116 | ("testSimpleFlagParsing", testSimpleFlagParsing), 117 | ("testSimpleKeyParsing", testSimpleKeyParsing), 118 | ("testUnrecognizedOptions", testUnrecognizedOptions), 119 | ("testValidation", testValidation), 120 | ("testVaridadicParse", testVaridadicParse), 121 | ] 122 | } 123 | 124 | extension RouterTests { 125 | // DO NOT MODIFY: This is autogenerated, use: 126 | // `swift test --generate-linuxmain` 127 | // to regenerate. 128 | static let __allTests__RouterTests = [ 129 | ("testAliasRoute", testAliasRoute), 130 | ("testFailedRoute", testFailedRoute), 131 | ("testFallback", testFallback), 132 | ("testFallbackOption", testFallbackOption), 133 | ("testGroupFailedRoute", testGroupFailedRoute), 134 | ("testGroupPartialRoute", testGroupPartialRoute), 135 | ("testGroupSuccessRoute", testGroupSuccessRoute), 136 | ("testNameRoute", testNameRoute), 137 | ("testNestedGroup", testNestedGroup), 138 | ("testSingleRouter", testSingleRouter), 139 | ] 140 | } 141 | 142 | extension StreamTests { 143 | // DO NOT MODIFY: This is autogenerated, use: 144 | // `swift test --generate-linuxmain` 145 | // to regenerate. 146 | static let __allTests__StreamTests = [ 147 | ("testCaptureStream", testCaptureStream), 148 | ("testLineStream", testLineStream), 149 | ("testNullStream", testNullStream), 150 | ("testRead", testRead), 151 | ("testReadAll", testReadAll), 152 | ("testReadData", testReadData), 153 | ("testReadFile", testReadFile), 154 | ("testReadLine", testReadLine), 155 | ("testReadLines", testReadLines), 156 | ("testWrite", testWrite), 157 | ("testWriteData", testWriteData), 158 | ("testWriteFile", testWriteFile), 159 | ] 160 | } 161 | 162 | extension SwiftCLITests { 163 | // DO NOT MODIFY: This is autogenerated, use: 164 | // `swift test --generate-linuxmain` 165 | // to regenerate. 166 | static let __allTests__SwiftCLITests = [ 167 | ("testCLIHelp", testCLIHelp), 168 | ("testCommandHelp", testCommandHelp), 169 | ("testFallback", testFallback), 170 | ("testGlobalOptions", testGlobalOptions), 171 | ("testGoWithArguments", testGoWithArguments), 172 | ("testOptionSplit", testOptionSplit), 173 | ("testSingleCommand", testSingleCommand), 174 | ] 175 | } 176 | 177 | extension TaskTests { 178 | // DO NOT MODIFY: This is autogenerated, use: 179 | // `swift test --generate-linuxmain` 180 | // to regenerate. 181 | static let __allTests__TaskTests = [ 182 | ("testBashCapture", testBashCapture), 183 | ("testBashRun", testBashRun), 184 | ("testCapture", testCapture), 185 | ("testCaptureDirectory", testCaptureDirectory), 186 | ("testCurrentDirectory", testCurrentDirectory), 187 | ("testEnv", testEnv), 188 | ("testIn", testIn), 189 | ("testPipe", testPipe), 190 | ("testRun", testRun), 191 | ("testRunDirectory", testRunDirectory), 192 | ("testSignals", testSignals), 193 | ("testTaskLineStream", testTaskLineStream), 194 | ("testTaskNullStream", testTaskNullStream), 195 | ] 196 | } 197 | 198 | extension ValidationTests { 199 | // DO NOT MODIFY: This is autogenerated, use: 200 | // `swift test --generate-linuxmain` 201 | // to regenerate. 202 | static let __allTests__ValidationTests = [ 203 | ("testComparable", testComparable), 204 | ("testEquatable", testEquatable), 205 | ("testString", testString), 206 | ] 207 | } 208 | 209 | public func __allTests() -> [XCTestCaseEntry] { 210 | return [ 211 | testCase(ArgumentListTests.__allTests__ArgumentListTests), 212 | testCase(CompletionGeneratorTests.__allTests__CompletionGeneratorTests), 213 | testCase(HelpMessageGeneratorTests.__allTests__HelpMessageGeneratorTests), 214 | testCase(InputTests.__allTests__InputTests), 215 | testCase(OptionRegistryTests.__allTests__OptionRegistryTests), 216 | testCase(ParameterFillerTests.__allTests__ParameterFillerTests), 217 | testCase(ParserTests.__allTests__ParserTests), 218 | testCase(RouterTests.__allTests__RouterTests), 219 | testCase(StreamTests.__allTests__StreamTests), 220 | testCase(SwiftCLITests.__allTests__SwiftCLITests), 221 | testCase(TaskTests.__allTests__TaskTests), 222 | testCase(ValidationTests.__allTests__ValidationTests), 223 | ] 224 | } 225 | #endif 226 | --------------------------------------------------------------------------------