Supporting Interactive Input in CLI Tools Using Property Wrappers
Previous posts covered how important a great developer experience is when creating CLI tools. Adding colors and styles, writing clear help information and error messages are some of the qualities one can invest efforts into. Although not relevant for CI environments, another important feature a tool can have is to support interactivity: when an argument is missing, instead of exiting with an error and printing a list of required arguments, a tool can ask the user for input.
An example of this behavior can be found in the GitHub CLI. When creating a new repository, the tool asks a few questions:
Interactivity is a broad topic, but in this post you’ll learn how to read simple inputs in a command line, and also how this can be improved by scoping this logic into a property wrapper to facilitate code reuse.
readLine()
Method
The In the Swift standard library, there is a global function that can read input from: readLine()
. When it is called, the execution waits until a string is returned in the standard input. (You can find the documentation here.
A short example is the following script:
print("What's your name?")
guard let name = readLine(), !name.isEmpty else {
print("Missing name")
exit(1)
}
print("Hello, (name)!")
And this is how it looks like in action:
Notice how readLine()
returns an optional, and the script checks also that the string is not empty. In that case, it prints a message, and exits with a non-zero code indicating an error.
While this pattern is enough in this example, having to call this multiple times might become an overhead. Even more when there are additional logic steps, such as performing validation - in this case, making sure that the string isn’t empty. Another issue might exist if you want to store this value in some property, so you can use it from different methods, instead of passing it as a function parameter everywhere it’s needed.
Property Wrappers to the Rescue
Using property wrappers is a perfect use case for a case like this, and it solves both issues - wrapping shared logic, and also being able to store it as a property. As the Swift Programming Language guide defines:
A property wrapper adds a layer of separation between code that manages how a property is stored and the code that defines a property.
Here is a short recap of how a property wrapper works and how it can be used:
@propertyWrapper
struct SmallNumber {
private var maximum: Int
private var number: Int
var wrappedValue: Int {
get { number }
set { number = min(newValue, maximum) }
}
init(wrappedValue: Int, maximum: Int) {
self.maximum = maximum
number = min(wrappedValue, maximum)
}
}
Then, it can be used in the following way:
@SmallNumber(maximum: 10) var myNumber = 20
print(myNumber) // prints 10, not 20
Notice the @propertyWrapper
keyword that is added to the struct, and the min(...)
logic present in both the initializer and the wrappedValue
setter. This allows adding the @SmallNumber
annotation to the property, and all subsequent access to the property, in fact uses the wrappedValue
. Therefore, in this example, it is enough to print myNumber
, which prints the limited number of 10 instead of 20.
You know can start imagining how you can use this same pattern when reading multiple inputs.
Creating the Question Property Wrapper
A logic following the similar pattern can be applied to wrap the readLine()
call. To begin, create a type that will contain two properties: a question (the text to be asked to the user), and another one to store the answer. Because the latter needs will be mutated, it is necessary to use a class, and not a struct:
@propertyWrapper
class Question {
private let text: String
private var answer: String?
var wrappedValue: String {
answer ?? ask()
}
init(_ text: String) {
self.text = text
}
}
This is a simple beginning. Notice that the wrappedValue
getter uses the answer, if there’s been recorded one. But if you try to building this, the compiler will complain the ask()
function is not defined. Also, you might be wondering where is the call to readLine()
? No worries, these two points are exactly what’s missing next in this class:
func ask() -> String {
//1
print(text, terminator: " ")
//2
let stringAnswer = readLine() ?? ""
guard !stringAnswer.isEmpty else {
print("(stringAnswer) is empty")
return ask()
}
//3
self.answer = stringAnswer
return stringAnswer
}
Breaking apart this function:
- Print the question to the user. To keep the answer displayed in the same line, use a space as the terminator parameter
- Call the
readLine()
method, and if the answer contains an empty string, ask again - Finally, store the answer so this question doesn’t need to be asked again, and return it. Here is the bit that requires the type to be a class, and not a struct.
This way, the question will only be asked once, whenever the property is used first in the code.
Using the Property Wrapper
Now you can use this wrapper. The nice thing about property wrappers, is that they also be added to local variables, so the code below also works:
func run() {
@Question("What is your name?")
var name: String
@Question("What is your hobby?")
var hobby: String
print("Hello, (name), it's great you like (hobby)!")
}
run()
Running the script above works quite well:
You might have noticed that the Question
type only works with strings, and this is a limitation. It can be improved by making use of generics, although it might not be trivial to implement the conversion from a string to other types - even custom types.
If you have used the argument parser before, you might remember there is something that serves exactly for this purpose: the ExpressibleByArgument
protocol!
Usage with the Argument Parser
There are a few improvements that can be done to the class above:
- Make it a generic type, constrained to the
ExpressibleByArgument
protocol - To allow using it inside an argument parser
Command
, it needs to conform toDecodable
(even though it won’t be actually decoded in runtime), and the class needs to be marked asfinal
- To allow the question to be asked before using the property, the property wrapper itself should be exposed, so it can be reached with the
$
sign, via$myQuestion
. This can be done using theprojectedValue
property.
Unifying all of the improvements above, marked with the respective numbers as comments, this is what the final Question
class looks like:
@propertyWrapper
//1: T is ExpressibleByArgument
//2: final and Decodable
public final class Question<T: ExpressibleByArgument>: Decodable {
private let text: String
private var answer: T? //1: Now this isn't String anymore
//2: To satisfy the compiler:
public init(from decoder: Decoder) throws { fatalError() }
public var wrappedValue: T {
answer ?? ask()
}
//3: Expose the property wrapper to allow using the `$` sign
public var projectedValue: Question<T> { self }
public init(_ text: String) {
self.text = text
}
// 3: Mark this function with discardableResult to allow ignoring the return value
@discardableResult
public func ask() -> T {
print(text, terminator: " ")
let stringAnswer = readLine() ?? ""
// Convert from a string to T, using the init(argument:) initializer
guard let asArgument = T(argument: stringAnswer) else {
print("(stringAnswer) is not a valid (T.self)")
return ask()
}
self.answer = asArgument
return asArgument
}
}
These changes will allow the usage inside a type that conforms to ParsableCommand
:
import ArgumentParser
@main
struct InteractiveConsole: ParsableCommand {
@Option
var path: String = "."
@Question("What is your name?")
var name: String
@Question("How old are you?")
var age: Int
mutating func run() throws {
print("Path is (path)")
$name.ask()
print("Hello there, (name)!")
print("Hey, (name), you look like you're (age - 10)!")
}
}
A few parts worth highlighting:
- Now a question can be used with an
Int
type, or any other type that can be converted from a string using theinit(argument:)
initializer - There is no need to call directly the
$question.ask()
function, but it’s nice to have control over the timing - The question is asked only once, while the property can be used multiple times and in different
When running the code above, you this is what it will look like:
More Support for Interactivity in the Argument Parser
Although this type is fine as it is, it is not so simple or elegant to have them working along with @Argument
and @Option
. The argument parser could have this feature built in for these two types - if an argument is missing in the command, it could ask the user interactively.
There is a branch in the argument parser repository that adds interactivity, but it had no new commits for a year already. It would be nice to see more improvements in this area, or at least some sort of transparency over the direction of the feature.
If you would like to see this set of features as well, reply with your thoughts or reactions to this topic in the Swift forums.
Explore Further
Thanks for following until the end! Please share your thoughts, comments or questions with us at X or Mastodon.
As a challenge, you could try implementing a way of mixing the Question
type with the Argument
and Option
property wrappers from the argument parser: make the question to be asked only when the argument is not present.
Also, you can keep exploring the world of interactivity in the terminal, by looking into the branch mentioned above, and also ConsoleKit, by Vapor (more especifically the files in the Sources/ConsoleKitTerminal/Input directory).
See you at the next post. Have a good one!