diff --git a/Sources/Commands/CMakeLists.txt b/Sources/Commands/CMakeLists.txt index c62c7424ed2..dfdc285f5ed 100644 --- a/Sources/Commands/CMakeLists.txt +++ b/Sources/Commands/CMakeLists.txt @@ -12,6 +12,7 @@ add_library(Commands PackageCommands/AddTarget.swift PackageCommands/AddTargetDependency.swift PackageCommands/AddSetting.swift + PackageCommands/AddTargetPlugin.swift PackageCommands/APIDiff.swift PackageCommands/ArchiveSource.swift PackageCommands/AuditBinaryArtifact.swift diff --git a/Sources/Commands/PackageCommands/AddTargetPlugin.swift b/Sources/Commands/PackageCommands/AddTargetPlugin.swift new file mode 100644 index 00000000000..8781ea9d07c --- /dev/null +++ b/Sources/Commands/PackageCommands/AddTargetPlugin.swift @@ -0,0 +1,87 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import PackageGraph +import Foundation + +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 + ) + } + } +} + diff --git a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift index da253850404..ddddd878631 100644 --- a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift +++ b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift @@ -39,6 +39,7 @@ public struct SwiftPackageCommand: AsyncParsableCommand { AddTargetDependency.self, AddSetting.self, AuditBinaryArtifact.self, + AddTargetPlugin.self, Clean.self, PurgeCache.self, Reset.self, diff --git a/Sources/PackageModelSyntax/AddPackageDependency.swift b/Sources/PackageModelSyntax/AddPackageDependency.swift index af017889d33..d4a1ac19bb7 100644 --- a/Sources/PackageModelSyntax/AddPackageDependency.swift +++ b/Sources/PackageModelSyntax/AddPackageDependency.swift @@ -130,4 +130,4 @@ fileprivate extension MappablePackageDependency.Kind { return id } } -} \ No newline at end of file +} diff --git a/Sources/PackageModelSyntax/AddTargetPlugin.swift b/Sources/PackageModelSyntax/AddTargetPlugin.swift new file mode 100644 index 00000000000..6c0bb1b7356 --- /dev/null +++ b/Sources/PackageModelSyntax/AddTargetPlugin.swift @@ -0,0 +1,165 @@ +//===----------------------------------------------------------------------===// +// +// 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 enum 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 = [] + + /// 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) + } + + guard try !self.pluginAlreadyAdded( + plugin, + to: targetName, + in: targetCall + ) + else { + return PackageEditResult(manifestEdits: []) + } + + let newTargetCall = try addTargetPluginLocal( + plugin, + to: targetCall + ) + + return PackageEditResult( + manifestEdits: [ + .replace(targetCall, with: newTargetCall.description) + ] + ) + } + + private static func pluginAlreadyAdded( + _ plugin: TargetDescription.PluginUsage, + to targetName: String, + in packageCall: FunctionCallExprSyntax + ) throws -> Bool { + let pluginSyntax = plugin.asSyntax() + guard let pluginFnSyntax = pluginSyntax.as(FunctionCallExprSyntax.self) + else { + throw ManifestEditError.cannotFindPackage + } + + guard let id = pluginFnSyntax.arguments.first(where: { + $0.label?.text == "name" + }) + else { + throw InternalError("Missing 'name' argument in plugin syntax") + } + + if let existingPlugins = packageCall.findArgument(labeled: "plugins") { + // If we have an existing plugins array, we need to check if + if let expr = existingPlugins.expression.as(ArrayExprSyntax.self) { + // Iterate through existing plugins and look for an argument that matches + // the `name` argument of the new plugin. + let existingArgument = expr.elements.first { elem in + if let funcExpr = elem.expression.as( + FunctionCallExprSyntax.self + ) { + return funcExpr.arguments.contains { + $0.with(\.trailingComma, nil).trimmedDescription == + id.with(\.trailingComma, nil).trimmedDescription + } + } + return true + } + + if let existingArgument { + let normalizedExistingArgument = existingArgument.detached.with(\.trailingComma, nil) + // This exact plugin already exists, return false to indicate we should do nothing. + if normalizedExistingArgument.trimmedDescription == pluginSyntax.trimmedDescription { + return true + } + throw ManifestEditError.existingPlugin( + pluginName: plugin.identifier, + taget: targetName + ) + } + } + } + + return false + } + + /// Implementation of adding a target plugin 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() + ) + } +} + +extension TargetDescription.PluginUsage { + fileprivate var identifier: String { + switch self { + case .plugin(let name, _): + name + } + } +} diff --git a/Sources/PackageModelSyntax/CMakeLists.txt b/Sources/PackageModelSyntax/CMakeLists.txt index 02142c690da..ca7abaade14 100644 --- a/Sources/PackageModelSyntax/CMakeLists.txt +++ b/Sources/PackageModelSyntax/CMakeLists.txt @@ -12,9 +12,11 @@ add_library(PackageModelSyntax AddSwiftSetting.swift AddTarget.swift AddTargetDependency.swift + AddTargetPlugin.swift ManifestEditError.swift ManifestSyntaxRepresentable.swift PackageDependency+Syntax.swift + PluginUsage+Syntax.swift PackageEditResult.swift ProductDescription+Syntax.swift SyntaxEditUtils.swift diff --git a/Sources/PackageModelSyntax/ManifestEditError.swift b/Sources/PackageModelSyntax/ManifestEditError.swift index 6c4cdde0806..506bbc9d039 100644 --- a/Sources/PackageModelSyntax/ManifestEditError.swift +++ b/Sources/PackageModelSyntax/ManifestEditError.swift @@ -24,6 +24,7 @@ package enum ManifestEditError: Error { case oldManifest(ToolsVersion, expected: ToolsVersion) case cannotAddSettingsToPluginTarget case existingDependency(dependencyName: String) + case existingPlugin(pluginName: String, taget: String) } extension ToolsVersion { @@ -49,6 +50,8 @@ extension ManifestEditError: CustomStringConvertible { "plugin targets do not support settings" case .existingDependency(let name): "unable to add dependency '\(name)' because it already exists in the list of dependencies" + case .existingPlugin(let name, let taget): + "unable to add plugin '\(name)' to taget '\(taget)' because it already exists in the list of plugins" } } } diff --git a/Sources/PackageModelSyntax/PluginUsage+Syntax.swift b/Sources/PackageModelSyntax/PluginUsage+Syntax.swift new file mode 100644 index 00000000000..6371dcaa624 --- /dev/null +++ b/Sources/PackageModelSyntax/PluginUsage+Syntax.swift @@ -0,0 +1,28 @@ +//===----------------------------------------------------------------------===// +// +// 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 +import SwiftSyntaxBuilder + +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))" + } + } + } +} diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index 3b96873e169..e38db0bd5b8 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -58,6 +58,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")) @@ -890,6 +905,8 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase { } } + // MARK: - Manifest Assert Helpers + // Helper function to arbitrarily assert on manifest content func assertManifest(_ packagePath: AbsolutePath, _ callback: (String) throws -> Void) throws { let manifestPath = packagePath.appending("Package.swift") @@ -911,7 +928,7 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase { initialManifest: String? = nil, url: String, requirementArgs: [String], - expectedManifestString: String, + expectedManifestString: String ) async throws { _ = try await execute( ["add-dependency", url] + requirementArgs, @@ -920,6 +937,50 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase { ) try assertManifestContains(packagePath, expectedManifestString) } + // Helper function to assert add-target-plugin succeeds + func executeAddTargetPluginAndAssert( + packagePath: AbsolutePath, + initialManifest: String? = nil, + args: [String], + expectedManifestString: String + ) async throws { + _ = try await execute( + ["add-target-plugin"] + args, + packagePath: packagePath, + manifest: initialManifest + ) + try assertManifestContains(packagePath, expectedManifestString) + } + + // Helper function to assert add-target-plugin fails without modifying the manifest + func assertAddTargetPluginFails( + packagePath: AbsolutePath, + initialManifest: String? = nil, + args: [String], + expectedErrorContains: String + ) async throws { + await XCTAssertThrowsCommandExecutionError( + try await execute( + ["add-target-plugin"] + args, + packagePath: packagePath, + manifest: initialManifest + ) + ) { error in + XCTAssertMatch(error.stderr, .contains(expectedErrorContains)) + } + } + + // Helper function to assert manifest does not contain a plugin entry + func assertManifestNotContains( + packagePath: AbsolutePath, + _ expected: String + ) throws { + try assertManifest(packagePath) { manifestContents in + XCTAssertNoMatch(manifestContents, .contains(expected)) + } + } + + // MARK: - Add Target Package func testPackageAddDifferentDependencyWithSameURLTwiceFails() async throws { try await testWithTemporaryDirectory { tmpPath in @@ -1346,6 +1407,221 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase { } } + // MARK: - Add Target Plugin + + func testPackageAddPluginDependencyExternalPackage() async throws { + try await testWithTemporaryDirectory { tmpPath in + let fs = localFileSystem + let path = tmpPath.appending("PackageB") + try fs.createDirectory(path) + + let initialManifest = """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package( + name: "client", + targets: [ .target(name: "library") ] + ) + """ + + try fs.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, + manifest: initialManifest + ) + + try assertManifestContains( + path, + #".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) + + let initialManifest = """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package( + name: "client", + targets: [ .target(name: "library") ] + ) + """ + try fs.writeFileContents( + path.appending(components: "Sources", "library", "library.swift"), + string: """ + public func Foo() { } + """ + ) + + try await assertAddTargetPluginFails( + packagePath: path, + initialManifest: initialManifest, + args: ["--package", "other-package", "other-product", "library-that-does-not-exist"], + expectedErrorContains: "error: unable to find target named 'library-that-does-not-exist' in package" + ) + + try assertManifestNotContains( + packagePath: path, + #".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) + + let initialManifest = """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package( + name: "client", + targets: [ .target(name: "library") ] + ) + """ + try fs.writeFileContents( + path.appending(components: "Sources", "library", "library.swift"), + string: """ + public func Foo() { } + """ + ) + + try await executeAddTargetPluginAndAssert( + packagePath: path, + initialManifest: initialManifest, + args: ["other-product", "library"], + expectedManifestString: #".plugin(name: "other-product")"# + ) + } + } + + func testPackageAddPluginDependencyFromInternalPackageToNonexistentTarget() async throws { + try await testWithTemporaryDirectory { tmpPath in + let fs = localFileSystem + let path = tmpPath.appending("PackageB") + try fs.createDirectory(path) + + let initialManifest = """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package( + name: "client", + targets: [ .target(name: "library") ] + ) + """ + try fs.writeFileContents( + path.appending(components: "Sources", "library", "library.swift"), + string: """ + public func Foo() { } + """ + ) + + try await assertAddTargetPluginFails( + packagePath: path, + initialManifest: initialManifest, + args: ["--package", "other-package", "other-product", "library-that-does-not-exist"], + expectedErrorContains: "error: unable to find target named 'library-that-does-not-exist' in package" + ) + try assertManifestNotContains( + packagePath: path, + #".plugin(name: "other-product"# + ) + } + } + + // MARK: Add Target Plugin Idempotency Tests + /// Adding the same external package plugin twice should have no effect + func testPackageAddSamePluginExternalPackageTwiceHasNoEffect() async throws { + try await testWithTemporaryDirectory { tmpPath in + let fs = localFileSystem + let path = tmpPath.appending("PackageB") + try fs.createDirectory(path) + + let initialManifest = """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package( + name: "client", + targets: [ .target(name: "library", plugins: [.plugin(name: "other-product", package: "other-package")]) ] + ) + """ + try fs.writeFileContents(path.appending("Package.swift"), string: initialManifest) + try fs.writeFileContents( + path.appending(components: "Sources", "library", "library.swift"), + string: """ + public func Foo() { } + """ + ) + + let expected = #".plugin(name: "other-product", package: "other-package")"# + try await executeAddTargetPluginAndAssert( + packagePath: path, + initialManifest: initialManifest, + args: ["--package", "other-package", "other-product", "library"], + expectedManifestString: expected + ) + + try assertManifest(path) { contents in + let comps = contents.components(separatedBy: expected) + XCTAssertEqual(comps.count, 2, "Expected the plugin entry to appear only once.") + } + } + } + + /// Adding the same internal package plugin twice should have no effect + func testPackageAddSamePluginInternalPackageTwiceHasNoEffect() async throws { + try await testWithTemporaryDirectory { tmpPath in + let fs = localFileSystem + let path = tmpPath.appending("PackageB") + try fs.createDirectory(path) + + let initialManifest = """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package( + name: "client", + targets: [ .target(name: "library", plugins: [.plugin(name: "other-product")]) ] + ) + """ + try fs.writeFileContents(path.appending("Package.swift"), string: initialManifest) + try fs.writeFileContents( + path.appending(components: "Sources", "library", "library.swift"), + string: """ + public func Foo() { } + """ + ) + + let expected = #".plugin(name: "other-product")"# + try await executeAddTargetPluginAndAssert( + packagePath: path, + initialManifest: initialManifest, + args: ["other-product", "library"], + expectedManifestString: expected + ) + + try assertManifest(path) { contents in + let comps = contents.components(separatedBy: expected) + XCTAssertEqual(comps.count, 2, "Expected the plugin entry to appear only once.") + } + } + } + + // MARK: - Add Target Product + func testPackageAddProduct() async throws { try await testWithTemporaryDirectory { tmpPath in let fs = localFileSystem