Pitch: Type-Safe Console Input

Vapor's Console package (which will be renamed to ConsoleKit) is an API for building command-line applications with Swift. This pitch proposes changing the current stringly typed API for getting console input to a type-safe API that would:

  1. Only allow you to attempt to get input that you defined for an app command.
  2. Automatically convert the string input the desired input type.

Current API

In Console 3, when you define a command, your arguments and options look like this:

final class SomeCommand: Command {
    public var arguments: [CommandArgument] = [
        CommandArgument.argument(name: "string", help: ["The string to log to stdout"]),
        CommandArgument.argument(name: "count", help: ["How many times to log the string"])
    ]
    
    public var options: [CommandOption] = [
        CommandOption.flag(name: "verbose", short: "v", help: ["Logs each string on its own line"])
    ]
}

To get the input values, you pass in the name of the argument/option into the console.argument or .option method as a string. Then you have to convert from a string to the type you actually want if it is not a string:

let countStr = try context.console.argument("count")
let count = Int(count)

This stringly typed API has several undesirable attributes, including it being susceptible to typos and requiring extra operations to get the type you want to work with.

Proposed API

The proposed solution to this is to add associatedtype requirements to the Command protocol which will be a cased type (enum or struct with static properties) that represents the valid arguments/options. You will then be able to pass a case into the console.argument or .option method.

Enums

Enums are the most obvious way to go with defining the Arguments and Options types. Here's why we would and wouldn't want to use enums:

Pros:

  • Significantly reduced memory footprint.

Cons:

  • Declaring the name and help values is more verbose.
  • Cannot define generic types to decode the argument or option value to.

A command's signature defined with an enum would look like this:

enum Arguments: String, CommandArgument {
    case string, count

    var help: String? {
        switch self {
        case .string: return "The string to print to srdout"
        case .count: "The number of times to output the string"
        }
    }
 }
    
enum Options: String, CommandOption {
    case verbose

    var help: String? {
        switch self {
        case .verbose: return "List each output on its own line"
        }
    }

    var shortFlag: Character? {
        switch self {
        case .verbose: return "v"
        }
    }
}

Cased Structs

If we used a struct, it wouldn't actually have static cases because using enums would actually be easier. Instead we would use key-paths for the properties.

Pros:

  • Defining the name and help properties is much more concise.
  • You can define generic type to automatically convert argument/option values to.

Cons:

  • Takes more space in memory.

A command's signature defined using structs would look like this:

struct Arguments: CommandArguments {
    var string = Argument<String>(name: "string", help: "The string to print to srdout")
    var count = Argument<Int>(name: "count", help: "The number of times to output the string")
}

struct Options: CommandOptions {
    var verbose = Option<Bool>(name: "verbose", short: "v", help: "List each output on its own line")
}

A couple notes about how the structs would be handled.

  • The Options and Arguments struct would require an initializer that takes no arguments. These instances would then be stored in the command context.
  • The Option and Argument structs would take in the arguments as auto-closures and return the values from computed properties if they are needed.

Raw Input

Sometimes you need to just take in raw input. An UnvalidatedCommand protocol (or some other name) which will allow you to do this.

Call For Discussion

At this point we still need to decide which way to take this, so post your opinions and ideas!

Implementation:

You can see the current implementation and watch development on this PR: https://github.com/vapor/console/pull/92

1 Like

Thanks for this detailed pitch @calebkleveter! I think type-safe command input would be a great feature for Vapor 4.

I like the enum approach quite a bit, but I think that not being able to declare argument type is a deal breaker. Maybe to reduce memory usage for the struct solution, we could make them static?

struct Repeat: Command {
    struct Arguments: CommandArguments {
        let string = Argument<String>(name: "string", help: "The string to print to srdout")
        let count = Argument<Int>(name: "count", help: "The number of times to output the string")
    }

    struct Options: CommandOptions {
        let verbose = Option<Bool>(name: "verbose", short: "v", help: "List each output on its own line")
    }

    static let arguments = Arguments()
    static let options = Options()

    init() {}

    func run(context: CommandContext) -> EventLoopFuture<Void> {
        if self.option(\.verbose, from: context) == true {
            context.console.info("Starting the repeat command...")
        }

        let string = self.argument(\.string, from: context)
        for i in 0..<context.argument(\.count, from: context) {
            context.console.print(string)
        }

        return context.eventLoop.makeSucceededFuture(())
    }
}

Needing to do from: context is not ideal though. We could potentially get around this by making the command context generic:

func run(context: CommandContext<Repeat>) -> EventLoopFuture<Void> {
    if context.option(\.verbose) == true {
        context.console.info("Starting the repeat command...")
    }

    let string = context.argument(\.string)
    for i in 0..<context.argument(\.count) {
        context.console.print(string)
    }

    return context.eventLoop.makeSucceededFuture(())
}

I'm not sure that's any better though. Thoughts?

Using the generic context was actually how I created the original proof of concept for myself when I was originally seeing if this would work, so I think we'll go with that unless someone has a better idea.

Sweet :+1:

Another thing I realized is that, with stored properties, we should be able to implement CommandArguments.all and CommandOptions.all using Mirror. That's the one thing it can actually do reliably :cowboy_hat_face:

Since you bring up Mirror, we can create the name properties for the arguments/options from the property name. Do you have an opinion on that?

Hmm... that could be nice. I'd be interested to see what the implementation for it looks like. In my head I imagine it would need to be kind of hacky. But if it's possible to do it cleanly, I could be convinced.

How important are the memory constraints?

It's not super critical for commands IMO, since they usually only run once at boot. But making more than one copy of the option / argument data is code smell. They are static and there's really no reason to ever copy them.

2 Likes

Side note, I do also really like how this model lines up with Fluent 4's WIP model API:

struct Repeat: Command {
    struct Arguments: CommandArguments {
        let string = Argument<String>(name: "string", help: "The string to print to srdout")
        let count = Argument<Int>(name: "count", help: "The number of times to output the string")
    }
    static let arguments = Arguments()
}
final class Planet: Model {
    struct Properties: ModelProperties {
        let id = Field<Int>("id")
        let name = Field<String>("name")
        let galaxy = Parent<Galaxy>(id: Field("galaxyID"))
    }
    static let properties = Properties()
}

Do you have a practical example of an application that have enough arguments to justify optimising memory footprint of argument declaration ?

I think his argument is more about passing around large-ish structs unnecessarily. Enums with computed properties are much cheaper and faster to pass around than a struct with stored properties.

What if we had a single associated type called Signiture that held both the options and the arguments :thinking:

Ah that’s a great idea. :+1:

1 Like

Here is the PR with a working implementation: https://github.com/vapor/console/pull/92

1 Like