Skip to content

Commit 9c0b1ff

Browse files
authored
Add a new swift-package-collections CLI (#3030)
* Add a new `swift-package-collections` CLI This implements the proposed CLI for package-collections. Requires #3028 * address feedback * address feedback and adopt to latest API * more feedback * Update with proposal changes * Fix output indentation and rename CLI tool * Fix JSON output and adopt `.makeWithDefaults()`
1 parent 9f4e214 commit 9c0b1ff

File tree

6 files changed

+362
-5
lines changed

6 files changed

+362
-5
lines changed

Package.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ let package = Package(
174174
.target(
175175
/** High-level commands */
176176
name: "Commands",
177-
dependencies: ["SwiftToolsSupport-auto", "Basics", "Build", "PackageGraph", "SourceControl", "Xcodeproj", "Workspace", "XCBuildSupport", "ArgumentParser"]),
177+
dependencies: ["SwiftToolsSupport-auto", "Basics", "Build", "PackageGraph", "SourceControl", "Xcodeproj", "Workspace", "XCBuildSupport", "ArgumentParser", "PackageCollections"]),
178178
.target(
179179
/** The main executable provided by SwiftPM */
180180
name: "swift-package",
@@ -191,6 +191,10 @@ let package = Package(
191191
/** Runs an executable product */
192192
name: "swift-run",
193193
dependencies: ["Commands"]),
194+
.target(
195+
/** Interacts with package collections */
196+
name: "swift-package-collection",
197+
dependencies: ["Commands"]),
194198
.target(
195199
/** Shim tool to find test names on OS X */
196200
name: "swiftpm-xctest-helper",
Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright 2020 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import ArgumentParser
12+
import Foundation
13+
import PackageCollections
14+
import PackageModel
15+
import TSCBasic
16+
import TSCUtility
17+
18+
private enum CollectionsError: Swift.Error {
19+
case invalidArgument(String)
20+
case invalidVersionString(String)
21+
}
22+
23+
extension CollectionsError: CustomStringConvertible {
24+
var description: String {
25+
switch self {
26+
case .invalidArgument(let argumentName):
27+
return "invalid argument '\(argumentName)'"
28+
case .invalidVersionString(let versionString):
29+
return "invalid version string '\(versionString)'"
30+
}
31+
}
32+
}
33+
34+
struct JSONOptions: ParsableArguments {
35+
@Flag(name: .long, help: "Output as JSON")
36+
var json: Bool = false
37+
}
38+
39+
public struct SwiftPackageCollectionsTool: ParsableCommand {
40+
public static var configuration = CommandConfiguration(
41+
commandName: "package-collection",
42+
_superCommandName: "swift",
43+
abstract: "Interact with package collections",
44+
discussion: "SEE ALSO: swift build, swift package, swift run, swift test",
45+
version: Versioning.currentVersion.completeDisplayString,
46+
subcommands: [
47+
Add.self,
48+
Describe.self,
49+
List.self,
50+
Refresh.self,
51+
Remove.self,
52+
Search.self
53+
],
54+
helpNames: [.short, .long, .customLong("help", withSingleDash: true)])
55+
56+
public init() {
57+
}
58+
59+
// MARK: Collections
60+
61+
struct List: ParsableCommand {
62+
static let configuration = CommandConfiguration(abstract: "List configured collections")
63+
64+
@OptionGroup
65+
var jsonOptions: JSONOptions
66+
67+
mutating func run() throws {
68+
let collections = try with { collections in
69+
return try tsc_await { collections.listCollections(identifiers: nil, callback: $0) }
70+
}
71+
72+
if jsonOptions.json {
73+
try JSONEncoder.makeWithDefaults().print(collections)
74+
} else {
75+
collections.forEach {
76+
print("\($0.name) - \($0.source.url)")
77+
}
78+
}
79+
}
80+
}
81+
82+
struct Refresh: ParsableCommand {
83+
static let configuration = CommandConfiguration(abstract: "Refresh configured collections")
84+
85+
mutating func run() throws {
86+
let collections = try with { collections in
87+
return try tsc_await { collections.refreshCollections(callback: $0) }
88+
}
89+
print("Refreshed \(collections.count) configured package collections.")
90+
}
91+
}
92+
93+
struct Add: ParsableCommand {
94+
static let configuration = CommandConfiguration(abstract: "Add a new collection")
95+
96+
@Argument(help: "URL of the collection to add")
97+
var collectionUrl: String
98+
99+
@Option(name: .long, help: "Sort order for the added collection")
100+
var order: Int?
101+
102+
mutating func run() throws {
103+
guard let collectionUrl = URL(string: collectionUrl) else {
104+
throw CollectionsError.invalidArgument("collectionUrl")
105+
}
106+
107+
let source = PackageCollectionsModel.CollectionSource(type: .json, url: collectionUrl)
108+
let collection = try with { collections in
109+
return try tsc_await { collections.addCollection(source, order: order, callback: $0) }
110+
}
111+
112+
print("Added \"\(collection.name)\" to your package collections.")
113+
}
114+
}
115+
116+
struct Remove: ParsableCommand {
117+
static let configuration = CommandConfiguration(abstract: "Remove a configured collection")
118+
119+
@Argument(help: "URL of the collection to remove")
120+
var collectionUrl: String
121+
122+
mutating func run() throws {
123+
guard let collectionUrl = URL(string: collectionUrl) else {
124+
throw CollectionsError.invalidArgument("collectionUrl")
125+
}
126+
127+
let source = PackageCollectionsModel.CollectionSource(type: .json, url: collectionUrl)
128+
let collection = try with { collections in
129+
return try tsc_await { collections.getCollection(source, callback: $0) }
130+
}
131+
132+
_ = try with { collections in try tsc_await { collections.removeCollection(source, callback: $0) } }
133+
print("Removed \"\(collection.name)\" from your package collections.")
134+
}
135+
}
136+
137+
// MARK: Search
138+
139+
enum SearchMethod: String, EnumerableFlag {
140+
case keywords
141+
case module
142+
}
143+
144+
struct Search: ParsableCommand {
145+
static var configuration = CommandConfiguration(abstract: "Search for packages by keywords or module names")
146+
147+
@OptionGroup
148+
var jsonOptions: JSONOptions
149+
150+
@Flag(help: "Pick the method for searching")
151+
var searchMethod: SearchMethod
152+
153+
@Argument(help: "Search query")
154+
var searchQuery: String
155+
156+
mutating func run() throws {
157+
switch searchMethod {
158+
case .keywords:
159+
let results = try with { collections in
160+
return try tsc_await { collections.findPackages(searchQuery, collections: nil, callback: $0) }
161+
}
162+
163+
if jsonOptions.json {
164+
try JSONEncoder.makeWithDefaults().print(results.items)
165+
} else {
166+
results.items.forEach {
167+
print("\($0.package.repository.url): \($0.package.summary ?? "")")
168+
}
169+
}
170+
171+
case .module:
172+
let results = try with { collections in
173+
return try tsc_await { collections.findTargets(searchQuery, searchType: .exactMatch, collections: nil, callback: $0) }
174+
}
175+
176+
let packages = Set(results.items.flatMap { $0.packages })
177+
if jsonOptions.json {
178+
try JSONEncoder.makeWithDefaults().print(packages)
179+
} else {
180+
packages.forEach {
181+
print("\($0.repository.url): \($0.summary ?? "")")
182+
}
183+
}
184+
}
185+
}
186+
}
187+
188+
// MARK: Packages
189+
190+
struct Describe: ParsableCommand {
191+
static var configuration = CommandConfiguration(abstract: "Get metadata for a single package or collection")
192+
193+
@OptionGroup
194+
var jsonOptions: JSONOptions
195+
196+
@Argument(help: "URL of the package or collection to get information for")
197+
var packageUrl: String
198+
199+
@Option(name: .long, help: "Version of the package to get information for")
200+
var version: String?
201+
202+
private func printVersion(_ version: PackageCollectionsModel.Package.Version?) -> String? {
203+
guard let version = version else {
204+
return nil
205+
}
206+
207+
let modules = version.targets.compactMap { $0.moduleName }.joined(separator: ", ")
208+
let platforms = optionalRow("Verified Platforms", version.verifiedPlatforms?.map { $0.name }.joined(separator: ", "))
209+
let swiftVersions = optionalRow("Verified Swift Versions", version.verifiedSwiftVersions?.map { $0.rawValue }.joined(separator: ", "))
210+
let license = optionalRow("License", version.license?.type.description)
211+
212+
return """
213+
\(version.version)
214+
Package Name: \(version.packageName)
215+
Modules: \(modules)\(platforms)\(swiftVersions)\(license)
216+
"""
217+
}
218+
219+
mutating func run() throws {
220+
let identity = PackageIdentity(url: packageUrl)
221+
let reference = PackageReference(identity: identity, path: packageUrl)
222+
223+
do { // assume URL is for a package
224+
let result = try with { collections in
225+
return try tsc_await { collections.getPackageMetadata(reference, callback: $0) }
226+
}
227+
228+
if let versionString = version {
229+
guard let version = TSCUtility.Version(string: versionString), let result = result.package.versions.first(where: { $0.version == version }), let printedResult = printVersion(result) else {
230+
throw CollectionsError.invalidVersionString(versionString)
231+
}
232+
233+
if jsonOptions.json {
234+
try JSONEncoder.makeWithDefaults().print(result)
235+
} else {
236+
print("\(indent())Version: \(printedResult)")
237+
}
238+
} else {
239+
let description = optionalRow("Description", result.package.summary)
240+
let versions = result.package.versions.map { "\($0.version)" }.joined(separator: ", ")
241+
let watchers = optionalRow("Watchers", result.package.watchersCount?.description)
242+
let readme = optionalRow("Readme", result.package.readmeURL?.absoluteString)
243+
let authors = optionalRow("Authors", result.package.authors?.map { $0.username }.joined(separator: ", "))
244+
let latestVersion = optionalRow("\(String(repeating: "-", count: 60))\n\(indent())Latest Version", printVersion(result.package.latestVersion))
245+
246+
if jsonOptions.json {
247+
try JSONEncoder.makeWithDefaults().print(result.package)
248+
} else {
249+
print("""
250+
\(description)
251+
Available Versions: \(versions)\(watchers)\(readme)\(authors)\(latestVersion)
252+
""")
253+
}
254+
}
255+
} catch { // assume URL is for a collection
256+
// If a version argument was given, we do not perform the fallback.
257+
if version != nil {
258+
throw error
259+
}
260+
261+
guard let collectionUrl = URL(string: packageUrl) else {
262+
throw CollectionsError.invalidArgument("collectionUrl")
263+
}
264+
265+
let source = PackageCollectionsModel.CollectionSource(type: .json, url: collectionUrl)
266+
let collection = try with { collections in
267+
try tsc_await { collections.getCollection(source, callback: $0) }
268+
}
269+
270+
let description = optionalRow("Description", collection.overview)
271+
let keywords = optionalRow("Keywords", collection.keywords?.joined(separator: ", "))
272+
let createdAt = optionalRow("Created At", DateFormatter().string(from: collection.createdAt))
273+
let packages = collection.packages.map { "\($0.repository.url)" }.joined(separator: "\n\(indent(levels: 2))")
274+
275+
if jsonOptions.json {
276+
try JSONEncoder.makeWithDefaults().print(collection)
277+
} else {
278+
print("""
279+
Name: \(collection.name)
280+
Source: \(collection.source.url)\(description)\(keywords)\(createdAt)
281+
Packages:
282+
\(packages)
283+
""")
284+
}
285+
}
286+
}
287+
}
288+
}
289+
290+
private func indent(levels: Int = 1) -> String {
291+
return String(repeating: " ", count: levels)
292+
}
293+
294+
private func optionalRow(_ title: String, _ contents: String?, indentationLevel: Int = 1) -> String {
295+
if let contents = contents, !contents.isEmpty {
296+
return "\n\(indent(levels: indentationLevel))\(title): \(contents)"
297+
} else {
298+
return ""
299+
}
300+
}
301+
302+
private extension JSONEncoder {
303+
func print<T>(_ value: T) throws where T : Encodable {
304+
let jsonData = try self.encode(value)
305+
let jsonString = String(data: jsonData, encoding: .utf8)!
306+
Swift.print(jsonString)
307+
}
308+
}
309+
310+
private extension ParsableCommand {
311+
func with<T>(handler: (_ collections: PackageCollectionsProtocol) throws -> T) throws -> T {
312+
let collections = PackageCollections()
313+
defer {
314+
do {
315+
try collections.shutdown()
316+
} catch {
317+
Self.exit(withError: error)
318+
}
319+
}
320+
321+
return try handler(collections)
322+
}
323+
}

Sources/PackageCollections/Model/Search.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ extension PackageCollectionsModel {
1515
public let items: [Item]
1616

1717
/// Represents a search result item
18-
public struct Item {
18+
public struct Item: Encodable {
1919
// Merged package metadata from across collections
2020
/// The matching package
2121
public let package: PackageCollectionsModel.Package

Sources/PackageCollections/Model/TargetListResult.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import TSCUtility
1616
extension PackageCollectionsModel {
1717
public typealias TargetListResult = [TargetListItem]
1818

19-
public struct TargetListItem {
19+
public struct TargetListItem: Encodable {
2020
public typealias Package = PackageCollectionsModel.TargetListResult.Package
2121

2222
/// Target
@@ -29,7 +29,7 @@ extension PackageCollectionsModel {
2929

3030
extension PackageCollectionsModel.TargetListResult {
3131
/// Metadata of package that contains the target
32-
public struct Package: Hashable {
32+
public struct Package: Hashable, Encodable {
3333
public typealias Version = PackageCollectionsModel.TargetListResult.PackageVersion
3434

3535
/// Package's repository address
@@ -48,7 +48,7 @@ extension PackageCollectionsModel.TargetListResult {
4848

4949
extension PackageCollectionsModel.TargetListResult {
5050
/// Represents a package version
51-
public struct PackageVersion: Hashable {
51+
public struct PackageVersion: Hashable, Encodable {
5252
/// The version
5353
public let version: TSCUtility.Version
5454

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# This source file is part of the Swift.org open source project
2+
#
3+
# Copyright (c) 2020 Apple Inc. and the Swift project authors
4+
# Licensed under Apache License v2.0 with Runtime Library Exception
5+
#
6+
# See http://swift.org/LICENSE.txt for license information
7+
# See http://swift.org/CONTRIBUTORS.txt for Swift project authors
8+
9+
add_executable(swift-package-collection
10+
main.swift)
11+
target_link_libraries(swift-package-collection PRIVATE
12+
Commands)
13+
14+
if(USE_CMAKE_INSTALL)
15+
install(TARGETS swift-package-collection
16+
RUNTIME DESTINATION bin)
17+
endif()

0 commit comments

Comments
 (0)