From 0c74056847e0e92ebe3ae16b186d0f814a01c994 Mon Sep 17 00:00:00 2001 From: hi2gage Date: Sun, 30 Mar 2025 16:58:47 -0600 Subject: [PATCH 1/2] Add AddTargetPlugin command --- Sources/Commands/CMakeLists.txt | 1 + .../PackageCommands/AddTargetPlugin.swift | 85 ++++++++++ .../PackageCommands/SwiftPackageCommand.swift | 1 + .../PackageModelSyntax/AddTargetPlugin.swift | 91 +++++++++++ Sources/PackageModelSyntax/CMakeLists.txt | 2 + .../PluginUsage+Syntax.swift | 27 ++++ Tests/CommandsTests/PackageCommandTests.swift | 152 ++++++++++++++++++ 7 files changed, 359 insertions(+) create mode 100644 Sources/Commands/PackageCommands/AddTargetPlugin.swift create mode 100644 Sources/PackageModelSyntax/AddTargetPlugin.swift create mode 100644 Sources/PackageModelSyntax/PluginUsage+Syntax.swift 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..2637155f248 --- /dev/null +++ b/Sources/Commands/PackageCommands/AddTargetPlugin.swift @@ -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 + ) + } + } +} + 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/AddTargetPlugin.swift b/Sources/PackageModelSyntax/AddTargetPlugin.swift new file mode 100644 index 00000000000..2dae71fcb87 --- /dev/null +++ b/Sources/PackageModelSyntax/AddTargetPlugin.swift @@ -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 = [] + + /// 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() + ) + } +} + 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/PluginUsage+Syntax.swift b/Sources/PackageModelSyntax/PluginUsage+Syntax.swift new file mode 100644 index 00000000000..bf661903ddf --- /dev/null +++ b/Sources/PackageModelSyntax/PluginUsage+Syntax.swift @@ -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))" + } + } + } +} diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index 3b96873e169..85315b6eb5c 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")) @@ -1346,6 +1361,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"#)) + } + } + + 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 From 710e3cf024362f8c3d9acbb20b9497ff46b55416 Mon Sep 17 00:00:00 2001 From: hi2gage Date: Sun, 29 Jun 2025 14:12:04 -0600 Subject: [PATCH 2/2] Add idempotentancy to AddTargetPlugin --- .../PackageCommands/AddTargetPlugin.swift | 2 + .../AddPackageDependency.swift | 2 +- .../PackageModelSyntax/AddTargetPlugin.swift | 86 +++++- .../ManifestEditError.swift | 3 + .../PluginUsage+Syntax.swift | 1 + Tests/CommandsTests/PackageCommandTests.swift | 264 +++++++++++++----- 6 files changed, 281 insertions(+), 77 deletions(-) diff --git a/Sources/Commands/PackageCommands/AddTargetPlugin.swift b/Sources/Commands/PackageCommands/AddTargetPlugin.swift index 2637155f248..8781ea9d07c 100644 --- a/Sources/Commands/PackageCommands/AddTargetPlugin.swift +++ b/Sources/Commands/PackageCommands/AddTargetPlugin.swift @@ -20,6 +20,8 @@ import SwiftSyntax import TSCBasic import TSCUtility import Workspace +import PackageGraph +import Foundation extension SwiftPackageCommand { struct AddTargetPlugin: SwiftCommand { 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 index 2dae71fcb87..6c0bb1b7356 100644 --- a/Sources/PackageModelSyntax/AddTargetPlugin.swift +++ b/Sources/PackageModelSyntax/AddTargetPlugin.swift @@ -18,7 +18,7 @@ import SwiftSyntax import SwiftSyntaxBuilder /// Add a target plugin to a manifest's source code. -public struct AddTargetPlugin { +public enum AddTargetPlugin { /// The set of argument labels that can occur after the "plugins" /// argument in the various target initializers. /// @@ -54,19 +54,33 @@ public struct AddTargetPlugin { } guard let stringLiteral = nameArgument.expression.as(StringLiteralExprSyntax.self), - let literalValue = stringLiteral.representedLiteralValue else { + let literalValue = stringLiteral.representedLiteralValue + else { return false } return literalValue == targetName } - guard let targetCall = FunctionCallExprSyntax.findFirst(in: targetArray, matching: matchesTargetCall) else { + 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 + plugin, + to: targetCall ) return PackageEditResult( @@ -76,16 +90,76 @@ public struct AddTargetPlugin { ) } - /// Implementation of adding a target dependency to an existing call. + 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, + 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/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 index bf661903ddf..6371dcaa624 100644 --- a/Sources/PackageModelSyntax/PluginUsage+Syntax.swift +++ b/Sources/PackageModelSyntax/PluginUsage+Syntax.swift @@ -12,6 +12,7 @@ import PackageModel import SwiftSyntax +import SwiftSyntaxBuilder extension TargetDescription.PluginUsage: ManifestSyntaxRepresentable { func asSyntax() -> ExprSyntax { diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index 85315b6eb5c..e38db0bd5b8 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -905,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") @@ -926,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, @@ -935,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 @@ -1361,35 +1407,39 @@ 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) - try fs.writeFileContents(path.appending("Package.swift"), string: - """ - // swift-tools-version: 5.9 - import PackageDescription - let package = Package( - name: "client", - targets: [ .target(name: "library") ] - ) - """ + let initialManifest = """ + // 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: + """ + + 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) - - let manifest = path.appending("Package.swift") - XCTAssertFileExists(manifest) - let contents: String = try fs.readFileContents(manifest) + _ = try await execute( + ["add-target-plugin", "--package", "other-package", "other-product", "library"], + packagePath: path, + manifest: initialManifest + ) - XCTAssertMatch(contents, .contains(#".plugin(name: "other-product", package: "other-package"#)) + try assertManifestContains( + path, + #".plugin(name: "other-product", package: "other-package")"# + ) } } @@ -1399,33 +1449,32 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase { 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") ] - ) - """ + let initialManifest = """ + // 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: - """ + """ + try fs.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"], + 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" ) - 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"#)) + try assertManifestNotContains( + packagePath: path, + #".plugin(name: "other-product", package: "other-package")"# + ) } } @@ -1436,29 +1485,27 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase { 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") ] - ) - """ + let initialManifest = """ + // 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: - """ + """ + try fs.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"#)) + try await executeAddTargetPluginAndAssert( + packagePath: path, + initialManifest: initialManifest, + args: ["other-product", "library"], + expectedManifestString: #".plugin(name: "other-product")"# + ) } } @@ -1468,36 +1515,113 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase { 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") ] - ) - """ + let initialManifest = """ + // 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: - """ + """ + try fs.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"], + 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"# + ) + } + } - let manifest = path.appending("Package.swift") - XCTAssertFileExists(manifest) - let contents: String = try fs.readFileContents(manifest) + // 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() { } + """ + ) - XCTAssertNoMatch(contents, .contains(#".plugin(name: "other-product"#)) + 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