Skip to content

Commit 9e7654d

Browse files
committed
Implement declarative protocol for declaring command-line tools with command-line flags.
1 parent c66994d commit 9e7654d

File tree

7 files changed

+569
-13
lines changed

7 files changed

+569
-13
lines changed

CommandLineKit.xcodeproj/project.pbxproj

+8
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
CC23248A2087DA70007D582B /* BackgroundColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC2324892087DA70007D582B /* BackgroundColor.swift */; };
1212
CC23248C2087E6EB007D582B /* TextProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC23248B2087E6EB007D582B /* TextProperties.swift */; };
1313
CC232490208945B8007D582B /* Terminal.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC23248F208945B8007D582B /* Terminal.swift */; };
14+
CC3A9BC32A92E7D3005A305E /* Command.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3A9BC22A92E7D3005A305E /* Command.swift */; };
15+
CC3A9BC52A92EA48005A305E /* FlagWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC3A9BC42A92EA48005A305E /* FlagWrapper.swift */; };
1416
CC7A60EB207A62A5007376A0 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC7A60EA207A62A5007376A0 /* main.swift */; };
1517
CCFC2AD7207A636B00EDDADD /* CommandLineKit.h in Headers */ = {isa = PBXBuildFile; fileRef = CCFC2AD5207A636B00EDDADD /* CommandLineKit.h */; settings = {ATTRIBUTES = (Public, ); }; };
1618
CCFC2ADA207A636B00EDDADD /* CommandLineKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CCFC2AD3207A636B00EDDADD /* CommandLineKit.framework */; };
@@ -69,6 +71,8 @@
6971
CC2324892087DA70007D582B /* BackgroundColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundColor.swift; sourceTree = "<group>"; };
7072
CC23248B2087E6EB007D582B /* TextProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextProperties.swift; sourceTree = "<group>"; };
7173
CC23248F208945B8007D582B /* Terminal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Terminal.swift; sourceTree = "<group>"; };
74+
CC3A9BC22A92E7D3005A305E /* Command.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Command.swift; sourceTree = "<group>"; };
75+
CC3A9BC42A92EA48005A305E /* FlagWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagWrapper.swift; sourceTree = "<group>"; };
7276
CC5E472E20C4260700F21B03 /* LinuxMain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LinuxMain.swift; sourceTree = "<group>"; };
7377
CC5E473020C4263400F21B03 /* LinuxMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LinuxMain.swift; path = Tests/LinuxMain.swift; sourceTree = SOURCE_ROOT; };
7478
CC7A60E7207A62A5007376A0 /* CommandLineKitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CommandLineKitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -168,6 +172,8 @@
168172
CCFC2AE0207A640700EDDADD /* Flag.swift */,
169173
CCFC2AE2207A662F00EDDADD /* Flags.swift */,
170174
CCFC2AE4207A693000EDDADD /* FlagError.swift */,
175+
CC3A9BC42A92EA48005A305E /* FlagWrapper.swift */,
176+
CC3A9BC22A92E7D3005A305E /* Command.swift */,
171177
CCFC2AE6207A695B00EDDADD /* ConvertibleFromString.swift */,
172178
CC23248F208945B8007D582B /* Terminal.swift */,
173179
CC23248B2087E6EB007D582B /* TextProperties.swift */,
@@ -350,6 +356,7 @@
350356
CCFC2AE7207A695B00EDDADD /* ConvertibleFromString.swift in Sources */,
351357
CC232490208945B8007D582B /* Terminal.swift in Sources */,
352358
CCFC2AE3207A662F00EDDADD /* Flags.swift in Sources */,
359+
CC3A9BC32A92E7D3005A305E /* Command.swift in Sources */,
353360
CCFC2B02207A6B2C00EDDADD /* LineReaderHistory.swift in Sources */,
354361
CCFC2AFC207A6ACA00EDDADD /* LineReader.swift in Sources */,
355362
CCFC2B06207A6B6700EDDADD /* AnsiCodes.swift in Sources */,
@@ -358,6 +365,7 @@
358365
CC23248A2087DA70007D582B /* BackgroundColor.swift in Sources */,
359366
CCFC2AFE207A6AF100EDDADD /* LineReaderError.swift in Sources */,
360367
CCFC2AFA207A6AAE00EDDADD /* ControlCharacters.swift in Sources */,
368+
CC3A9BC52A92EA48005A305E /* FlagWrapper.swift in Sources */,
361369
);
362370
runOnlyForDeploymentPostprocessing = 0;
363371
};

README.md

+72-8
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
[![Platform: macOS](https://img.shields.io/badge/Platform-macOS-blue.svg?style=flat)](https://developer.apple.com/osx/)
44
[![Platform: Linux](https://img.shields.io/badge/Platform-Linux-blue.svg?style=flat)](https://www.ubuntu.com/)
5-
[![Language: Swift 5.5](https://img.shields.io/badge/Language-Swift%205.5-green.svg?style=flat)](https://developer.apple.com/swift/)
6-
[![IDE: Xcode 13](https://img.shields.io/badge/IDE-Xcode%2013-orange.svg?style=flat)](https://developer.apple.com/xcode/)
5+
[![Language: Swift 5.8](https://img.shields.io/badge/Language-Swift%205.8-green.svg?style=flat)](https://developer.apple.com/swift/)
6+
[![IDE: Xcode 14](https://img.shields.io/badge/IDE-Xcode%2014-orange.svg?style=flat)](https://developer.apple.com/xcode/)
77
[![Carthage: compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage)
88
[![License: BSD](https://img.shields.io/badge/License-BSD-lightgrey.svg?style=flat)](https://developers.google.com/open-source/licenses/bsd)
99

@@ -56,7 +56,7 @@ example: `--size 2 4 8 16`. The sequence is terminated by either the end of the
5656
another flag, or the terminator "---". All command-line arguments following the terminator are not being parsed
5757
and are returned in the `parameters` field of the `Flags` object.
5858

59-
### Example
59+
### Programmatic API
6060

6161
Here is an [example](https://github.com/objecthub/swift-lispkit/blob/master/Sources/LispKitRepl/main.swift)
6262
from the [LispKit](https://github.com/objecthub/swift-lispkit) project. It uses factory methods (like `flags.string`,
@@ -73,7 +73,7 @@ let filePaths = flags.strings("f", "filepath",
7373
description: "Adds file path in which programs are searched for.")
7474
let libPaths = flags.strings("l", "libpath",
7575
description: "Adds file path in which libraries are searched for.")
76-
let heapSize = flags.int("h", "heapsize",
76+
let heapSize = flags.int("x", "heapsize",
7777
description: "Initial capacity of the heap", value: 1000)
7878
let importLibs = flags.strings("i", "import",
7979
description: "Imports library automatically after startup.")
@@ -157,6 +157,70 @@ if let file = prelude.value {
157157
}
158158
```
159159

160+
### Declarative API
161+
162+
The code below illustrates how to combine the `Command` protocol with property wrappers
163+
declaring the various command-line flags. The whole lifecycle of a command-line tool that
164+
is declared like this will be managed automatically. After flags are being parsed, either
165+
methods `run()` or `fail(with:)` are being called (depending on whether flag parsing
166+
succeeds or fails).
167+
168+
```swift
169+
@main struct LispKitRepl: Command {
170+
@CommandArguments(short: "f", description: "Adds file path in which programs are searched for.")
171+
var filePath: [String]
172+
@CommandArguments(short: "l", description: "Adds file path in which libraries are searched for.")
173+
var libPaths: [String]
174+
@CommandArgument(short: "x", description: "Initial capacity of the heap")
175+
var heapSize: Int = 1234
176+
...
177+
@CommandOption(short: "h", description: "Show description of usage and options of this tools.")
178+
var help: Bool
179+
@CommandParameters // Inject the unparsed parameters
180+
var params: [String]
181+
@CommandFlags // Inject the flags object
182+
var flags: Flags
183+
184+
mutating func fail(with reason: String) throws {
185+
print(reason)
186+
exit(1)
187+
}
188+
189+
mutating func run() throws {
190+
// If help flag was provided, print usage description and exit tool
191+
if help {
192+
print(flags.usageDescription(usageName: TextStyle.bold.properties.apply(to: "usage:"),
193+
synopsis: "[<option> ...] [---] [<program> <arg> ...]",
194+
usageStyle: TextProperties.none,
195+
optionsName: TextStyle.bold.properties.apply(to: "options:"),
196+
flagStyle: TextStyle.italic.properties),
197+
terminator: "")
198+
exit(0)
199+
}
200+
...
201+
// Define how optional messages and errors are printed
202+
func printOpt(_ message: String) {
203+
if !quiet {
204+
print(message)
205+
}
206+
}
207+
...
208+
// Set heap size
209+
virtualMachine.setHeapSize(heapSize)
210+
...
211+
// Register all file paths
212+
for path in filePaths {
213+
virtualMachine.fileHandler.register(path)
214+
}
215+
...
216+
// Load prelude file if it was provided via flag `prelude`
217+
if let file = prelude {
218+
virtualMachine.load(file)
219+
}
220+
}
221+
}
222+
```
223+
160224
## Text style and colors
161225

162226
CommandLineKit provides a
@@ -231,13 +295,13 @@ if let ln = LineReader() {
231295

232296
## Requirements
233297

234-
- [Xcode 13](https://developer.apple.com/xcode/)
235-
- [Swift 5.5](https://developer.apple.com/swift/)
298+
- [Xcode 14](https://developer.apple.com/xcode/)
299+
- [Swift 5.8](https://developer.apple.com/swift/)
236300
- [Carthage](https://github.com/Carthage/Carthage)
237301
- [Swift Package Manager](https://swift.org/package-manager/)
238302

239303
## Copyright
240304

241-
Author: Matthias Zenger (<matthias@objecthub.net>)
242-
Copyright © 2018-2021 Google LLC.
305+
Author: Matthias Zenger (<matthias@objecthub.com>)
306+
Copyright © 2018-2023 Google LLC.
243307
_Please note: This is not an official Google product._

Sources/CommandLineKit/Command.swift

+168
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
//
2+
// Command.swift
3+
// CommandLineKit
4+
//
5+
// Created by Matthias Zenger on 20/08/2023.
6+
// Copyright © 2023 Matthias Zenger. All rights reserved.
7+
//
8+
// Redistribution and use in source and binary forms, with or without
9+
// modification, are permitted provided that the following conditions are met:
10+
//
11+
// * Redistributions of source code must retain the above copyright notice,
12+
// this list of conditions and the following disclaimer.
13+
//
14+
// * Redistributions in binary form must reproduce the above copyright notice,
15+
// this list of conditions and the following disclaimer in the documentation
16+
// and/or other materials provided with the distribution.
17+
//
18+
// * Neither the name of the copyright holder nor the names of its contributors
19+
// may be used to endorse or promote products derived from this software without
20+
// specific prior written permission.
21+
//
22+
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
23+
// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
24+
// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
25+
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
26+
// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
27+
// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
28+
// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
29+
// ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
30+
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
31+
// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
32+
//
33+
34+
import Foundation
35+
36+
///
37+
/// The command protocol implements an API for command-line tools. A `Flags` object
38+
/// gets automatically instantiated and flags, declared with flag property wrappers,
39+
/// are automatically registered with the `Flags` object. If flag parsing results in
40+
/// an error, the `fail(with: String)` method is called. Otherwise, `run()` gets
41+
/// executed.
42+
///
43+
public protocol Command {
44+
static var name: String { get }
45+
static var arguments: [String] { get }
46+
init()
47+
mutating func run() throws
48+
mutating func fail(with: String) throws
49+
}
50+
51+
extension Command {
52+
53+
public mutating func fail(with reason: String) {
54+
print(reason)
55+
exit(1)
56+
}
57+
58+
public static var name: String {
59+
if let toolPath = CommandLine.arguments.first {
60+
return URL(fileURLWithPath: toolPath).lastPathComponent
61+
} else {
62+
let str = String(describing: self)
63+
if let i = str.firstIndex(of: "("), i > str.startIndex, i < str.endIndex {
64+
return String(str[str.startIndex..<i])
65+
} else {
66+
return str
67+
}
68+
}
69+
}
70+
71+
public static var arguments: [String] {
72+
var args = CommandLine.arguments
73+
args.removeFirst()
74+
return args
75+
}
76+
77+
public static func newFlags() -> Flags {
78+
return Flags(toolName: Self.name, arguments: Self.arguments)
79+
}
80+
81+
public static func argumentNameConverter() -> ArgumentNameConverter {
82+
return ArgumentNameConverter(Self.argumentNamingStrategy())
83+
}
84+
85+
public static func argumentNamingStrategy() -> ArgumentNameConverter.Strategy {
86+
return .lowercase
87+
}
88+
89+
public static func main() throws {
90+
var command = Self()
91+
let flags = Self.newFlags()
92+
let converter = Self.argumentNameConverter()
93+
let children = Mirror(reflecting: command).children
94+
for child in children {
95+
if let wrapper = child.value as? FlagWrapper {
96+
if let label = child.label {
97+
if label.hasPrefix("_") {
98+
wrapper.register(as: converter.convert(String(label.dropFirst())), with: flags)
99+
} else {
100+
wrapper.register(as: converter.convert(label), with: flags)
101+
}
102+
} else {
103+
wrapper.register(as: nil, with: flags)
104+
}
105+
}
106+
}
107+
if let reason = flags.parsingFailure() {
108+
try command.fail(with: reason)
109+
} else {
110+
try command.run()
111+
}
112+
}
113+
}
114+
115+
public class ArgumentNameConverter {
116+
117+
public enum Strategy {
118+
case camelcase
119+
case lowercase
120+
case separate(Character)
121+
}
122+
123+
public let strategy: Strategy
124+
public let prefix: String
125+
126+
public init(_ strategy: Strategy = .lowercase, prefix: String = "") {
127+
self.strategy = strategy
128+
self.prefix = prefix
129+
}
130+
131+
public func convert(_ name: String) -> String {
132+
guard !name.isEmpty else {
133+
return ""
134+
}
135+
switch self.strategy {
136+
case .camelcase:
137+
if prefix.isEmpty {
138+
return name
139+
} else {
140+
return prefix + name.prefix(1).capitalized + name.dropFirst()
141+
}
142+
case .lowercase:
143+
return (prefix + name).lowercased()
144+
case .separate(let separator):
145+
var index = name.startIndex
146+
var separate = true
147+
var res = ""
148+
while index < name.endIndex {
149+
let character = name[index]
150+
if character.isUppercase {
151+
if separate && !res.isEmpty {
152+
res.append(separator)
153+
}
154+
let next = name.index(after: index)
155+
separate = next < name.endIndex &&
156+
name[next].isUppercase &&
157+
name.index(after: next) < name.endIndex &&
158+
name[name.index(after: next)].isLowercase
159+
} else {
160+
separate = character != separator
161+
}
162+
res += character.lowercased()
163+
index = name.index(after: index)
164+
}
165+
return res
166+
}
167+
}
168+
}

Sources/CommandLineKit/ConvertibleFromString.swift

+11-2
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,9 @@ extension Double: ConvertibleFromString {
9292
extension Bool: ConvertibleFromString {
9393
public init?(fromString str: String) {
9494
switch str.lowercased() {
95-
case "true", "t", "yes", "y":
95+
case "true", "t", "yes", "y", "1":
9696
self.init(true)
97-
case "false", "f", "no", "n":
97+
case "false", "f", "no", "n", "0":
9898
self.init(false)
9999
default:
100100
return nil
@@ -114,3 +114,12 @@ extension RawRepresentable where RawValue: ConvertibleFromString {
114114
return Self.init(fromString: str)
115115
}
116116
}
117+
118+
extension Optional: ConvertibleFromString where Wrapped: ConvertibleFromString {
119+
public init?(fromString str: String) {
120+
guard let value = Wrapped.from(string: str) else {
121+
return nil
122+
}
123+
self.init(value)
124+
}
125+
}

0 commit comments

Comments
 (0)