Skip to content

Add swift package add-target-plugin command #8432

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/Commands/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ add_library(Commands
PackageCommands/AddProduct.swift
PackageCommands/AddTarget.swift
PackageCommands/AddTargetDependency.swift
PackageCommands/AddTargetPlugin.swift
PackageCommands/APIDiff.swift
PackageCommands/ArchiveSource.swift
PackageCommands/CompletionCommand.swift
Expand Down
85 changes: 85 additions & 0 deletions Sources/Commands/PackageCommands/AddTargetPlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift open source project
//
// Copyright (c) 2014-2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See http://swift.org/LICENSE.txt for license information
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

import ArgumentParser
import Basics
import CoreCommands
import PackageModel
import PackageModelSyntax
import SwiftParser
import SwiftSyntax
import TSCBasic
import TSCUtility
import Workspace

extension SwiftPackageCommand {
struct AddTargetPlugin: SwiftCommand {
package static let configuration = CommandConfiguration(
abstract: "Add a new target plugin to the manifest"
)

@OptionGroup(visibility: .hidden)
var globalOptions: GlobalOptions

@Argument(help: "The name of the new plugin")
var pluginName: String

@Argument(help: "The name of the target to update")
var targetName: String

@Option(help: "The package in which the plugin resides")
var package: String?

func run(_ swiftCommandState: SwiftCommandState) throws {
let workspace = try swiftCommandState.getActiveWorkspace()

guard let packagePath = try swiftCommandState.getWorkspaceRoot().packages.first else {
throw StringError("unknown package")
}

// Load the manifest file
let fileSystem = workspace.fileSystem
let manifestPath = packagePath.appending("Package.swift")
let manifestContents: ByteString
do {
manifestContents = try fileSystem.readFileContents(manifestPath)
} catch {
throw StringError("cannot find package manifest in \(manifestPath)")
}

// Parse the manifest.
let manifestSyntax = manifestContents.withData { data in
data.withUnsafeBytes { buffer in
buffer.withMemoryRebound(to: UInt8.self) { buffer in
Parser.parse(source: buffer)
}
}
}

let plugin: TargetDescription.PluginUsage = .plugin(name: pluginName, package: package)

let editResult = try PackageModelSyntax.AddTargetPlugin.addTargetPlugin(
plugin,
targetName: targetName,
to: manifestSyntax
)

try editResult.applyEdits(
to: fileSystem,
manifest: manifestSyntax,
manifestPath: manifestPath,
verbose: !globalOptions.logging.quiet
)
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public struct SwiftPackageCommand: AsyncParsableCommand {
AddProduct.self,
AddTarget.self,
AddTargetDependency.self,
AddTargetPlugin.self,
Clean.self,
PurgeCache.self,
Reset.self,
Expand Down
91 changes: 91 additions & 0 deletions Sources/PackageModelSyntax/AddTargetPlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See http://swift.org/LICENSE.txt for license information
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

import Basics
import PackageLoading
import PackageModel
import SwiftParser
import SwiftSyntax
import SwiftSyntaxBuilder

/// Add a target plugin to a manifest's source code.
public struct AddTargetPlugin {
/// The set of argument labels that can occur after the "plugins"
/// argument in the various target initializers.
///
/// TODO: Could we generate this from the the PackageDescription module, so
/// we don't have keep it up-to-date manually?
private static let argumentLabelsAfterDependencies: Set<String> = []

/// Produce the set of source edits needed to add the given target
/// plugin to the given manifest file.
public static func addTargetPlugin(
_ plugin: TargetDescription.PluginUsage,
targetName: String,
to manifest: SourceFileSyntax
) throws -> PackageEditResult {
// Make sure we have a suitable tools version in the manifest.
try manifest.checkEditManifestToolsVersion()

guard let packageCall = manifest.findCall(calleeName: "Package") else {
throw ManifestEditError.cannotFindPackage
}

// Dig out the array of targets.
guard let targetsArgument = packageCall.findArgument(labeled: "targets"),
let targetArray = targetsArgument.expression.findArrayArgument() else {
throw ManifestEditError.cannotFindTargets
}

// Look for a call whose name is a string literal matching the
// requested target name.
func matchesTargetCall(call: FunctionCallExprSyntax) -> Bool {
guard let nameArgument = call.findArgument(labeled: "name") else {
return false
}

guard let stringLiteral = nameArgument.expression.as(StringLiteralExprSyntax.self),
let literalValue = stringLiteral.representedLiteralValue else {
return false
}

return literalValue == targetName
}

guard let targetCall = FunctionCallExprSyntax.findFirst(in: targetArray, matching: matchesTargetCall) else {
throw ManifestEditError.cannotFindTarget(targetName: targetName)
}

let newTargetCall = try addTargetPluginLocal(
plugin, to: targetCall
)

return PackageEditResult(
manifestEdits: [
.replace(targetCall, with: newTargetCall.description)
]
)
}

/// Implementation of adding a target dependency to an existing call.
static func addTargetPluginLocal(
_ plugin: TargetDescription.PluginUsage,
to targetCall: FunctionCallExprSyntax
) throws -> FunctionCallExprSyntax {
try targetCall.appendingToArrayArgument(
label: "plugins",
trailingLabels: Self.argumentLabelsAfterDependencies,
newElement: plugin.asSyntax()
)
}
}

2 changes: 2 additions & 0 deletions Sources/PackageModelSyntax/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ add_library(PackageModelSyntax
AddProduct.swift
AddTarget.swift
AddTargetDependency.swift
AddTargetPlugin.swift
ManifestEditError.swift
ManifestSyntaxRepresentable.swift
PackageDependency+Syntax.swift
PluginUsage+Syntax.swift
PackageEditResult.swift
ProductDescription+Syntax.swift
SyntaxEditUtils.swift
Expand Down
27 changes: 27 additions & 0 deletions Sources/PackageModelSyntax/PluginUsage+Syntax.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift open source project
//
// Copyright (c) 2014-2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See http://swift.org/LICENSE.txt for license information
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

import PackageModel
import SwiftSyntax

extension TargetDescription.PluginUsage: ManifestSyntaxRepresentable {
func asSyntax() -> ExprSyntax {
switch self {
case let .plugin(name: name, package: package):
if let package {
return ".plugin(name: \(literal: name.description), package: \(literal: package.description))"
} else {
return ".plugin(name: \(literal: name.description))"
}
}
}
}
152 changes: 152 additions & 0 deletions Tests/CommandsTests/PackageCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase {
)
}

private func assertExecuteCommandFails(
_ args: [String] = [],
packagePath: AbsolutePath? = nil,
expectedErrorContains expected: String,
file: StaticString = #file,
line: UInt = #line
) async throws {
do {
_ = try await execute(args, packagePath: packagePath)
XCTFail("Expected command to fail", file: file, line: line)
} catch let SwiftPMError.executionFailure(_, _, stderr) {
XCTAssertMatch(stderr, .contains(expected), file: file, line: line)
}
}

func testNoParameters() async throws {
let stdout = try await execute().stdout
XCTAssertMatch(stdout, .contains("USAGE: swift package"))
Expand Down Expand Up @@ -1235,6 +1250,143 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase {
}
}

func testPackageAddPluginDependencyExternalPackage() async throws {
try await testWithTemporaryDirectory { tmpPath in
let fs = localFileSystem
let path = tmpPath.appending("PackageB")
try fs.createDirectory(path)

try fs.writeFileContents(path.appending("Package.swift"), string:
"""
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "client",
targets: [ .target(name: "library") ]
)
"""
)
try localFileSystem.writeFileContents(path.appending(components: "Sources", "library", "library.swift"), string:
"""
public func Foo() { }
"""
)

_ = try await execute(["add-target-plugin", "--package", "other-package", "other-product", "library"], packagePath: path)

let manifest = path.appending("Package.swift")
XCTAssertFileExists(manifest)
let contents: String = try fs.readFileContents(manifest)

XCTAssertMatch(contents, .contains(#".plugin(name: "other-product", package: "other-package"#))
}
}

func testPackageAddPluginDependencyFromExternalPackageToNonexistentTarget() async throws {
try await testWithTemporaryDirectory { tmpPath in
let fs = localFileSystem
let path = tmpPath.appending("PackageB")
try fs.createDirectory(path)

try fs.writeFileContents(path.appending("Package.swift"), string:
"""
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "client",
targets: [ .target(name: "library") ]
)
"""
)
try localFileSystem.writeFileContents(path.appending(components: "Sources", "library", "library.swift"), string:
"""
public func Foo() { }
"""
)

try await assertExecuteCommandFails(
["add-target-plugin", "--package", "other-package", "other-product", "library-that-does-not-exist"],
packagePath: path,
expectedErrorContains: "error: unable to find target named 'library-that-does-not-exist' in package"
)

let manifest = path.appending("Package.swift")
XCTAssertFileExists(manifest)
let contents: String = try fs.readFileContents(manifest)

XCTAssertNoMatch(contents, .contains(#".plugin(name: "other-product", package: "other-package"#))
}
}


func testPackageAddPluginDependencyInternalPackage() async throws {
try await testWithTemporaryDirectory { tmpPath in
let fs = localFileSystem
let path = tmpPath.appending("PackageB")
try fs.createDirectory(path)

try fs.writeFileContents(path.appending("Package.swift"), string:
"""
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "client",
targets: [ .target(name: "library") ]
)
"""
)
try localFileSystem.writeFileContents(path.appending(components: "Sources", "library", "library.swift"), string:
"""
public func Foo() { }
"""
)

_ = try await execute(["add-target-plugin", "other-product", "library"], packagePath: path)

let manifest = path.appending("Package.swift")
XCTAssertFileExists(manifest)
let contents: String = try fs.readFileContents(manifest)

XCTAssertMatch(contents, .contains(#".plugin(name: "other-product"#))
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: I really appreciate the automated tests to validate the behaviour we want. Could I trouble you to add a few more that would check different use cases and fault injections?

  • Calling add-target-plugin on a non-existing package
  • Calling add-target-plugin providing all four combination of a valid/invalid plugin name and target name.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @bkhouri,
Thanks for the feedback! I've added two additional tests to cover this scenario:

Calling add-target-plugin on a non-existing package

Regarding your second request about validating the plugin and target names: the CLI currently just passes those strings through to the Package.swift manifest without performing validation, similar to how add-target-dependency behaves. Because of that, it's not currently possible to add meaningful tests that assert on the existence of the plugin or target.

That being said, I'm happy to discuss adding validation to some of these commands, but I believe it's outside the scope of this PR.

}

func testPackageAddPluginDependencyFromInternalPackageToNonexistentTarget() async throws {
try await testWithTemporaryDirectory { tmpPath in
let fs = localFileSystem
let path = tmpPath.appending("PackageB")
try fs.createDirectory(path)

try fs.writeFileContents(path.appending("Package.swift"), string:
"""
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "client",
targets: [ .target(name: "library") ]
)
"""
)
try localFileSystem.writeFileContents(path.appending(components: "Sources", "library", "library.swift"), string:
"""
public func Foo() { }
"""
)

try await assertExecuteCommandFails(
["add-target-plugin", "--package", "other-package", "other-product", "library-that-does-not-exist"],
packagePath: path,
expectedErrorContains: "error: unable to find target named 'library-that-does-not-exist' in package"
)

let manifest = path.appending("Package.swift")
XCTAssertFileExists(manifest)
let contents: String = try fs.readFileContents(manifest)

XCTAssertNoMatch(contents, .contains(#".plugin(name: "other-product"#))
}
}

func testPackageAddProduct() async throws {
try await testWithTemporaryDirectory { tmpPath in
let fs = localFileSystem
Expand Down