Skip to content

Instantly share code, notes, and snippets.

@kristopherjohnson
Last active June 21, 2018 00:41
Show Gist options
  • Save kristopherjohnson/9df3bec981a0eac422903c9f3712b7f7 to your computer and use it in GitHub Desktop.
Save kristopherjohnson/9df3bec981a0eac422903c9f3712b7f7 to your computer and use it in GitHub Desktop.
Swift 3: Command-line options and arguments parser
import Foundation
extension Array {
/// Returns a new `Array` made by appending a given element to the `Array`.
func appending(_ newElement: Element) -> Array {
var a = Array(self)
a.append(newElement)
return a
}
}
extension String {
/// Returns substring starting from given numeric index to the end of the string.
func substring(fromNumericIndex numericIndex: Int) -> String {
return self.substring(from: self.index(self.startIndex, offsetBy: numericIndex))
}
}
/// Possible types of values for command options.
public enum CommandLineOptionValueType {
case noValue
case string(String)
}
/// The value associated with a command option.
public enum CommandLineOptionValue: CustomStringConvertible {
case noValue
case string(String)
public var description: String {
switch self {
case .noValue:
return "(no value)"
case .string(let s):
return "\"\(s)\""
}
}
}
/// Describes a possible command-line option.
public struct CommandLineOptionDefinition: CustomStringConvertible {
public let name: String
public let letter: Character?
public let valueType: CommandLineOptionValueType
public let briefHelp: String
public init(name: String, letter: Character, valueType: CommandLineOptionValueType, briefHelp: String) {
self.name = name
self.letter = letter
self.valueType = valueType
self.briefHelp = briefHelp
}
public init(name: String, valueType: CommandLineOptionValueType, briefHelp: String) {
self.name = name
self.letter = nil
self.valueType = valueType
self.briefHelp = briefHelp
}
public var description: String {
return "\(helpName) (\(valueType)) \(briefHelp)"
}
/// Return the option name as it should be displayed in descriptions.
///
/// If there is a letter, then the result is "-letter/--name". Otherwise
/// the result is just "--name".
public var helpName: String {
if let letter = letter {
return "-\(letter)/--\(name)"
}
else {
return "--\(name)"
}
}
/// Return a description of the option syntax.
///
/// Returns a string like "-o, --option" or "-o ARG, --option=ARG".
public var helpSyntax: String {
switch valueType {
case .noValue:
if let letter = letter {
return "-\(letter), --\(name)"
}
else {
return "=-\(name)"
}
case .string(let argName):
if let letter = letter {
return "-\(letter) \(argName), --\(name)=\(argName)"
}
else {
return "=-\(name)=\(argName)"
}
}
}
}
/// Print help for a set of option definitions.
public func printHelp(optionDefinitions: [CommandLineOptionDefinition], firstColumnWidth: Int = 40) {
func paddedColumn(_ s: String) -> String {
return s.padding(toLength: firstColumnWidth, withPad: " ", startingAt: 0)
}
for optionDefinition in optionDefinitions {
print(String(format: "%@ %@",
paddedColumn(optionDefinition.helpSyntax),
optionDefinition.briefHelp))
}
}
/// Print help for a set of option definitions to a specified output stream.
public func printHelp<Target: TextOutputStream>(
optionDefinitions: [CommandLineOptionDefinition],
to outputStream: inout Target,
firstColumnWidth: Int = 40)
{
func paddedColumn(_ s: String) -> String {
return s.padding(toLength: firstColumnWidth, withPad: " ", startingAt: 0)
}
for optionDefinition in optionDefinitions {
print(String(format: "%@ %@",
paddedColumn(optionDefinition.helpSyntax),
optionDefinition.briefHelp),
to: &outputStream)
}
}
/// A parsed command line option.
public struct CommandLineOption: CustomStringConvertible {
public let definition: CommandLineOptionDefinition
public let value: CommandLineOptionValue
public var description: String {
return "\(definition.helpName) \(value)"
}
}
/// Errors thrown by `CommandLineParseResult` initializer.
public enum CommandLineParseError: Error, CustomStringConvertible {
case unimplementedFeature(String)
case undefinedOption(String)
case missingOptionParameter(String)
case invalidArgumentSyntax(String)
case valueNotAllowed(String)
case missingValue(String)
public var description: String {
switch self {
case .unimplementedFeature(let name):
return "unimplemented feature: \(name)"
case .undefinedOption(let name):
return "undefined option \"\(name)\""
case .missingOptionParameter(let name):
return "missing value for option \"\(name)\""
case .invalidArgumentSyntax(let message):
return "invalid argument syntax: \(message)"
case .valueNotAllowed(let name):
return "value not allowed for option: \(name)"
case .missingValue(let name):
return "argument required for option: \(name)"
}
}
}
/// Result of parsing a command line.
public struct CommandLineParseResult {
/// Arguments passed to the initializer.
public let arguments: [String]
/// Option definitions passed to the initializer.
public let optionDefinitions: [CommandLineOptionDefinition]
/// The first argument (or `nil` if no arguments were given).
public let program: String?
/// Option arguments.
public let parsedOptions: [CommandLineOption]
/// Non-option arguments.
public let parsedArguments: [String]
public init(arguments: [String], optionDefinitions: [CommandLineOptionDefinition]) throws {
self.arguments = arguments
self.optionDefinitions = optionDefinitions
var iterator = arguments.makeIterator()
self.program = iterator.next()
(self.parsedOptions, self.parsedArguments) = try CommandLineParseResult.parse(
iterator: &iterator,
optionDefinitions: optionDefinitions,
accumulatedOptions: [],
accumulatedArguments: [])
}
/// Return the parsed option with the given name, or `nil` if it was not present.
public func option(named name: String) -> CommandLineOption? {
return parsedOptions.first { $0.definition.name == name }
}
/// Return the value of the parsed option with the given name, or `nil` if it was not present.
public func value(optionNamed name: String) -> CommandLineOptionValue? {
return option(named: name)?.value
}
/// Return `true` if an option with the specified name was parsed, or `false` otherwise.
public func isPresent(optionNamed name: String) -> Bool {
if let _ = option(named: name) {
return true
}
else {
return false
}
}
private static func parse<StringIterator: IteratorProtocol>(
iterator: inout StringIterator,
optionDefinitions: [CommandLineOptionDefinition],
accumulatedOptions: [CommandLineOption],
accumulatedArguments: [String]
) throws -> ([CommandLineOption], [String])
where StringIterator.Element == String
{
guard let argument = iterator.next() else {
return (accumulatedOptions, accumulatedArguments)
}
if argument.hasPrefix("--") && argument.characters.count > 2 {
let (name, value) = try parseNameAndValue(argument.substring(fromNumericIndex: 2))
if let matchingDefinition = optionDefinitions.first(where: { name == $0.name }) {
switch matchingDefinition.valueType {
case .noValue:
// --option
if value != nil {
throw CommandLineParseError.valueNotAllowed(argument)
}
let option = CommandLineOption(definition: matchingDefinition,
value: .noValue)
return try parse(
iterator: &iterator,
optionDefinitions: optionDefinitions,
accumulatedOptions: accumulatedOptions.appending(option),
accumulatedArguments: accumulatedArguments)
case .string:
if let value = value {
// --option=VALUE
let option = CommandLineOption(definition: matchingDefinition,
value: .string(value))
return try parse(
iterator: &iterator,
optionDefinitions: optionDefinitions,
accumulatedOptions: accumulatedOptions.appending(option),
accumulatedArguments: accumulatedArguments)
}
else if let value = iterator.next() {
// --option VALUE
let option = CommandLineOption(definition: matchingDefinition,
value: .string(value))
return try parse(
iterator: &iterator,
optionDefinitions: optionDefinitions,
accumulatedOptions: accumulatedOptions.appending(option),
accumulatedArguments: accumulatedArguments)
}
else {
throw CommandLineParseError.missingValue(argument)
}
}
}
else {
throw CommandLineParseError.undefinedOption(argument)
}
}
if argument.hasPrefix("-") && argument.characters.count > 1 {
let letters = argument.substring(fromNumericIndex: 1)
var options: [CommandLineOption] = []
var skipRemainingLetters = false
for (n, letter) in letters.characters.enumerated() {
if skipRemainingLetters {
continue
}
if let matchingDefinition = optionDefinitions.first(where: { letter == $0.letter }) {
switch matchingDefinition.valueType {
case .noValue:
// -o
options.append(CommandLineOption(definition: matchingDefinition,
value: .noValue))
case .string:
if n != 0 {
throw CommandLineParseError.invalidArgumentSyntax("-\(letter) cannot be grouped with other options because it requires an argument")
}
else if letters.characters.count > 1 {
// -oVALUE
let value = letters.substring(fromNumericIndex: 1)
skipRemainingLetters = true
options.append(CommandLineOption(definition: matchingDefinition,
value: .string(value)))
}
else if let value = iterator.next() {
// -o VALUE
options.append(CommandLineOption(definition: matchingDefinition,
value: .string(value)))
}
else {
throw CommandLineParseError.missingValue("-\(letter)")
}
}
}
else {
throw CommandLineParseError.undefinedOption(argument)
}
}
return try parse(
iterator: &iterator,
optionDefinitions: optionDefinitions,
accumulatedOptions: accumulatedOptions + options,
accumulatedArguments: accumulatedArguments)
}
return try parse(
iterator: &iterator,
optionDefinitions: optionDefinitions,
accumulatedOptions: accumulatedOptions,
accumulatedArguments: accumulatedArguments.appending(argument))
}
/// Split a string into name and value.
///
/// Given a string, look for "=". If found, return portion before "=" and portion after "=".
/// If "=" is not present, return the string and `nil`.
private static func parseNameAndValue(_ s: String) throws -> (String, String?) {
let components = s.components(separatedBy: "=")
switch components.count {
case 0:
throw CommandLineParseError.invalidArgumentSyntax("empty argument")
case 1:
return (components[0], nil)
case 2:
return (components[0], components[1])
default:
throw CommandLineParseError.invalidArgumentSyntax("contains multiple '=' characters")
}
}
}
@kristopherjohnson
Copy link
Author

Example use:

do {
    let optionDefinitions = [
        CommandLineOptionDefinition(
            name: "help",
            letter: "h",
            valueType: .noValue,
            briefHelp: "Show this help message"),

        CommandLineOptionDefinition(
            name: "version",
            letter: "v",
            valueType: .noValue,
            briefHelp: "Show program version"),

        CommandLineOptionDefinition(
            name: "verbose",
            letter: "V",
            valueType: .noValue,
            briefHelp: "Enable verbose output"),

        CommandLineOptionDefinition(
            name: "input-format",
            letter: "i",
            valueType: .string("FMT"),
            briefHelp: "Specify input file format"),

        CommandLineOptionDefinition(
            name: "output-format",
            letter: "o",
            valueType: .string("FMT"),
            briefHelp: "Specify output file file")
    ]

    print("Command options:")
    printHelp(optionDefinitions: optionDefinitions, firstColumnWidth: 30)

    //let result = try CommandLineParseResult(arguments: CommandLine.arguments, optionDefinitions: optionDefinitions)
    let testArguments = ["test", "-hV", "-i", "ASCII", "--output-format=HTML", "file1", "file2"]
    let result = try CommandLineParseResult(arguments: testArguments,
                                            optionDefinitions: optionDefinitions)

    // Lists of parsed options and arguments.
    print()
    print("program: \(result.program)")
    print("parsedOptions: \(result.parsedOptions)")
    print("parsedArguments: \(result.parsedArguments)")

    // Check which options were provided.
    print()
    print("help: \(result.isPresent(optionNamed: "help"))")
    print("verbose: \(result.isPresent(optionNamed: "verbose"))")
    print("version: \(result.isPresent(optionNamed: "version"))")
    if let inputFormat = result.value(optionNamed: "input-format") {
        print("input-format: \(inputFormat)")
    }
    if let outputFormat = result.value(optionNamed: "output-format") {
        print("output-format: \(outputFormat)")
    }
}
catch let error {
    print("Error: \(error)")
}

Gives this output:

Command options:
-h, --help                     Show this help message
-v, --version                  Show program version
-V, --verbose                  Enable verbose output
-i FMT, --input-format=FMT     Specify input file format
-o FMT, --output-format=FMT    Specify output file file

program: Optional("test")
parsedOptions: [--help/-h (no value), --verbose/-V (no value), --input-format/-i "ASCII", --output-format/-o "HTML"]
parsedArguments: ["file1", "file2"]

help: true
verbose: true
version: false
input-format: "ASCII"
output-format: "HTML"

@oderwat
Copy link

oderwat commented Aug 20, 2017

Shouldn't description for string values be better without the "" marks?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment