Skip to content

Commit

Permalink
Support an async entry point for commands (#404)
Browse files Browse the repository at this point in the history
Adds a new `AsyncParsableCommand` protocol, which provides a
`static func main() async` entry point and can call through to the root
command's or a subcommand's asynchronous `run()` method. For this
asynchronous execution, the root command must conform to `AsyncParsableCommand`,
but its subcommands can be a mix of asynchronous and synchronous commands.

Due to an issue in Swift 5.5, you can only use `@main` on an
`AsyncParsableCommand` root command starting in Swift 5.6.
This change also includes a workaround for clients that are using Swift 5.5.
Declare a separate type that conforms to `AsyncMainProtocol` and add the `@main`
attribute to that type.

```
@main enum Main: AsyncMain {
    typealias Command = <#command#>
}
```
  • Loading branch information
natecook1000 committed Mar 14, 2022
1 parent 357c419 commit 1141ed1
Show file tree
Hide file tree
Showing 15 changed files with 298 additions and 146 deletions.
75 changes: 75 additions & 0 deletions Examples/count-lines/CountLines.swift
@@ -0,0 +1,75 @@
//===----------------------------------------------------------*- swift -*-===//
//
// This source file is part of the Swift Argument Parser open source project
//
// Copyright (c) 2020 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

import ArgumentParser
import Foundation

@main
struct CountLines: AsyncParsableCommand {
@Argument(
help: "A file to count lines in. If omitted, counts the lines of stdin.",
completion: .file(), transform: URL.init(fileURLWithPath:))
var inputFile: URL?

@Option(help: "Only count lines with this prefix.")
var prefix: String?

@Flag(help: "Include extra information in the output.")
var verbose = false
}

extension CountLines {
var fileHandle: FileHandle {
get throws {
guard let inputFile = inputFile else {
return .standardInput
}
return try FileHandle(forReadingFrom: inputFile)
}
}

func printCount(_ count: Int) {
guard verbose else {
print(count)
return
}

if let filename = inputFile?.lastPathComponent {
print("Lines in '\(filename)'", terminator: "")
} else {
print("Lines from stdin", terminator: "")
}

if let prefix = prefix {
print(", prefixed by '\(prefix)'", terminator: "")
}

print(": \(count)")
}

mutating func run() async throws {
guard #available(macOS 12, *) else {
print("'count-lines' isn't supported on this platform.")
return
}

let countAllLines = prefix == nil
let lineCount = try await fileHandle.bytes.lines.reduce(0) { count, line in
if countAllLines || line.starts(with: prefix!) {
return count + 1
} else {
return count
}
}

printCount(lineCount)
}
}
3 changes: 1 addition & 2 deletions Examples/math/main.swift → Examples/math/Math.swift
Expand Up @@ -11,6 +11,7 @@

import ArgumentParser

@main
struct Math: ParsableCommand {
// Customize your command's help and subcommands by implementing the
// `configuration` property.
Expand Down Expand Up @@ -242,5 +243,3 @@ func customCompletion(_ s: [String]) -> [String] {
? ["aardvark", "aaaaalbert"]
: ["hello", "helicopter", "heliotrope"]
}

Math.main()
3 changes: 1 addition & 2 deletions Examples/repeat/main.swift → Examples/repeat/Repeat.swift
Expand Up @@ -11,6 +11,7 @@

import ArgumentParser

@main
struct Repeat: ParsableCommand {
@Option(help: "The number of times to repeat 'phrase'.")
var count: Int?
Expand All @@ -33,5 +34,3 @@ struct Repeat: ParsableCommand {
}
}
}

Repeat.main()
53 changes: 32 additions & 21 deletions Package.swift
@@ -1,4 +1,4 @@
// swift-tools-version:5.2
// swift-tools-version:5.5
//===----------------------------------------------------------*- swift -*-===//
//
// This source file is part of the Swift Argument Parser open source project
Expand All @@ -14,6 +14,7 @@ import PackageDescription

var package = Package(
name: "swift-argument-parser",
platforms: [.macOS(.v10_15), .macCatalyst(.v13), .iOS(.v13), .tvOS(.v13), .watchOS(.v6)],
products: [
.library(
name: "ArgumentParser",
Expand All @@ -23,48 +24,58 @@ var package = Package(
targets: [
.target(
name: "ArgumentParser",
dependencies: ["ArgumentParserToolInfo"]),
dependencies: ["ArgumentParserToolInfo"],
exclude: ["CMakeLists.txt"]),
.target(
name: "ArgumentParserTestHelpers",
dependencies: ["ArgumentParser", "ArgumentParserToolInfo"]),
dependencies: ["ArgumentParser", "ArgumentParserToolInfo"],
exclude: ["CMakeLists.txt"]),
.target(
name: "ArgumentParserToolInfo",
dependencies: []),
dependencies: [],
exclude: ["CMakeLists.txt"]),

.target(
.executableTarget(
name: "roll",
dependencies: ["ArgumentParser"],
path: "Examples/roll"),
.target(
.executableTarget(
name: "math",
dependencies: ["ArgumentParser"],
path: "Examples/math"),
.target(
.executableTarget(
name: "repeat",
dependencies: ["ArgumentParser"],
path: "Examples/repeat"),

.target(
name: "changelog-authors",
dependencies: ["ArgumentParser"],
path: "Tools/changelog-authors"),

.testTarget(
name: "ArgumentParserEndToEndTests",
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"]),
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"],
exclude: ["CMakeLists.txt"]),
.testTarget(
name: "ArgumentParserUnitTests",
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"]),
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"],
exclude: ["CMakeLists.txt"]),
.testTarget(
name: "ArgumentParserPackageManagerTests",
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"],
exclude: ["CMakeLists.txt"]),
.testTarget(
name: "ArgumentParserExampleTests",
dependencies: ["ArgumentParserTestHelpers"]),
dependencies: ["ArgumentParserTestHelpers"],
resources: [.copy("CountLinesTest.txt")]),
]
)

#if swift(>=5.2)
// Skip if < 5.2 to avoid issue with nested type synthesized 'CodingKeys'
package.targets.append(
.testTarget(
name: "ArgumentParserPackageManagerTests",
dependencies: ["ArgumentParser", "ArgumentParserTestHelpers"]))
#if swift(>=5.6) && os(macOS)
package.targets.append(contentsOf: [
.executableTarget(
name: "count-lines",
dependencies: ["ArgumentParser"],
path: "Examples/count-lines"),
.executableTarget(
name: "changelog-authors",
dependencies: ["ArgumentParser"],
path: "Tools/changelog-authors"),
])
#endif
71 changes: 0 additions & 71 deletions Package@swift-5.5.swift

This file was deleted.

1 change: 1 addition & 0 deletions Sources/ArgumentParser/CMakeLists.txt
Expand Up @@ -14,6 +14,7 @@ add_library(ArgumentParser
"Parsable Properties/Option.swift"
"Parsable Properties/OptionGroup.swift"

"Parsable Types/AsyncParsableCommand.swift"
"Parsable Types/CommandConfiguration.swift"
"Parsable Types/EnumerableFlag.swift"
"Parsable Types/ExpressibleByArgument.swift"
Expand Down
54 changes: 54 additions & 0 deletions Sources/ArgumentParser/Parsable Types/AsyncParsableCommand.swift
@@ -0,0 +1,54 @@
//===----------------------------------------------------------*- swift -*-===//
//
// This source file is part of the Swift Argument Parser open source project
//
// Copyright (c) 2020 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

/// A type that can be executed as part of a nested tree of commands.
public protocol AsyncParsableCommand: ParsableCommand {
mutating func run() async throws
}

extension AsyncParsableCommand {
public static func main() async {
do {
var command = try parseAsRoot()
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
try command.run()
}
} catch {
exit(withError: error)
}
}
}

@available(
swift, deprecated: 5.6,
message: "Use @main directly on your root `AsyncParsableCommand` type.")
public protocol AsyncMainProtocol {
associatedtype Command: ParsableCommand
}

@available(swift, deprecated: 5.6)
extension AsyncMainProtocol {
public static func main() async {
do {
var command = try Command.parseAsRoot()
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
try command.run()
}
} catch {
Command.exit(withError: error)
}
}
}

2 changes: 1 addition & 1 deletion Sources/ArgumentParserTestHelpers/TestHelpers.swift
Expand Up @@ -249,7 +249,7 @@ extension XCTest {

let outputData = output.fileHandleForReading.readDataToEndOfFile()
let outputActual = String(data: outputData, encoding: .utf8)!.trimmingCharacters(in: .whitespacesAndNewlines)

let errorData = error.fileHandleForReading.readDataToEndOfFile()
let errorActual = String(data: errorData, encoding: .utf8)!.trimmingCharacters(in: .whitespacesAndNewlines)

Expand Down
43 changes: 43 additions & 0 deletions Tests/ArgumentParserExampleTests/CountLinesExampleTests.swift
@@ -0,0 +1,43 @@
//===----------------------------------------------------------*- swift -*-===//
//
// This source file is part of the Swift Argument Parser open source project
//
// Copyright (c) 2020 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

#if os(macOS) && swift(>=5.6)

import XCTest
import ArgumentParserTestHelpers

final class CountLinesExampleTests: XCTestCase {
func testCountLines() throws {
guard #available(macOS 12, *) else { return }
let testFile = try XCTUnwrap(Bundle.module.url(forResource: "CountLinesTest", withExtension: "txt"))
try AssertExecuteCommand(command: "count-lines \(testFile.path)", expected: "20")
try AssertExecuteCommand(command: "count-lines \(testFile.path) --prefix al", expected: "4")
}

func testCountLinesHelp() throws {
guard #available(macOS 12, *) else { return }
let helpText = """
USAGE: count-lines <input-file> [--prefix <prefix>] [--verbose]
ARGUMENTS:
<input-file> A file to count lines in. If omitted, counts the
lines of stdin.
OPTIONS:
--prefix <prefix> Only count lines with this prefix.
--verbose Include extra information in the output.
-h, --help Show help information.
"""
try AssertExecuteCommand(command: "count-lines -h", expected: helpText)
}
}

#endif

0 comments on commit 1141ed1

Please sign in to comment.