Skip to content

Commit d2ef799

Browse files
committed
Add AddTargetPlugin command
1 parent 49e69c7 commit d2ef799

File tree

7 files changed

+359
-0
lines changed

7 files changed

+359
-0
lines changed

Sources/Commands/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ add_library(Commands
1111
PackageCommands/AddProduct.swift
1212
PackageCommands/AddTarget.swift
1313
PackageCommands/AddTargetDependency.swift
14+
PackageCommands/AddTargetPlugin.swift
1415
PackageCommands/APIDiff.swift
1516
PackageCommands/ArchiveSource.swift
1617
PackageCommands/CompletionCommand.swift
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2014-2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import ArgumentParser
14+
import Basics
15+
import CoreCommands
16+
import PackageModel
17+
import PackageModelSyntax
18+
import SwiftParser
19+
import SwiftSyntax
20+
import TSCBasic
21+
import TSCUtility
22+
import Workspace
23+
24+
extension SwiftPackageCommand {
25+
struct AddTargetPlugin: SwiftCommand {
26+
package static let configuration = CommandConfiguration(
27+
abstract: "Add a new target plugin to the manifest"
28+
)
29+
30+
@OptionGroup(visibility: .hidden)
31+
var globalOptions: GlobalOptions
32+
33+
@Argument(help: "The name of the new plugin")
34+
var pluginName: String
35+
36+
@Argument(help: "The name of the target to update")
37+
var targetName: String
38+
39+
@Option(help: "The package in which the plugin resides")
40+
var package: String?
41+
42+
func run(_ swiftCommandState: SwiftCommandState) throws {
43+
let workspace = try swiftCommandState.getActiveWorkspace()
44+
45+
guard let packagePath = try swiftCommandState.getWorkspaceRoot().packages.first else {
46+
throw StringError("unknown package")
47+
}
48+
49+
// Load the manifest file
50+
let fileSystem = workspace.fileSystem
51+
let manifestPath = packagePath.appending("Package.swift")
52+
let manifestContents: ByteString
53+
do {
54+
manifestContents = try fileSystem.readFileContents(manifestPath)
55+
} catch {
56+
throw StringError("cannot find package manifest in \(manifestPath)")
57+
}
58+
59+
// Parse the manifest.
60+
let manifestSyntax = manifestContents.withData { data in
61+
data.withUnsafeBytes { buffer in
62+
buffer.withMemoryRebound(to: UInt8.self) { buffer in
63+
Parser.parse(source: buffer)
64+
}
65+
}
66+
}
67+
68+
let plugin: TargetDescription.PluginUsage = .plugin(name: pluginName, package: package)
69+
70+
let editResult = try PackageModelSyntax.AddTargetPlugin.addTargetPlugin(
71+
plugin,
72+
targetName: targetName,
73+
to: manifestSyntax
74+
)
75+
76+
try editResult.applyEdits(
77+
to: fileSystem,
78+
manifest: manifestSyntax,
79+
manifestPath: manifestPath,
80+
verbose: !globalOptions.logging.quiet
81+
)
82+
}
83+
}
84+
}
85+

Sources/Commands/PackageCommands/SwiftPackageCommand.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public struct SwiftPackageCommand: AsyncParsableCommand {
3737
AddProduct.self,
3838
AddTarget.self,
3939
AddTargetDependency.self,
40+
AddTargetPlugin.self,
4041
Clean.self,
4142
PurgeCache.self,
4243
Reset.self,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Basics
14+
import PackageLoading
15+
import PackageModel
16+
import SwiftParser
17+
import SwiftSyntax
18+
import SwiftSyntaxBuilder
19+
20+
/// Add a target plugin to a manifest's source code.
21+
public struct AddTargetPlugin {
22+
/// The set of argument labels that can occur after the "plugins"
23+
/// argument in the various target initializers.
24+
///
25+
/// TODO: Could we generate this from the the PackageDescription module, so
26+
/// we don't have keep it up-to-date manually?
27+
private static let argumentLabelsAfterDependencies: Set<String> = []
28+
29+
/// Produce the set of source edits needed to add the given target
30+
/// plugin to the given manifest file.
31+
public static func addTargetPlugin(
32+
_ plugin: TargetDescription.PluginUsage,
33+
targetName: String,
34+
to manifest: SourceFileSyntax
35+
) throws -> PackageEditResult {
36+
// Make sure we have a suitable tools version in the manifest.
37+
try manifest.checkEditManifestToolsVersion()
38+
39+
guard let packageCall = manifest.findCall(calleeName: "Package") else {
40+
throw ManifestEditError.cannotFindPackage
41+
}
42+
43+
// Dig out the array of targets.
44+
guard let targetsArgument = packageCall.findArgument(labeled: "targets"),
45+
let targetArray = targetsArgument.expression.findArrayArgument() else {
46+
throw ManifestEditError.cannotFindTargets
47+
}
48+
49+
// Look for a call whose name is a string literal matching the
50+
// requested target name.
51+
func matchesTargetCall(call: FunctionCallExprSyntax) -> Bool {
52+
guard let nameArgument = call.findArgument(labeled: "name") else {
53+
return false
54+
}
55+
56+
guard let stringLiteral = nameArgument.expression.as(StringLiteralExprSyntax.self),
57+
let literalValue = stringLiteral.representedLiteralValue else {
58+
return false
59+
}
60+
61+
return literalValue == targetName
62+
}
63+
64+
guard let targetCall = FunctionCallExprSyntax.findFirst(in: targetArray, matching: matchesTargetCall) else {
65+
throw ManifestEditError.cannotFindTarget(targetName: targetName)
66+
}
67+
68+
let newTargetCall = try addTargetPluginLocal(
69+
plugin, to: targetCall
70+
)
71+
72+
return PackageEditResult(
73+
manifestEdits: [
74+
.replace(targetCall, with: newTargetCall.description)
75+
]
76+
)
77+
}
78+
79+
/// Implementation of adding a target dependency to an existing call.
80+
static func addTargetPluginLocal(
81+
_ plugin: TargetDescription.PluginUsage,
82+
to targetCall: FunctionCallExprSyntax
83+
) throws -> FunctionCallExprSyntax {
84+
try targetCall.appendingToArrayArgument(
85+
label: "plugins",
86+
trailingLabels: Self.argumentLabelsAfterDependencies,
87+
newElement: plugin.asSyntax()
88+
)
89+
}
90+
}
91+

Sources/PackageModelSyntax/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ add_library(PackageModelSyntax
1111
AddProduct.swift
1212
AddTarget.swift
1313
AddTargetDependency.swift
14+
AddTargetPlugin.swift
1415
ManifestEditError.swift
1516
ManifestSyntaxRepresentable.swift
1617
PackageDependency+Syntax.swift
18+
PluginUsage+Syntax.swift
1719
PackageEditResult.swift
1820
ProductDescription+Syntax.swift
1921
SyntaxEditUtils.swift
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2014-2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import PackageModel
14+
import SwiftSyntax
15+
16+
extension TargetDescription.PluginUsage: ManifestSyntaxRepresentable {
17+
func asSyntax() -> ExprSyntax {
18+
switch self {
19+
case let .plugin(name: name, package: package):
20+
if let package {
21+
return ".plugin(name: \(literal: name.description), package: \(literal: package.description))"
22+
} else {
23+
return ".plugin(name: \(literal: name.description))"
24+
}
25+
}
26+
}
27+
}

Tests/CommandsTests/PackageCommandTests.swift

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,21 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase {
5353
)
5454
}
5555

56+
private func assertExecuteCommandFails(
57+
_ args: [String] = [],
58+
packagePath: AbsolutePath? = nil,
59+
expectedErrorContains expected: String,
60+
file: StaticString = #file,
61+
line: UInt = #line
62+
) async throws {
63+
do {
64+
_ = try await execute(args, packagePath: packagePath)
65+
XCTFail("Expected command to fail", file: file, line: line)
66+
} catch let SwiftPMError.executionFailure(_, _, stderr) {
67+
XCTAssertMatch(stderr, .contains(expected), file: file, line: line)
68+
}
69+
}
70+
5671
func testNoParameters() async throws {
5772
let stdout = try await execute().stdout
5873
XCTAssertMatch(stdout, .contains("USAGE: swift package"))
@@ -1235,6 +1250,143 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase {
12351250
}
12361251
}
12371252

1253+
func testPackageAddPluginDependencyExternalPackage() async throws {
1254+
try await testWithTemporaryDirectory { tmpPath in
1255+
let fs = localFileSystem
1256+
let path = tmpPath.appending("PackageB")
1257+
try fs.createDirectory(path)
1258+
1259+
try fs.writeFileContents(path.appending("Package.swift"), string:
1260+
"""
1261+
// swift-tools-version: 5.9
1262+
import PackageDescription
1263+
let package = Package(
1264+
name: "client",
1265+
targets: [ .target(name: "library") ]
1266+
)
1267+
"""
1268+
)
1269+
try localFileSystem.writeFileContents(path.appending(components: "Sources", "library", "library.swift"), string:
1270+
"""
1271+
public func Foo() { }
1272+
"""
1273+
)
1274+
1275+
_ = try await execute(["add-target-plugin", "--package", "other-package", "other-product", "library"], packagePath: path)
1276+
1277+
let manifest = path.appending("Package.swift")
1278+
XCTAssertFileExists(manifest)
1279+
let contents: String = try fs.readFileContents(manifest)
1280+
1281+
XCTAssertMatch(contents, .contains(#".plugin(name: "other-product", package: "other-package"#))
1282+
}
1283+
}
1284+
1285+
func testPackageAddPluginDependencyFromExternalPackageToNonexistentTarget() async throws {
1286+
try await testWithTemporaryDirectory { tmpPath in
1287+
let fs = localFileSystem
1288+
let path = tmpPath.appending("PackageB")
1289+
try fs.createDirectory(path)
1290+
1291+
try fs.writeFileContents(path.appending("Package.swift"), string:
1292+
"""
1293+
// swift-tools-version: 5.9
1294+
import PackageDescription
1295+
let package = Package(
1296+
name: "client",
1297+
targets: [ .target(name: "library") ]
1298+
)
1299+
"""
1300+
)
1301+
try localFileSystem.writeFileContents(path.appending(components: "Sources", "library", "library.swift"), string:
1302+
"""
1303+
public func Foo() { }
1304+
"""
1305+
)
1306+
1307+
try await assertExecuteCommandFails(
1308+
["add-target-plugin", "--package", "other-package", "other-product", "library-that-does-not-exist"],
1309+
packagePath: path,
1310+
expectedErrorContains: "error: unable to find target named 'library-that-does-not-exist' in package"
1311+
)
1312+
1313+
let manifest = path.appending("Package.swift")
1314+
XCTAssertFileExists(manifest)
1315+
let contents: String = try fs.readFileContents(manifest)
1316+
1317+
XCTAssertNoMatch(contents, .contains(#".plugin(name: "other-product", package: "other-package"#))
1318+
}
1319+
}
1320+
1321+
1322+
func testPackageAddPluginDependencyInternalPackage() async throws {
1323+
try await testWithTemporaryDirectory { tmpPath in
1324+
let fs = localFileSystem
1325+
let path = tmpPath.appending("PackageB")
1326+
try fs.createDirectory(path)
1327+
1328+
try fs.writeFileContents(path.appending("Package.swift"), string:
1329+
"""
1330+
// swift-tools-version: 5.9
1331+
import PackageDescription
1332+
let package = Package(
1333+
name: "client",
1334+
targets: [ .target(name: "library") ]
1335+
)
1336+
"""
1337+
)
1338+
try localFileSystem.writeFileContents(path.appending(components: "Sources", "library", "library.swift"), string:
1339+
"""
1340+
public func Foo() { }
1341+
"""
1342+
)
1343+
1344+
_ = try await execute(["add-target-plugin", "other-product", "library"], packagePath: path)
1345+
1346+
let manifest = path.appending("Package.swift")
1347+
XCTAssertFileExists(manifest)
1348+
let contents: String = try fs.readFileContents(manifest)
1349+
1350+
XCTAssertMatch(contents, .contains(#".plugin(name: "other-product"#))
1351+
}
1352+
}
1353+
1354+
func testPackageAddPluginDependencyFromInternalPackageToNonexistentTarget() async throws {
1355+
try await testWithTemporaryDirectory { tmpPath in
1356+
let fs = localFileSystem
1357+
let path = tmpPath.appending("PackageB")
1358+
try fs.createDirectory(path)
1359+
1360+
try fs.writeFileContents(path.appending("Package.swift"), string:
1361+
"""
1362+
// swift-tools-version: 5.9
1363+
import PackageDescription
1364+
let package = Package(
1365+
name: "client",
1366+
targets: [ .target(name: "library") ]
1367+
)
1368+
"""
1369+
)
1370+
try localFileSystem.writeFileContents(path.appending(components: "Sources", "library", "library.swift"), string:
1371+
"""
1372+
public func Foo() { }
1373+
"""
1374+
)
1375+
1376+
try await assertExecuteCommandFails(
1377+
["add-target-plugin", "--package", "other-package", "other-product", "library-that-does-not-exist"],
1378+
packagePath: path,
1379+
expectedErrorContains: "error: unable to find target named 'library-that-does-not-exist' in package"
1380+
)
1381+
1382+
let manifest = path.appending("Package.swift")
1383+
XCTAssertFileExists(manifest)
1384+
let contents: String = try fs.readFileContents(manifest)
1385+
1386+
XCTAssertNoMatch(contents, .contains(#".plugin(name: "other-product"#))
1387+
}
1388+
}
1389+
12381390
func testPackageAddProduct() async throws {
12391391
try await testWithTemporaryDirectory { tmpPath in
12401392
let fs = localFileSystem

0 commit comments

Comments
 (0)