Skip to content

Commit 0c74056

Browse files
committed
Add AddTargetPlugin command
1 parent 0c7ebf2 commit 0c74056

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
@@ -12,6 +12,7 @@ add_library(Commands
1212
PackageCommands/AddTarget.swift
1313
PackageCommands/AddTargetDependency.swift
1414
PackageCommands/AddSetting.swift
15+
PackageCommands/AddTargetPlugin.swift
1516
PackageCommands/APIDiff.swift
1617
PackageCommands/ArchiveSource.swift
1718
PackageCommands/AuditBinaryArtifact.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
@@ -39,6 +39,7 @@ public struct SwiftPackageCommand: AsyncParsableCommand {
3939
AddTargetDependency.self,
4040
AddSetting.self,
4141
AuditBinaryArtifact.self,
42+
AddTargetPlugin.self,
4243
Clean.self,
4344
PurgeCache.self,
4445
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
@@ -12,9 +12,11 @@ add_library(PackageModelSyntax
1212
AddSwiftSetting.swift
1313
AddTarget.swift
1414
AddTargetDependency.swift
15+
AddTargetPlugin.swift
1516
ManifestEditError.swift
1617
ManifestSyntaxRepresentable.swift
1718
PackageDependency+Syntax.swift
19+
PluginUsage+Syntax.swift
1820
PackageEditResult.swift
1921
ProductDescription+Syntax.swift
2022
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
@@ -58,6 +58,21 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase {
5858
)
5959
}
6060

61+
private func assertExecuteCommandFails(
62+
_ args: [String] = [],
63+
packagePath: AbsolutePath? = nil,
64+
expectedErrorContains expected: String,
65+
file: StaticString = #file,
66+
line: UInt = #line
67+
) async throws {
68+
do {
69+
_ = try await execute(args, packagePath: packagePath)
70+
XCTFail("Expected command to fail", file: file, line: line)
71+
} catch let SwiftPMError.executionFailure(_, _, stderr) {
72+
XCTAssertMatch(stderr, .contains(expected), file: file, line: line)
73+
}
74+
}
75+
6176
func testNoParameters() async throws {
6277
let stdout = try await execute().stdout
6378
XCTAssertMatch(stdout, .contains("USAGE: swift package"))
@@ -1346,6 +1361,143 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase {
13461361
}
13471362
}
13481363

1364+
func testPackageAddPluginDependencyExternalPackage() async throws {
1365+
try await testWithTemporaryDirectory { tmpPath in
1366+
let fs = localFileSystem
1367+
let path = tmpPath.appending("PackageB")
1368+
try fs.createDirectory(path)
1369+
1370+
try fs.writeFileContents(path.appending("Package.swift"), string:
1371+
"""
1372+
// swift-tools-version: 5.9
1373+
import PackageDescription
1374+
let package = Package(
1375+
name: "client",
1376+
targets: [ .target(name: "library") ]
1377+
)
1378+
"""
1379+
)
1380+
try localFileSystem.writeFileContents(path.appending(components: "Sources", "library", "library.swift"), string:
1381+
"""
1382+
public func Foo() { }
1383+
"""
1384+
)
1385+
1386+
_ = try await execute(["add-target-plugin", "--package", "other-package", "other-product", "library"], packagePath: path)
1387+
1388+
let manifest = path.appending("Package.swift")
1389+
XCTAssertFileExists(manifest)
1390+
let contents: String = try fs.readFileContents(manifest)
1391+
1392+
XCTAssertMatch(contents, .contains(#".plugin(name: "other-product", package: "other-package"#))
1393+
}
1394+
}
1395+
1396+
func testPackageAddPluginDependencyFromExternalPackageToNonexistentTarget() async throws {
1397+
try await testWithTemporaryDirectory { tmpPath in
1398+
let fs = localFileSystem
1399+
let path = tmpPath.appending("PackageB")
1400+
try fs.createDirectory(path)
1401+
1402+
try fs.writeFileContents(path.appending("Package.swift"), string:
1403+
"""
1404+
// swift-tools-version: 5.9
1405+
import PackageDescription
1406+
let package = Package(
1407+
name: "client",
1408+
targets: [ .target(name: "library") ]
1409+
)
1410+
"""
1411+
)
1412+
try localFileSystem.writeFileContents(path.appending(components: "Sources", "library", "library.swift"), string:
1413+
"""
1414+
public func Foo() { }
1415+
"""
1416+
)
1417+
1418+
try await assertExecuteCommandFails(
1419+
["add-target-plugin", "--package", "other-package", "other-product", "library-that-does-not-exist"],
1420+
packagePath: path,
1421+
expectedErrorContains: "error: unable to find target named 'library-that-does-not-exist' in package"
1422+
)
1423+
1424+
let manifest = path.appending("Package.swift")
1425+
XCTAssertFileExists(manifest)
1426+
let contents: String = try fs.readFileContents(manifest)
1427+
1428+
XCTAssertNoMatch(contents, .contains(#".plugin(name: "other-product", package: "other-package"#))
1429+
}
1430+
}
1431+
1432+
1433+
func testPackageAddPluginDependencyInternalPackage() async throws {
1434+
try await testWithTemporaryDirectory { tmpPath in
1435+
let fs = localFileSystem
1436+
let path = tmpPath.appending("PackageB")
1437+
try fs.createDirectory(path)
1438+
1439+
try fs.writeFileContents(path.appending("Package.swift"), string:
1440+
"""
1441+
// swift-tools-version: 5.9
1442+
import PackageDescription
1443+
let package = Package(
1444+
name: "client",
1445+
targets: [ .target(name: "library") ]
1446+
)
1447+
"""
1448+
)
1449+
try localFileSystem.writeFileContents(path.appending(components: "Sources", "library", "library.swift"), string:
1450+
"""
1451+
public func Foo() { }
1452+
"""
1453+
)
1454+
1455+
_ = try await execute(["add-target-plugin", "other-product", "library"], packagePath: path)
1456+
1457+
let manifest = path.appending("Package.swift")
1458+
XCTAssertFileExists(manifest)
1459+
let contents: String = try fs.readFileContents(manifest)
1460+
1461+
XCTAssertMatch(contents, .contains(#".plugin(name: "other-product"#))
1462+
}
1463+
}
1464+
1465+
func testPackageAddPluginDependencyFromInternalPackageToNonexistentTarget() async throws {
1466+
try await testWithTemporaryDirectory { tmpPath in
1467+
let fs = localFileSystem
1468+
let path = tmpPath.appending("PackageB")
1469+
try fs.createDirectory(path)
1470+
1471+
try fs.writeFileContents(path.appending("Package.swift"), string:
1472+
"""
1473+
// swift-tools-version: 5.9
1474+
import PackageDescription
1475+
let package = Package(
1476+
name: "client",
1477+
targets: [ .target(name: "library") ]
1478+
)
1479+
"""
1480+
)
1481+
try localFileSystem.writeFileContents(path.appending(components: "Sources", "library", "library.swift"), string:
1482+
"""
1483+
public func Foo() { }
1484+
"""
1485+
)
1486+
1487+
try await assertExecuteCommandFails(
1488+
["add-target-plugin", "--package", "other-package", "other-product", "library-that-does-not-exist"],
1489+
packagePath: path,
1490+
expectedErrorContains: "error: unable to find target named 'library-that-does-not-exist' in package"
1491+
)
1492+
1493+
let manifest = path.appending("Package.swift")
1494+
XCTAssertFileExists(manifest)
1495+
let contents: String = try fs.readFileContents(manifest)
1496+
1497+
XCTAssertNoMatch(contents, .contains(#".plugin(name: "other-product"#))
1498+
}
1499+
}
1500+
13491501
func testPackageAddProduct() async throws {
13501502
try await testWithTemporaryDirectory { tmpPath in
13511503
let fs = localFileSystem

0 commit comments

Comments
 (0)