From 7a9ba7ae342ef35abb80084ee9f4ac6a3dd7ab7f Mon Sep 17 00:00:00 2001 From: Doug Gregor Date: Mon, 22 Apr 2024 11:54:06 -0700 Subject: [PATCH] [SE-0301] Add `swift package add-product` command and supporting library Introduce support for adding a new product to the package manifest, both programmatically (via PackageModelSyntax) and via the `swift package add-product` command. Help for this command is: OVERVIEW: Add a new product to the manifest USAGE: swift package add-product [--type ] [--targets ...] [--url ] [--path ] [--checksum ] ARGUMENTS: The name of the new product OPTIONS: --type The type of target to add, which can be one of 'executable', 'library', 'static-library', 'dynamic-library', or 'plugin' (default: library) --targets A list of targets that are part of this product --url The URL for a remote binary target --path The path to a local binary target --checksum The checksum for a remote binary target --version Show the version. -h, -help, --help Show help information. --- Sources/Commands/CMakeLists.txt | 1 + .../Commands/PackageCommands/AddProduct.swift | 118 ++++++++++++++++++ .../Commands/PackageCommands/AddTarget.swift | 6 +- .../PackageCommands/SwiftPackageCommand.swift | 1 + Sources/PackageModelSyntax/AddProduct.swift | 58 +++++++++ Sources/PackageModelSyntax/CMakeLists.txt | 2 + .../ProductDescription+Syntax.swift | 61 +++++++++ .../PackageModelSyntax/SyntaxEditUtils.swift | 41 ++++-- Tests/CommandsTests/PackageCommandTests.swift | 30 +++++ .../ManifestEditTests.swift | 39 ++++++ 10 files changed, 345 insertions(+), 12 deletions(-) create mode 100644 Sources/Commands/PackageCommands/AddProduct.swift create mode 100644 Sources/PackageModelSyntax/AddProduct.swift create mode 100644 Sources/PackageModelSyntax/ProductDescription+Syntax.swift diff --git a/Sources/Commands/CMakeLists.txt b/Sources/Commands/CMakeLists.txt index f7978b6d84e..75a97da70c6 100644 --- a/Sources/Commands/CMakeLists.txt +++ b/Sources/Commands/CMakeLists.txt @@ -8,6 +8,7 @@ add_library(Commands PackageCommands/AddDependency.swift + PackageCommands/AddProduct.swift PackageCommands/AddTarget.swift PackageCommands/APIDiff.swift PackageCommands/ArchiveSource.swift diff --git a/Sources/Commands/PackageCommands/AddProduct.swift b/Sources/Commands/PackageCommands/AddProduct.swift new file mode 100644 index 00000000000..92329fb0a2d --- /dev/null +++ b/Sources/Commands/PackageCommands/AddProduct.swift @@ -0,0 +1,118 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014-2024 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 AddProduct: SwiftCommand { + /// The package product type used for the command-line. This is a + /// subset of `ProductType` that expands out the library types. + enum CommandProductType: String, Codable, ExpressibleByArgument { + case executable + case library + case staticLibrary = "static-library" + case dynamicLibrary = "dynamic-library" + case plugin + } + + package static let configuration = CommandConfiguration( + abstract: "Add a new product to the manifest") + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + @Argument(help: "The name of the new product") + var name: String + + @Option(help: "The type of target to add, which can be one of 'executable', 'library', 'static-library', 'dynamic-library', or 'plugin'") + var type: CommandProductType = .library + + @Option( + parsing: .upToNextOption, + help: "A list of targets that are part of this product" + ) + var targets: [String] = [] + + @Option(help: "The URL for a remote binary target") + var url: String? + + @Option(help: "The path to a local binary target") + var path: String? + + @Option(help: "The checksum for a remote binary target") + var checksum: 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) + } + } + } + + // Map the product type. + let type: ProductType = switch self.type { + case .executable: .executable + case .library: .library(.automatic) + case .dynamicLibrary: .library(.dynamic) + case .staticLibrary: .library(.static) + case .plugin: .plugin + } + + let product = try ProductDescription( + name: name, + type: type, + targets: targets + ) + + let editResult = try PackageModelSyntax.AddProduct.addProduct( + product, + to: manifestSyntax + ) + + try editResult.applyEdits( + to: fileSystem, + manifest: manifestSyntax, + manifestPath: manifestPath, + verbose: !globalOptions.logging.quiet + ) + } + } +} + diff --git a/Sources/Commands/PackageCommands/AddTarget.swift b/Sources/Commands/PackageCommands/AddTarget.swift index 321fc7ba8ac..0bd46584f57 100644 --- a/Sources/Commands/PackageCommands/AddTarget.swift +++ b/Sources/Commands/PackageCommands/AddTarget.swift @@ -28,8 +28,6 @@ extension SwiftPackageCommand { case library case executable case test - case binary - case plugin case macro } @@ -42,7 +40,7 @@ extension SwiftPackageCommand { @Argument(help: "The name of the new target") var name: String - @Option(help: "The type of target to add, which can be one of ") + @Option(help: "The type of target to add, which can be one of 'library', 'executable', 'test', or 'macro'") var type: TargetType = .library @Option( @@ -91,8 +89,6 @@ extension SwiftPackageCommand { case .library: .regular case .executable: .executable case .test: .test - case .binary: .binary - case .plugin: .plugin case .macro: .macro } diff --git a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift index d2ca89f5b3c..e34da31471c 100644 --- a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift +++ b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift @@ -36,6 +36,7 @@ package struct SwiftPackageCommand: AsyncParsableCommand { version: SwiftVersion.current.completeDisplayString, subcommands: [ AddDependency.self, + AddProduct.self, AddTarget.self, Clean.self, PurgeCache.self, diff --git a/Sources/PackageModelSyntax/AddProduct.swift b/Sources/PackageModelSyntax/AddProduct.swift new file mode 100644 index 00000000000..3e058232f3a --- /dev/null +++ b/Sources/PackageModelSyntax/AddProduct.swift @@ -0,0 +1,58 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 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 SwiftParser +import SwiftSyntax +import SwiftSyntaxBuilder + +/// Add a product to the manifest's source code. +public struct AddProduct { + /// The set of argument labels that can occur after the "products" + /// argument in the Package 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 argumentLabelsAfterProducts: Set = [ + "dependencies", + "targets", + "swiftLanguageVersions", + "cLanguageStandard", + "cxxLanguageStandard" + ] + + /// Produce the set of source edits needed to add the given package + /// dependency to the given manifest file. + public static func addProduct( + _ product: ProductDescription, + 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 + } + + let newPackageCall = try packageCall.appendingToArrayArgument( + label: "products", + trailingLabels: argumentLabelsAfterProducts, + newElement: product.asSyntax() + ) + + return PackageEditResult( + manifestEdits: [ + .replace(packageCall, with: newPackageCall.description) + ] + ) + } +} diff --git a/Sources/PackageModelSyntax/CMakeLists.txt b/Sources/PackageModelSyntax/CMakeLists.txt index 3a968e84d7e..cc1f9fb6c20 100644 --- a/Sources/PackageModelSyntax/CMakeLists.txt +++ b/Sources/PackageModelSyntax/CMakeLists.txt @@ -8,11 +8,13 @@ add_library(PackageModelSyntax AddPackageDependency.swift + AddProduct.swift AddTarget.swift ManifestEditError.swift ManifestSyntaxRepresentable.swift PackageDependency+Syntax.swift PackageEditResult.swift + ProductDescription+Syntax.swift SyntaxEditUtils.swift TargetDescription+Syntax.swift ) diff --git a/Sources/PackageModelSyntax/ProductDescription+Syntax.swift b/Sources/PackageModelSyntax/ProductDescription+Syntax.swift new file mode 100644 index 00000000000..eed6650dfce --- /dev/null +++ b/Sources/PackageModelSyntax/ProductDescription+Syntax.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014-2024 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 PackageModel +import SwiftSyntax +import SwiftParser + +extension ProductDescription: ManifestSyntaxRepresentable { + /// The function name in the package manifest. + /// + /// Some of these are actually invalid, but it's up to the caller + /// to check the precondition. + private var functionName: String { + switch type { + case .executable: "executable" + case .library(_): "library" + case .macro: "macro" + case .plugin: "plugin" + case .snippet: "snippet" + case .test: "test" + } + } + + func asSyntax() -> ExprSyntax { + var arguments: [LabeledExprSyntax] = [] + arguments.append(label: "name", stringLiteral: name) + + // Libraries have a type. + if case .library(let libraryType) = type { + switch libraryType { + case .automatic: + break + + case .dynamic, .static: + arguments.append( + label: "type", + expression: ".\(raw: libraryType.rawValue)" + ) + } + } + + arguments.appendIfNonEmpty( + label: "targets", + arrayLiteral: targets + ) + + let separateParen: String = arguments.count > 1 ? "\n" : "" + let argumentsSyntax = LabeledExprListSyntax(arguments) + return ".\(raw: functionName)(\(argumentsSyntax)\(raw: separateParen))" + } +} diff --git a/Sources/PackageModelSyntax/SyntaxEditUtils.swift b/Sources/PackageModelSyntax/SyntaxEditUtils.swift index 27c7ed475ae..df64aa4c2d3 100644 --- a/Sources/PackageModelSyntax/SyntaxEditUtils.swift +++ b/Sources/PackageModelSyntax/SyntaxEditUtils.swift @@ -25,6 +25,16 @@ extension Trivia { var hasNewlines: Bool { contains(where: \.isNewline) } + + /// Produce trivia from the last newline to the end, dropping anything + /// prior to that. + func onlyLastLine() -> Trivia { + guard let lastNewline = pieces.lastIndex(where: { $0.isNewline }) else { + return self + } + + return Trivia(pieces: pieces[lastNewline...]) + } } /// Syntax walker to find the first occurrence of a given node kind that @@ -186,7 +196,7 @@ extension ArrayExprSyntax { if let last = elements.last { // The leading trivia of the new element should match that of the // last element. - leadingTrivia = last.leadingTrivia + leadingTrivia = last.leadingTrivia.onlyLastLine() // Add a trailing comma to the last element if it isn't already // there. @@ -324,14 +334,31 @@ extension Array { elements.append(expression: element.asSyntax()) } - // When we have more than one element in the array literal, we add - // newlines at the beginning of each element. Do the same for the - // right square bracket. - let rightSquareLeadingTrivia: Trivia = elements.count > 0 - ? .newline - : Trivia() + // Figure out the trivia for the left and right square + let leftSquareTrailingTrivia: Trivia + let rightSquareLeadingTrivia: Trivia + switch elements.count { + case 0: + // Put a single space between the square brackets. + leftSquareTrailingTrivia = Trivia() + rightSquareLeadingTrivia = .space + + case 1: + // Put spaces around the single element + leftSquareTrailingTrivia = .space + rightSquareLeadingTrivia = .space + + default: + // Each of the elements will have a leading newline. Add a leading + // newline before the close bracket. + leftSquareTrailingTrivia = Trivia() + rightSquareLeadingTrivia = .newline + } let array = ArrayExprSyntax( + leftSquare: .leftSquareToken( + trailingTrivia: leftSquareTrailingTrivia + ), elements: ArrayElementListSyntax(elements), rightSquare: .rightSquareToken( leadingTrivia: rightSquareLeadingTrivia diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index 82c4810ef1d..be29ac86dd5 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -851,6 +851,36 @@ final class PackageCommandTests: CommandsTestCase { } } + func testPackageAddProduct() throws { + try 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" + ) + """ + ) + + _ = try execute(["add-product", "MyLib", "--targets", "MyLib", "--type", "static-library"], packagePath: path) + + let manifest = path.appending("Package.swift") + XCTAssertFileExists(manifest) + let contents: String = try fs.readFileContents(manifest) + + XCTAssertMatch(contents, .contains(#"products:"#)) + XCTAssertMatch(contents, .contains(#".library"#)) + XCTAssertMatch(contents, .contains(#"name: "MyLib""#)) + XCTAssertMatch(contents, .contains(#"type: .static"#)) + XCTAssertMatch(contents, .contains(#"targets:"#)) + XCTAssertMatch(contents, .contains(#""MyLib""#)) + } + } func testPackageEditAndUnedit() throws { try fixture(name: "Miscellaneous/PackageEdit") { fixturePath in let fooPath = fixturePath.appending("foo") diff --git a/Tests/PackageModelSyntaxTests/ManifestEditTests.swift b/Tests/PackageModelSyntaxTests/ManifestEditTests.swift index 1e78a26be06..2acb6debd62 100644 --- a/Tests/PackageModelSyntaxTests/ManifestEditTests.swift +++ b/Tests/PackageModelSyntaxTests/ManifestEditTests.swift @@ -335,6 +335,43 @@ class ManifestEditTests: XCTestCase { } } + func testAddLibraryProduct() throws { + try assertManifestRefactor(""" + // swift-tools-version: 5.5 + let package = Package( + name: "packages", + targets: [ + .target(name: "MyLib"), + ], + ) + """, + expectedManifest: """ + // swift-tools-version: 5.5 + let package = Package( + name: "packages", + products: [ + .library( + name: "MyLib", + type: .dynamic, + targets: [ "MyLib" ] + ), + ], + targets: [ + .target(name: "MyLib"), + ], + ) + """) { manifest in + try AddProduct.addProduct( + ProductDescription( + name: "MyLib", + type: .library(.dynamic), + targets: [ "MyLib" ] + ), + to: manifest + ) + } + } + func testAddLibraryTarget() throws { try assertManifestRefactor(""" // swift-tools-version: 5.5 @@ -412,6 +449,7 @@ class ManifestEditTests: XCTestCase { let package = Package( name: "packages", targets: [ + // These are the targets .target(name: "MyLib") ] ) @@ -421,6 +459,7 @@ class ManifestEditTests: XCTestCase { let package = Package( name: "packages", targets: [ + // These are the targets .target(name: "MyLib"), .executableTarget( name: "MyProgram",