Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Single character flags #24

Merged
merged 10 commits into from
Apr 6, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Commandant.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
D00CCE2D1A2075ED00109F8C /* ArgumentParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00CCE2C1A2075ED00109F8C /* ArgumentParser.swift */; };
D00CCE2F1A2075F700109F8C /* Option.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00CCE2E1A2075F700109F8C /* Option.swift */; };
D0BF14FB1A4C8957003147BC /* HelpCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0BF14FA1A4C8957003147BC /* HelpCommand.swift */; };
D8169D871ACB942D00923FB0 /* Switch.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8169D861ACB942D00923FB0 /* Switch.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -64,6 +65,7 @@
D00CCE2C1A2075ED00109F8C /* ArgumentParser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArgumentParser.swift; sourceTree = "<group>"; };
D00CCE2E1A2075F700109F8C /* Option.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Option.swift; sourceTree = "<group>"; };
D0BF14FA1A4C8957003147BC /* HelpCommand.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HelpCommand.swift; sourceTree = "<group>"; };
D8169D861ACB942D00923FB0 /* Switch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Switch.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -221,6 +223,7 @@
D00CCE261A20741300109F8C /* Command.swift */,
D00CCE2A1A20748500109F8C /* Errors.swift */,
D00CCE2E1A2075F700109F8C /* Option.swift */,
D8169D861ACB942D00923FB0 /* Switch.swift */,
);
name = Core;
sourceTree = "<group>";
Expand Down Expand Up @@ -343,6 +346,7 @@
D0BF14FB1A4C8957003147BC /* HelpCommand.swift in Sources */,
D00CCE2F1A2075F700109F8C /* Option.swift in Sources */,
D00CCE2B1A20748500109F8C /* Errors.swift in Sources */,
D8169D871ACB942D00923FB0 /* Switch.swift in Sources */,
D00CCE271A20741300109F8C /* Command.swift in Sources */,
D00CCE2D1A2075ED00109F8C /* ArgumentParser.swift in Sources */,
);
Expand Down
72 changes: 57 additions & 15 deletions Commandant/ArgumentParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ private enum RawArgument: Equatable {
/// A value, either associated with an option or passed as a positional
/// argument.
case Value(String)

/// One or more flag arguments (e.g 'r' and 'f' for `-rf`)
case Flag(Set<Character>)
}

private func ==(lhs: RawArgument, rhs: RawArgument) -> Bool {
Expand All @@ -27,6 +30,9 @@ private func ==(lhs: RawArgument, rhs: RawArgument) -> Bool {
case let (.Value(left), .Value(right)):
return left == right

case let (.Flag(left), .Flag(right)):
return left == right

default:
return false
}
Expand All @@ -40,6 +46,9 @@ extension RawArgument: Printable {

case let .Value(value):
return "\"\(value)\""

case let .Flag(flags):
return "-\(String(flags))"
}
}
}
Expand All @@ -51,23 +60,25 @@ public final class ArgumentParser {

/// Initializes the generator from a simple list of command-line arguments.
public init(_ arguments: [String]) {
var permitKeys = true

for arg in arguments {
// Check whether this is a keyed argument.
if permitKeys && arg.hasPrefix("--") {
// Check for -- by itself, which should terminate the keyed
// argument list.
let keyStartIndex = arg.startIndex.successor().successor()
if keyStartIndex == arg.endIndex {
permitKeys = false
} else {
let key = arg.substringFromIndex(keyStartIndex)
rawArguments.append(.Key(key))
}
// The first instance of `--` terminates the option list.
let params = split(arguments, maxSplit: 1, allowEmptySlices: true) { $0 == "--" }

// Parse out the keyed and flag options.
let options = params.first!
rawArguments.extend(options.map { arg in
if arg.hasPrefix("-") {
// Do we have `--{key}` or `-{flags}`.
var opt = dropFirst(arg)
return opt.hasPrefix("-") ? .Key(dropFirst(opt)) : .Flag(Set(opt))
} else {
rawArguments.append(.Value(arg))
return .Value(arg)
}
})

// Remaining arguments are all positional parameters.
if params.count == 2 {
let positional = params.last!
rawArguments.extend(positional.map { .Value($0) })
}
}

Expand Down Expand Up @@ -145,4 +156,35 @@ public final class ArgumentParser {

return nil
}

/// Returns whether the given key was specified and removes it from the
/// list of arguments remaining.
internal func consumeKey(key: String) -> Bool {
let oldArguments = rawArguments
rawArguments = oldArguments.filter { $0 != .Key(key) }

return rawArguments.count < oldArguments.count
}

/// Returns whether the given flag was specified and removes it from the
/// list of arguments remaining.
internal func consumeBooleanFlag(flag: Character) -> Bool {
for (index, arg) in enumerate(rawArguments) {
switch arg {
case var .Flag(flags) where flags.contains(flag):
flags.remove(flag)
if flags.isEmpty {
rawArguments.removeAtIndex(index)
} else {
rawArguments[index] = .Flag(flags)
}
return true

default:
break
}
}

return false
}
}
29 changes: 13 additions & 16 deletions Commandant/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,27 +40,24 @@ internal func missingArgumentError(argumentName: String) -> CommandantError {
return CommandantError.UsageError(description: description)
}

/// Constructs an error by combining the example of key (and value, if applicable)
/// with the usage description.
internal func informativeUsageError(keyValueExample: String, usage: String) -> CommandantError {
let lines = usage.componentsSeparatedByCharactersInSet(NSCharacterSet.newlineCharacterSet())

return .UsageError(description: reduce(lines, keyValueExample) { previous, value in
return previous + "\n\t" + value
})
}

/// Constructs an error that describes how to use the option, with the given
/// example of key (and value, if applicable) usage.
internal func informativeUsageError<T>(keyValueExample: String, option: Option<T>) -> CommandantError {
var description = ""

if option.defaultValue != nil {
description += "["
}

description += keyValueExample

if option.defaultValue != nil {
description += "]"
return informativeUsageError("[\(keyValueExample)]", option.usage)
} else {
return informativeUsageError(keyValueExample, option.usage)
}

description += option.usage.componentsSeparatedByCharactersInSet(NSCharacterSet.newlineCharacterSet())
.reduce(""){ previous, value in
return previous + "\n\t" + value
}

return CommandantError.UsageError(description: description)
}

/// Constructs an error that describes how to use the option.
Expand Down
5 changes: 3 additions & 2 deletions Commandant/Option.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import LlamaKit
/// Represents a record of options for a command, which can be parsed from
/// a list of command-line arguments.
///
/// This is most helpful when used in conjunction with the `Option` type, and
/// `<*>` and `<|` combinators.
/// This is most helpful when used in conjunction with the `Option` and `Switch`
/// types, and `<*>` and `<|` combinators.
///
/// Example:
///
Expand All @@ -30,6 +30,7 @@ import LlamaKit
/// return create
/// <*> m <| Option(key: "verbose", defaultValue: 0, usage: "the verbosity level with which to read the logs")
/// <*> m <| Option(key: "outputFilename", defaultValue: "", usage: "a file to print output to, instead of stdout")
/// <*> m <| Switch(flag: "d", key: "delete", defaultValue: false, usage: "delete the logs when finished")
/// <*> m <| Option(usage: "the log to read")
/// }
/// }
Expand Down
64 changes: 64 additions & 0 deletions Commandant/Switch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// Switch.swift
// Commandant
//
// Created by Neil Pankey on 3/31/15.
// Copyright (c) 2015 Carthage. All rights reserved.
//

import LlamaKit

/// Describes a parameterless command line flag that defaults to false and can only
/// be switched on. Canonical examples include `--force` and `--recurse`.
///
/// For a boolean toggle that can be enabled and disabled use `Option<Bool>`.
public struct Switch {
/// The key that enables this switch. For example, a key of `verbose` would be
/// used for a `--verbose` option.
public let key: String

/// Optional single letter flag that enables this switch. For example, `-v` would
/// be used as a shorthand for `--verbose`.
///
/// Multiple flags can be grouped together as a single argument and will split
/// when parsing (e.g. `rm -rf` treats 'r' and 'f' as inidividual flags).
public let flag: Character?

/// A human-readable string describing the purpose of this option. This will
/// be shown in help messages.
public let usage: String

public init(flag: Character? = nil, key: String, usage: String) {
self.flag = flag
self.key = key
self.usage = usage
}
}

extension Switch: Printable {
public var description: String {
var options = "--\(key)"
if let flag = self.flag {
options += "|-\(flag)"
}
return options
}
}

/// Evaluates the given boolean switch in the given mode.
///
/// If parsing command line arguments, and no value was specified on the command
/// line, the option's `defaultValue` is used.
public func <|(mode: CommandMode, option: Switch) -> Result<Bool, CommandantError> {
switch mode {
case let .Arguments(arguments):
var enabled = arguments.consumeKey(option.key)
if let flag = option.flag {
enabled = arguments.consumeBooleanFlag(flag)
}
return success(enabled)

case .Usage:
return failure(informativeUsageError(option.description, option.usage))
}
}
26 changes: 18 additions & 8 deletions CommandantTests/OptionSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,37 +29,43 @@ class OptionsTypeSpec: QuickSpec {

it("should succeed without optional arguments") {
let value = tryArguments("required").value
let expected = TestOptions(intValue: 42, stringValue: "foobar", optionalFilename: "filename", requiredName: "required", enabled: false)
let expected = TestOptions(intValue: 42, stringValue: "foobar", optionalFilename: "filename", requiredName: "required", enabled: false, force: false, glob: false)
expect(value).to(equal(expected))
}

it("should succeed with some optional arguments") {
let value = tryArguments("required", "--intValue", "3", "fuzzbuzz").value
let expected = TestOptions(intValue: 3, stringValue: "foobar", optionalFilename: "fuzzbuzz", requiredName: "required", enabled: false)
let expected = TestOptions(intValue: 3, stringValue: "foobar", optionalFilename: "fuzzbuzz", requiredName: "required", enabled: false, force: false, glob: false)
expect(value).to(equal(expected))
}

it("should override previous optional arguments") {
let value = tryArguments("required", "--intValue", "3", "--stringValue", "fuzzbuzz", "--intValue", "5", "--stringValue", "bazbuzz").value
let expected = TestOptions(intValue: 5, stringValue: "bazbuzz", optionalFilename: "filename", requiredName: "required", enabled: false)
let expected = TestOptions(intValue: 5, stringValue: "bazbuzz", optionalFilename: "filename", requiredName: "required", enabled: false, force: false, glob: false)
expect(value).to(equal(expected))
}

it("should enable a boolean flag") {
let value = tryArguments("required", "--enabled", "--intValue", "3", "fuzzbuzz").value
let expected = TestOptions(intValue: 3, stringValue: "foobar", optionalFilename: "fuzzbuzz", requiredName: "required", enabled: true)
let expected = TestOptions(intValue: 3, stringValue: "foobar", optionalFilename: "fuzzbuzz", requiredName: "required", enabled: true, force: false, glob: false)
expect(value).to(equal(expected))
}

it("should re-disable a boolean flag") {
let value = tryArguments("required", "--enabled", "--no-enabled", "--intValue", "3", "fuzzbuzz").value
let expected = TestOptions(intValue: 3, stringValue: "foobar", optionalFilename: "fuzzbuzz", requiredName: "required", enabled: false)
let expected = TestOptions(intValue: 3, stringValue: "foobar", optionalFilename: "fuzzbuzz", requiredName: "required", enabled: false, force: false, glob: false)
expect(value).to(equal(expected))
}

it("should enable multiple boolean flags") {
let value = tryArguments("required", "-fg").value
let expected = TestOptions(intValue: 42, stringValue: "foobar", optionalFilename: "filename", requiredName: "required", enabled: false, force: true, glob: true)
expect(value).to(equal(expected))
}

it("should treat -- as the end of valued options") {
let value = tryArguments("--", "--intValue").value
let expected = TestOptions(intValue: 42, stringValue: "foobar", optionalFilename: "filename", requiredName: "--intValue", enabled: false)
let expected = TestOptions(intValue: 42, stringValue: "foobar", optionalFilename: "filename", requiredName: "--intValue", enabled: false, force: false, glob: false)
expect(value).to(equal(expected))
}
}
Expand All @@ -82,9 +88,11 @@ struct TestOptions: OptionsType, Equatable {
let optionalFilename: String
let requiredName: String
let enabled: Bool
let force: Bool
let glob: Bool

static func create(a: Int)(b: String)(c: String)(d: String)(e: Bool) -> TestOptions {
return self(intValue: a, stringValue: b, optionalFilename: d, requiredName: c, enabled: e)
static func create(a: Int)(b: String)(c: String)(d: String)(e: Bool)(f: Bool)(g: Bool) -> TestOptions {
return self(intValue: a, stringValue: b, optionalFilename: d, requiredName: c, enabled: e, force: f, glob: g)
}

static func evaluate(m: CommandMode) -> Result<TestOptions, CommandantError> {
Expand All @@ -94,6 +102,8 @@ struct TestOptions: OptionsType, Equatable {
<*> m <| Option(usage: "A name you're required to specify")
<*> m <| Option(defaultValue: "filename", usage: "A filename that you can optionally specify")
<*> m <| Option(key: "enabled", defaultValue: false, usage: "Whether to be enabled")
<*> m <| Switch(flag: "f", key: "force", usage: "Whether to force")
<*> m <| Switch(flag: "g", key: "glob", usage: "Whether to glob")
}
}

Expand Down