diff --git a/Sources/Commands/CMakeLists.txt b/Sources/Commands/CMakeLists.txt index 3c273289783..de649872045 100644 --- a/Sources/Commands/CMakeLists.txt +++ b/Sources/Commands/CMakeLists.txt @@ -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 diff --git a/Sources/Commands/PackageCommands/AddTargetPlugin.swift b/Sources/Commands/PackageCommands/AddTargetPlugin.swift new file mode 100644 index 00000000000..afee37a424a --- /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.AddPluginDependency.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 222878ab5fc..8ebf5ecb1ff 100644 --- a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift +++ b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift @@ -37,6 +37,7 @@ public struct SwiftPackageCommand: AsyncParsableCommand { AddProduct.self, AddTarget.self, AddTargetDependency.self, + AddTargetPlugin.self, Clean.self, PurgeCache.self, Reset.self, diff --git a/Sources/PackageModelSyntax/AddPluginDependency.swift b/Sources/PackageModelSyntax/AddPluginDependency.swift new file mode 100644 index 00000000000..b1147f7cb41 --- /dev/null +++ b/Sources/PackageModelSyntax/AddPluginDependency.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 AddPluginDependency { + /// 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/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 eb1f658d432..1b21774eb2a 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -1235,6 +1235,70 @@ 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 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 testPackageAddProduct() async throws { try await testWithTemporaryDirectory { tmpPath in let fs = localFileSystem diff --git a/Tests/PackageRegistryTests/RegistryClientTests.swift b/Tests/PackageRegistryTests/RegistryClientTests.swift index b8390c1900f..809fdf32b89 100644 --- a/Tests/PackageRegistryTests/RegistryClientTests.swift +++ b/Tests/PackageRegistryTests/RegistryClientTests.swift @@ -803,7 +803,7 @@ final class RegistryClientTests: XCTestCase { let availableManifests = try await registryClient.getAvailableManifests( package: identity, version: version, - observabilityScope: ObservabilitySystem.NOOP, + observabilityScope: ObservabilitySystem.NOOP ) XCTAssertEqual(availableManifests["Package.swift"]?.toolsVersion, .v5_5) @@ -3987,7 +3987,7 @@ extension RegistryClient { package: package.underlying, version: version, fileSystem: InMemoryFileSystem(), - observabilityScope: ObservabilitySystem.NOOP, + observabilityScope: ObservabilitySystem.NOOP ) }