From 9b1e5aba091f502857419c61c428a400609d105c Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 14 May 2025 13:36:12 -0400 Subject: [PATCH 001/225] created command for templates + added commands for local and git --- Sources/Commands/PackageCommands/Init.swift | 6 +- .../PackageCommands/PackageTemplates.swift | 123 ++++++++++++++++++ Sources/Workspace/InitTemplatePackage.swift | 110 ++++++++++++++++ 3 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 Sources/Commands/PackageCommands/PackageTemplates.swift create mode 100644 Sources/Workspace/InitTemplatePackage.swift diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 7057845a3df..c5d1336a93d 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -23,7 +23,11 @@ import SPMBuildCore extension SwiftPackageCommand { struct Init: SwiftCommand { public static let configuration = CommandConfiguration( - abstract: "Initialize a new package.") + abstract: "Initialize a new package.", + subcommands: [ + PackageTemplates.self + ] + ) @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions diff --git a/Sources/Commands/PackageCommands/PackageTemplates.swift b/Sources/Commands/PackageCommands/PackageTemplates.swift new file mode 100644 index 00000000000..e8dc593ef89 --- /dev/null +++ b/Sources/Commands/PackageCommands/PackageTemplates.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014-2022 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 + +@_spi(SwiftPMInternal) +import CoreCommands + +import PackageModel +import Workspace +import SPMBuildCore +import Workspacex +import TSCUtility + + +extension SwiftPackageCommand { + struct PackageTemplates: SwiftCommand { + public static let configuration = CommandConfiguration( + commandName: "template", + abstract: "Initialize a new package based on a template." + ) + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + @Option( + name: .customLong("template-type"), + help: ArgumentHelp("Package type:", discussion: """ + - registry : Use a template from a package registry. + - git : Use a template from a Git repository. + - local : Use a template from a local directory. + """)) + var templateType: TemplateType + + @Option(name: .customLong("package-name"), help: "Provide the name for the new package.") + var packageName: String? + + //package-path provides the consumer's package + + @Option( + name: .customLong("template-path"), + help: "Specify the package path to operate on (default current directory). This changes the working directory before any other operation.", + completion: .directory + ) + public var templateDirectory: AbsolutePath + + //options for type git + @Option(help: "The exact package version to depend on.") + var exact: Version? + + @Option(help: "The specific package revision to depend on.") + var revision: String? + + @Option(help: "The branch of the package to depend on.") + var branch: String? + + @Option(help: "The package version to depend on (up to the next major version).") + var from: Version? + + @Option(help: "The package version to depend on (up to the next minor version).") + var upToNextMinorFrom: Version? + + @Option(help: "Specify upper bound on the package version range (exclusive).") + var to: Version? + + + enum TemplateType: String, Codable, CaseIterable, ExpressibleByArgument { + case local + case git + case registry + } + + + func run(_ swiftCommandState: SwiftCommandState) throws { + + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") + } + + switch self.templateType { + case .local: + try self.generateFromLocalTemplate( + packagePath: templateDirectory, + ) + case .git: + try self.generateFromGitTemplate() + case .generateFromRegistryTemplate: + try self.generateFromRegistryTemplate() + } + + } + + private func generateFromLocalTemplate( + packagePath: AbsolutePath + ) throws { + + let template = InitTemplatePackage(initMode: templateType, packageName: packageName, templatePath: templateDirectory, fileSystem: swiftCommandState.fileSystem) + } + + + private func generateFromGitTemplate( + ) throws { + + } + + private func generateFromRegistryTemplate( + ) throws { + + } + } +} + + diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift new file mode 100644 index 00000000000..213a36b1101 --- /dev/null +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -0,0 +1,110 @@ +// +// InitTemplatePackage.swift +// SwiftPM +// +// Created by John Bute on 2025-05-13. +// +import Basics +import PackageModel +import SPMBuildCore +import TSCUtility +@_spi(SwiftPMInternal) +import Foundation +import Commands + + +final class InitTemplatePackage { + + + + var initMode: TemplateType + + + var packageName: String? + + var templatePath: AbsolutePath + + let fileSystem: FileSystem + + init(initMode: InitPackage.PackageType, packageName: String? = nil, templatePath: AbsolutePath, fileSystem: FileSystem) { + self.initMode = initMode + self.packageName = packageName + self.templatePath = templatePath + self.fileSystem = fileSystem + + } + + + private func checkTemplateExists(templatePath: AbsolutePath) throws { + //Checks if there is a package in directory, if it contains a .template command line-tool and if it contains a /template folder. + + //check if the path does exist + guard self.fileSystem.exists(templatePath) else { + throw TemplateError.invalidPath + } + + // Check if Package.swift exists in the directory + let manifest = templatePath.appending(component: Manifest.filename) + guard self.fileSystem.exists(manifest) else { + throw TemplateError.invalidPath + } + + //check if package.swift contains a .plugin + + //check if it contains a template folder + + } + + func initPackage(_ swiftCommandState: SwiftCommandState) throws { + + //Logic here for initializing initial package (should find better way to organize this but for now) + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") + } + + let packageName = self.packageName ?? cwd.basename + + // Testing is on by default, with XCTest only enabled explicitly. + // For macros this is reversed, since we don't support testing + // macros with Swift Testing yet. + var supportedTestingLibraries = Set() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.swiftTesting) + } + + let initPackage = try InitPackage( + name: packageName, + packageType: initMode, + supportedTestingLibraries: supportedTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, + fileSystem: swiftCommandState.fileSystem + ) + initPackage.progressReporter = { message in + print(message) + } + try initPackage.writePackageStructure() + } +} + +private enum TemplateError: Swift.Error { + case invalidPath + case manifestAlreadyExists +} + + +extension TemplateError: CustomStringConvertible { + var description: String { + switch self { + case .manifestAlreadyExists: + return "a manifest file already exists in this directory" + case let .invalidPath: + return "Path does not exist, or is invalid." + } + } +} From b800ec61549ae7be351b915e99243a81f2c381b5 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 21 May 2025 13:12:59 -0400 Subject: [PATCH 002/225] initial push for templates in package.swift prototype --- .../PackageCommands/PackageTemplates.swift | 18 +- .../PackageDescriptionSerialization.swift | 30 ++++ ...geDescriptionSerializationConversion.swift | 45 +++++ Sources/PackageDescription/Target.swift | 112 +++++++++++- .../PackageLoading/ManifestJSONParser.swift | 58 ++++++- Sources/PackageLoading/ManifestLoader.swift | 3 +- .../Manifest/TargetDescription.swift | 162 +++++++++++++++++- .../ManifestSourceGeneration.swift | 69 +++++++- Sources/PackageModelSyntax/AddTarget.swift | 4 +- .../TargetDescription+Syntax.swift | 1 + Sources/Workspace/InitTemplatePackage.swift | 30 ++-- Tests/FunctionalTests/PluginTests.swift | 23 ++- 12 files changed, 520 insertions(+), 35 deletions(-) diff --git a/Sources/Commands/PackageCommands/PackageTemplates.swift b/Sources/Commands/PackageCommands/PackageTemplates.swift index e8dc593ef89..423031a27c8 100644 --- a/Sources/Commands/PackageCommands/PackageTemplates.swift +++ b/Sources/Commands/PackageCommands/PackageTemplates.swift @@ -19,10 +19,10 @@ import CoreCommands import PackageModel import Workspace import SPMBuildCore -import Workspacex import TSCUtility + extension SwiftPackageCommand { struct PackageTemplates: SwiftCommand { public static let configuration = CommandConfiguration( @@ -40,7 +40,7 @@ extension SwiftPackageCommand { - git : Use a template from a Git repository. - local : Use a template from a local directory. """)) - var templateType: TemplateType + var templateType: InitTemplatePackage.TemplateType @Option(name: .customLong("package-name"), help: "Provide the name for the new package.") var packageName: String? @@ -72,13 +72,6 @@ extension SwiftPackageCommand { @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? - - - enum TemplateType: String, Codable, CaseIterable, ExpressibleByArgument { - case local - case git - case registry - } func run(_ swiftCommandState: SwiftCommandState) throws { @@ -91,17 +84,19 @@ extension SwiftPackageCommand { case .local: try self.generateFromLocalTemplate( packagePath: templateDirectory, + swiftCommandState: swiftCommandState ) case .git: try self.generateFromGitTemplate() - case .generateFromRegistryTemplate: + case .registry: try self.generateFromRegistryTemplate() } } private func generateFromLocalTemplate( - packagePath: AbsolutePath + packagePath: AbsolutePath, + swiftCommandState: SwiftCommandState ) throws { let template = InitTemplatePackage(initMode: templateType, packageName: packageName, templatePath: templateDirectory, fileSystem: swiftCommandState.fileSystem) @@ -121,3 +116,4 @@ extension SwiftPackageCommand { } +extension InitTemplatePackage.TemplateType: ExpressibleByArgument {} diff --git a/Sources/PackageDescription/PackageDescriptionSerialization.swift b/Sources/PackageDescription/PackageDescriptionSerialization.swift index 8ade7137333..fc18ebaef8b 100644 --- a/Sources/PackageDescription/PackageDescriptionSerialization.swift +++ b/Sources/PackageDescription/PackageDescriptionSerialization.swift @@ -180,6 +180,7 @@ enum Serialization { case binary case plugin case `macro` + case template } enum PluginCapability: Codable { @@ -210,6 +211,34 @@ enum Serialization { case plugin(name: String, package: String?) } + enum TemplateInitializationOptions: Codable { + case packageInit(templateType: TemplateType, executable: TargetDependency, templatePermissions: [TemplatePermissions]?, description: String) + } + + enum TemplateType: Codable { + /// A target that contains code for the Swift package's functionality. + case regular + /// A target that contains code for an executable's main module. + case executable + /// A target that contains tests for the Swift package's other targets. + case test + /// A target that adapts a library on the system to work with Swift + /// packages. + case `macro` + } + + enum TemplateNetworkPermissionScope: Codable { + case none + case local(ports: [Int]) + case all(ports: [Int]) + case docker + case unixDomainSocket + } + + enum TemplatePermissions: Codable { + case allowNetworkConnections(scope: TemplateNetworkPermissionScope, reason: String) + } + struct Target: Codable { let name: String let path: String? @@ -230,6 +259,7 @@ enum Serialization { let linkerSettings: [LinkerSetting]? let checksum: String? let pluginUsages: [PluginUsage]? + let templateInitializationOptions: TemplateInitializationOptions? } // MARK: - resource serialization diff --git a/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift b/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift index 09d4f73ebf3..b8d9219c98a 100644 --- a/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift +++ b/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift @@ -237,6 +237,7 @@ extension Serialization.TargetType { case .binary: self = .binary case .plugin: self = .plugin case .macro: self = .macro + case .template: self = .template } } } @@ -295,6 +296,49 @@ extension Serialization.PluginUsage { } } +extension Serialization.TemplateInitializationOptions { + init(_ usage: PackageDescription.Target.TemplateInitializationOptions) { + switch usage { + + case .packageInit(let templateType, let executable, let templatePermissions, let description): + self = .packageInit(templateType: .init(templateType), executable: .init(executable), templatePermissions: templatePermissions?.map { .init($0) }, description: description) + } + } +} +extension Serialization.TemplateType { + init(_ type: PackageDescription.Target.TemplateType) { + switch type { + case .regular: self = .regular + case .executable: self = .executable + case .macro: self = .macro + case .test: self = .test + } + } +} + +extension Serialization.TemplatePermissions { + init(_ permission: PackageDescription.TemplatePermissions) { + switch permission { + case .allowNetworkConnections(let scope, let reason): self = .allowNetworkConnections( + scope: .init(scope), + reason: reason + ) + } + } +} + +extension Serialization.TemplateNetworkPermissionScope { + init(_ scope: PackageDescription.TemplateNetworkPermissionScope) { + switch scope { + case .none: self = .none + case .local(let ports): self = .local(ports: ports) + case .all(let ports): self = .all(ports: ports) + case .docker: self = .docker + case .unixDomainSocket: self = .unixDomainSocket + } + } +} + extension Serialization.Target { init(_ target: PackageDescription.Target) { self.name = target.name @@ -316,6 +360,7 @@ extension Serialization.Target { self.linkerSettings = target.linkerSettings?.map { .init($0) } self.checksum = target.checksum self.pluginUsages = target.plugins?.map { .init($0) } + self.templateInitializationOptions = target.templateInitializationOptions.map { .init($0) } } } diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index cca6fde7b04..668995142c0 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -39,6 +39,8 @@ public final class Target { case plugin /// A target that provides a Swift macro. case `macro` + /// A target that provides a Swift template + case template } /// The different types of a target's dependency on another entity. @@ -230,6 +232,25 @@ public final class Target { case plugin(name: String, package: String?) } + public var templateInitializationOptions: TemplateInitializationOptions? + + public enum TemplateType: String { + /// A target that contains code for the Swift package's functionality. + case regular + /// A target that contains code for an executable's main module. + case executable + /// A target that contains tests for the Swift package's other targets. + case test + /// A target that adapts a library on the system to work with Swift + /// packages. + case `macro` + } + + @available(_PackageDescription, introduced: 5.9) + public enum TemplateInitializationOptions { + case packageInit(templateType: TemplateType, executable: Dependency, templatePermissions: [TemplatePermissions]? = nil, description: String) + } + /// Construct a target. @_spi(PackageDescriptionInternal) public init( @@ -251,7 +272,8 @@ public final class Target { swiftSettings: [SwiftSetting]? = nil, linkerSettings: [LinkerSetting]? = nil, checksum: String? = nil, - plugins: [PluginUsage]? = nil + plugins: [PluginUsage]? = nil, + templateInitializationOptions: TemplateInitializationOptions? = nil ) { self.name = name self.dependencies = dependencies @@ -280,7 +302,8 @@ public final class Target { pkgConfig == nil && providers == nil && pluginCapability == nil && - checksum == nil + checksum == nil && + templateInitializationOptions == nil ) case .system: precondition( @@ -296,7 +319,8 @@ public final class Target { swiftSettings == nil && linkerSettings == nil && checksum == nil && - plugins == nil + plugins == nil && + templateInitializationOptions == nil ) case .binary: precondition( @@ -312,7 +336,8 @@ public final class Target { cxxSettings == nil && swiftSettings == nil && linkerSettings == nil && - plugins == nil + plugins == nil && + templateInitializationOptions == nil ) case .plugin: precondition( @@ -326,7 +351,8 @@ public final class Target { cxxSettings == nil && swiftSettings == nil && linkerSettings == nil && - plugins == nil + plugins == nil && + templateInitializationOptions == nil ) case .macro: precondition( @@ -337,7 +363,16 @@ public final class Target { providers == nil && pluginCapability == nil && cSettings == nil && - cxxSettings == nil + cxxSettings == nil && + templateInitializationOptions == nil + ) + case .template: + precondition( + url == nil && + pkgConfig == nil && + providers == nil && + pluginCapability == nil && + checksum == nil ) } } @@ -1235,6 +1270,26 @@ public final class Target { packageAccess: packageAccess, pluginCapability: capability) } + + @available(_PackageDescription, introduced: 6.0) + public static func template( + name: String, + templateInitializationOptions: TemplateInitializationOptions, + exclude: [String] = [], + executable: Dependency + ) -> Target { + return Target( + name: name, + dependencies: [], + path: nil, + exclude: exclude, + sources: nil, + publicHeadersPath: nil, + type: .template, + packageAccess: false, + templateInitializationOptions: templateInitializationOptions + ) + } } extension Target.Dependency { @@ -1563,3 +1618,48 @@ extension Target.PluginUsage: ExpressibleByStringLiteral { } } +/// The type of permission a plug-in requires. +/// +/// Supported types are ``allowNetworkConnections(scope:reason:)`` and ``writeToPackageDirectory(reason:)``. +@available(_PackageDescription, introduced: 6.0) +public enum TemplatePermissions { + /// Create a permission to make network connections. + /// + /// The command plug-in requires permission to make network connections. The `reason` string is shown + /// to the user at the time of request for approval, explaining why the plug-in is requesting access. + /// - Parameter scope: The scope of the permission. + /// - Parameter reason: A reason why the permission is needed. This is shown to the user when permission is sought. + @available(_PackageDescription, introduced: 6.0) + case allowNetworkConnections(scope: TemplateNetworkPermissionScope, reason: String) + +} + +/// The scope of a network permission. +/// +/// The scope can be none, local connections only, or all connections. +@available(_PackageDescription, introduced: 5.9) +public enum TemplateNetworkPermissionScope { + /// Do not allow network access. + case none + /// Allow local network connections; can be limited to a list of allowed ports. + case local(ports: [Int] = []) + /// Allow local and outgoing network connections; can be limited to a list of allowed ports. + case all(ports: [Int] = []) + /// Allow connections to Docker through UNIX domain sockets. + case docker + /// Allow connections to any UNIX domain socket. + case unixDomainSocket + + /// Allow local and outgoing network connections, limited to a range of allowed ports. + public static func all(ports: Range) -> TemplateNetworkPermissionScope { + return .all(ports: Array(ports)) + } + + /// Allow local network connections, limited to a range of allowed ports. + public static func local(ports: Range) -> TemplateNetworkPermissionScope { + return .local(ports: Array(ports)) + } +} + + + diff --git a/Sources/PackageLoading/ManifestJSONParser.swift b/Sources/PackageLoading/ManifestJSONParser.swift index 1082aaf2a6e..567011c47d6 100644 --- a/Sources/PackageLoading/ManifestJSONParser.swift +++ b/Sources/PackageLoading/ManifestJSONParser.swift @@ -198,6 +198,7 @@ enum ManifestJSONParser { try target.exclude.forEach{ _ = try RelativePath(validating: $0) } let pluginUsages = target.pluginUsages?.map { TargetDescription.PluginUsage.init($0) } + let templateInitializationOptions = try target.templateInitializationOptions.map { try TargetDescription.TemplateInitializationOptions.init($0, identityResolver: identityResolver)} return try TargetDescription( name: target.name, @@ -215,7 +216,8 @@ enum ManifestJSONParser { pluginCapability: pluginCapability, settings: try Self.parseBuildSettings(target), checksum: target.checksum, - pluginUsages: pluginUsages + pluginUsages: pluginUsages, + templateInitializationOptions: templateInitializationOptions ) } @@ -566,6 +568,8 @@ extension TargetDescription.TargetKind { self = .plugin case .macro: self = .macro + case .template: + self = .template } } } @@ -631,6 +635,58 @@ extension TargetDescription.PluginUsage { } } +extension TargetDescription.TemplateInitializationOptions { + init (_ usage: Serialization.TemplateInitializationOptions, identityResolver: IdentityResolver) throws { + switch usage { + case .packageInit(let templateType, let executable, let templatePermissions, let description): + self = .packageInit(templateType: .init(templateType), executable: try .init(executable, identityResolver: identityResolver), templatePermissions: templatePermissions?.map { .init($0) }, description: description) + } + } +} + +extension TargetDescription.TemplateType { + init(_ type: Serialization.TemplateType) { + switch type { + case .regular: + self = .regular + case .executable: + self = .executable + case .test: + self = .test + case .macro: + self = .macro + } + } +} + +extension TargetDescription.TemplatePermission { + init(_ permission: Serialization.TemplatePermissions) { + switch permission { + case .allowNetworkConnections(let scope, let reason): + self = .allowNetworkConnections(scope: .init(scope), reason: reason) + } + + } +} + +extension TargetDescription.TemplateNetworkPermissionScope { + init(_ scope: Serialization.TemplateNetworkPermissionScope) { + switch scope { + case .none: + self = .none + case .local(let ports): + self = .local(ports: ports) + case .all(ports: let ports): + self = .all(ports: ports) + case .docker: + self = .docker + case .unixDomainSocket: + self = .unixDomainSocket + } + } +} + + extension TSCUtility.Version { init(_ version: Serialization.Version) { self.init( diff --git a/Sources/PackageLoading/ManifestLoader.swift b/Sources/PackageLoading/ManifestLoader.swift index 76d576fb3d5..ea17a3007ac 100644 --- a/Sources/PackageLoading/ManifestLoader.swift +++ b/Sources/PackageLoading/ManifestLoader.swift @@ -940,10 +940,11 @@ public final class ManifestLoader: ManifestLoaderProtocol { return evaluationResult // Return the result containing the error output } + // Read the JSON output that was emitted by libPackageDescription. let jsonOutput: String = try localFileSystem.readFileContents(jsonOutputFile) evaluationResult.manifestJSON = jsonOutput - + print(jsonOutput) // withTemporaryDirectory handles cleanup automatically return evaluationResult } diff --git a/Sources/PackageModel/Manifest/TargetDescription.swift b/Sources/PackageModel/Manifest/TargetDescription.swift index d336bedb60a..6f5fa5068dc 100644 --- a/Sources/PackageModel/Manifest/TargetDescription.swift +++ b/Sources/PackageModel/Manifest/TargetDescription.swift @@ -24,6 +24,7 @@ public struct TargetDescription: Hashable, Encodable, Sendable { case binary case plugin case `macro` + case template } /// Represents a target's dependency on another entity. @@ -194,6 +195,47 @@ public struct TargetDescription: Hashable, Encodable, Sendable { case plugin(name: String, package: String?) } + public let templateInitializationOptions: TemplateInitializationOptions? + + public enum TemplateInitializationOptions: Hashable, Sendable { + case packageInit(templateType: TemplateType, executable: Dependency, templatePermissions: [TemplatePermission]?, description: String) + } + + public enum TemplateType: String, Hashable, Codable, Sendable { + /// A target that contains code for the Swift package's functionality. + case regular + /// A target that contains code for an executable's main module. + case executable + /// A target that contains tests for the Swift package's other targets. + case test + /// A target that adapts a library on the system to work with Swift + /// packages. + case `macro` + } + + public enum TemplateNetworkPermissionScope: Hashable, Codable, Sendable { + case none + case local(ports: [Int]) + case all(ports: [Int]) + case docker + case unixDomainSocket + + public init?(_ scopeString: String, ports: [Int]) { + switch scopeString { + case "none": self = .none + case "local": self = .local(ports: ports) + case "all": self = .all(ports: ports) + case "docker": self = .docker + case "unix-socket": self = .unixDomainSocket + default: return nil + } + } + } + + public enum TemplatePermission: Hashable, Codable, Sendable { + case allowNetworkConnections(scope: TemplateNetworkPermissionScope, reason: String) + } + public init( name: String, dependencies: [Dependency] = [], @@ -210,7 +252,8 @@ public struct TargetDescription: Hashable, Encodable, Sendable { pluginCapability: PluginCapability? = nil, settings: [TargetBuildSettingDescription.Setting] = [], checksum: String? = nil, - pluginUsages: [PluginUsage]? = nil + pluginUsages: [PluginUsage]? = nil, + templateInitializationOptions: TemplateInitializationOptions? = nil ) throws { let targetType = String(describing: type) switch type { @@ -245,6 +288,14 @@ public struct TargetDescription: Hashable, Encodable, Sendable { propertyName: "checksum", value: checksum ?? "" ) } + if templateInitializationOptions != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "templateInitializationOptions", + value: String(describing: templateInitializationOptions!) + ) + } + case .system: if !dependencies.isEmpty { throw Error.disallowedPropertyInTarget( targetName: name, @@ -300,6 +351,13 @@ public struct TargetDescription: Hashable, Encodable, Sendable { propertyName: "pluginUsages", value: String(describing: pluginUsages!) ) } + if templateInitializationOptions != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "templateInitializationOptions", + value: String(describing: templateInitializationOptions!) + ) + } case .binary: if path == nil && url == nil { throw Error.binaryTargetRequiresEitherPathOrURL(targetName: name) } if !dependencies.isEmpty { throw Error.disallowedPropertyInTarget( @@ -362,6 +420,13 @@ public struct TargetDescription: Hashable, Encodable, Sendable { propertyName: "pluginUsages", value: String(describing: pluginUsages!) ) } + if templateInitializationOptions != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "templateInitializationOptions", + value: String(describing: templateInitializationOptions!) + ) + } case .plugin: if pluginCapability == nil { throw Error.pluginTargetRequiresPluginCapability(targetName: name) } if url != nil { throw Error.disallowedPropertyInTarget( @@ -406,6 +471,13 @@ public struct TargetDescription: Hashable, Encodable, Sendable { propertyName: "pluginUsages", value: String(describing: pluginUsages!) ) } + if templateInitializationOptions != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "templateInitializationOptions", + value: String(describing: templateInitializationOptions!) + ) + } case .macro: if url != nil { throw Error.disallowedPropertyInTarget( targetName: name, @@ -443,6 +515,53 @@ public struct TargetDescription: Hashable, Encodable, Sendable { propertyName: "pluginCapability", value: String(describing: pluginCapability!) ) } + if templateInitializationOptions != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "templateInitializationOptions", + value: String(describing: templateInitializationOptions!) + ) + } + case .template: + // List forbidden properties for `.template` targets + if url != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "url", + value: url ?? "" + ) } + if pkgConfig != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "pkgConfig", + value: pkgConfig ?? "" + ) } + if providers != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "providers", + value: String(describing: providers!) + ) } + if pluginCapability != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "pluginCapability", + value: String(describing: pluginCapability!) + ) } + if checksum != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "checksum", + value: checksum ?? "" + ) } + if templateInitializationOptions == nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "templateInitializationOptions", + value: String(describing: templateInitializationOptions!) + ) + } + } self.name = name @@ -461,6 +580,7 @@ public struct TargetDescription: Hashable, Encodable, Sendable { self.settings = settings self.checksum = checksum self.pluginUsages = pluginUsages + self.templateInitializationOptions = templateInitializationOptions } } @@ -586,6 +706,44 @@ extension TargetDescription.PluginUsage: Codable { } } +extension TargetDescription.TemplateInitializationOptions: Codable { + private enum CodingKeys: String, CodingKey { + case packageInit + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .packageInit(a1, a2, a3, a4): + var unkeyedContainer = container.nestedUnkeyedContainer(forKey: .packageInit) + try unkeyedContainer.encode(a1) + try unkeyedContainer.encode(a2) + if let permissions = a3 { + try unkeyedContainer.encode(a3) + } else { + try unkeyedContainer.encodeNil() + } + try unkeyedContainer.encode(a4) + } + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + guard let key = values.allKeys.first(where: values.contains) else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Did not find a matching key")) + } + switch key { + case .packageInit: + var unkeyedValues = try values.nestedUnkeyedContainer(forKey: key) + let templateType = try unkeyedValues.decode(TargetDescription.TemplateType.self) + let executable = try unkeyedValues.decode(TargetDescription.Dependency.self) + let templatePermissions = try unkeyedValues.decodeIfPresent([TargetDescription.TemplatePermission].self) + let description = try unkeyedValues.decode(String.self) + self = .packageInit(templateType: templateType, executable: executable, templatePermissions: templatePermissions ?? nil, description: description) + } + } +} + import protocol Foundation.LocalizedError private enum Error: LocalizedError, Equatable { @@ -606,3 +764,5 @@ private enum Error: LocalizedError, Equatable { } } } + + diff --git a/Sources/PackageModel/ManifestSourceGeneration.swift b/Sources/PackageModel/ManifestSourceGeneration.swift index 98bd54b5313..b22cd65edf1 100644 --- a/Sources/PackageModel/ManifestSourceGeneration.swift +++ b/Sources/PackageModel/ManifestSourceGeneration.swift @@ -338,7 +338,12 @@ fileprivate extension SourceCodeFragment { if let checksum = target.checksum { params.append(SourceCodeFragment(key: "checksum", string: checksum)) } - + + if let templateInitializationOptions = target.templateInitializationOptions { + let node = SourceCodeFragment(from: templateInitializationOptions) + params.append(SourceCodeFragment(key: "templateInitializationOptions", subnode: node)) + } + switch target.type { case .regular: self.init(enum: "target", subnodes: params, multiline: true) @@ -354,6 +359,8 @@ fileprivate extension SourceCodeFragment { self.init(enum: "plugin", subnodes: params, multiline: true) case .macro: self.init(enum: "macro", subnodes: params, multiline: true) + case .template: + self.init(enum: "template", subnodes: params, multiline: true) } } @@ -534,6 +541,66 @@ fileprivate extension SourceCodeFragment { } } + init(from templateInitializationOptions: TargetDescription.TemplateInitializationOptions) { + switch templateInitializationOptions { + case .packageInit(let templateType, let executable, let templatePermissions, let description): + var params: [SourceCodeFragment] = [] + + switch templateType { + case .regular: + self.init(enum: "target", subnodes: params, multiline: true) + case .executable: + self.init(enum: "executableTarget", subnodes: params, multiline: true) + case .test: + self.init(enum: "testTarget", subnodes: params, multiline: true) + case .macro: + self.init(enum: "macro", subnodes: params, multiline: true) + } + // Template type as an enum + + // Executable fragment + params.append(SourceCodeFragment(key: "executable", subnode: .init(from: executable))) + + // Permissions, if any + if let permissions = templatePermissions { + let permissionFragments = permissions.map { SourceCodeFragment(from:$0) } + params.append(SourceCodeFragment(key: "permissions", subnodes: permissionFragments)) + } + + // Description + params.append(SourceCodeFragment(key: "description", string: description)) + + self.init(enum: "packageInit", subnodes: params) + } + } + + init(from permission: TargetDescription.TemplatePermission) { + switch permission { + case .allowNetworkConnections(let scope, let reason): + let scope = SourceCodeFragment(key: "scope", subnode: .init(from: scope)) + let reason = SourceCodeFragment(key: "reason", string: reason) + self.init(enum: "allowNetworkConnections", subnodes: [scope, reason]) + } + } + + init(from networkPermissionScope: TargetDescription.TemplateNetworkPermissionScope) { + switch networkPermissionScope { + case .none: + self.init(enum: "none") + case .local(let ports): + let ports = SourceCodeFragment(key: "ports", subnodes: ports.map { SourceCodeFragment("\($0)") }) + self.init(enum: "local", subnodes: [ports]) + case .all(let ports): + let ports = SourceCodeFragment(key: "ports", subnodes: ports.map { SourceCodeFragment("\($0)") }) + self.init(enum: "all", subnodes: [ports]) + case .docker: + self.init(enum: "docker") + case .unixDomainSocket: + self.init(enum: "unixDomainSocket") + } + } + + /// Instantiates a SourceCodeFragment to represent a single target build setting. init(from setting: TargetBuildSettingDescription.Setting) { var params: [SourceCodeFragment] = [] diff --git a/Sources/PackageModelSyntax/AddTarget.swift b/Sources/PackageModelSyntax/AddTarget.swift index b6a081a7d67..2382087a6ad 100644 --- a/Sources/PackageModelSyntax/AddTarget.swift +++ b/Sources/PackageModelSyntax/AddTarget.swift @@ -161,7 +161,7 @@ public enum AddTarget { ) let outerDirectory: String? = switch target.type { - case .binary, .plugin, .system: nil + case .binary, .plugin, .system, .template: nil case .executable, .regular, .macro: "Sources" case .test: "Tests" } @@ -275,7 +275,7 @@ public enum AddTarget { } let sourceFileText: SourceFileSyntax = switch target.type { - case .binary, .plugin, .system: + case .binary, .plugin, .system, .template: fatalError("should have exited above") case .macro: diff --git a/Sources/PackageModelSyntax/TargetDescription+Syntax.swift b/Sources/PackageModelSyntax/TargetDescription+Syntax.swift index eed59e5fcae..ea4b252d0ae 100644 --- a/Sources/PackageModelSyntax/TargetDescription+Syntax.swift +++ b/Sources/PackageModelSyntax/TargetDescription+Syntax.swift @@ -27,6 +27,7 @@ extension TargetDescription: ManifestSyntaxRepresentable { case .regular: "target" case .system: "systemLibrary" case .test: "testTarget" + case .template: "templateTarget" } } diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 213a36b1101..81506213928 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -8,30 +8,38 @@ import Basics import PackageModel import SPMBuildCore import TSCUtility -@_spi(SwiftPMInternal) import Foundation -import Commands +import Basics +import PackageModel +import SPMBuildCore +import TSCUtility +public final class InitTemplatePackage { -final class InitTemplatePackage { - - - var initMode: TemplateType - + + public enum TemplateType: String, CustomStringConvertible { + case local = "local" + case git = "git" + case registry = "registry" + + public var description: String { + return rawValue + } + } + var packageName: String? var templatePath: AbsolutePath let fileSystem: FileSystem - init(initMode: InitPackage.PackageType, packageName: String? = nil, templatePath: AbsolutePath, fileSystem: FileSystem) { + public init(initMode: InitTemplatePackage.TemplateType, packageName: String? = nil, templatePath: AbsolutePath, fileSystem: FileSystem) { self.initMode = initMode self.packageName = packageName self.templatePath = templatePath self.fileSystem = fileSystem - } @@ -54,7 +62,7 @@ final class InitTemplatePackage { //check if it contains a template folder } - +/* func initPackage(_ swiftCommandState: SwiftCommandState) throws { //Logic here for initializing initial package (should find better way to organize this but for now) @@ -90,8 +98,10 @@ final class InitTemplatePackage { } try initPackage.writePackageStructure() } + */ } + private enum TemplateError: Swift.Error { case invalidPath case manifestAlreadyExists diff --git a/Tests/FunctionalTests/PluginTests.swift b/Tests/FunctionalTests/PluginTests.swift index 89dd646b6a0..52ca343c9bd 100644 --- a/Tests/FunctionalTests/PluginTests.swift +++ b/Tests/FunctionalTests/PluginTests.swift @@ -246,7 +246,6 @@ final class PluginTests: XCTestCase { } func testCommandPluginInvocation() async throws { - try XCTSkipIf(true, "test is disabled because it isn't stable, see rdar://117870608") // Only run the test if the environment in which we're running actually supports Swift concurrency (which the plugin APIs require). try XCTSkipIf(!UserToolchain.default.supportsSwiftConcurrency(), "skipping because test environment doesn't support concurrency") @@ -260,7 +259,7 @@ final class PluginTests: XCTestCase { try localFileSystem.writeFileContents( manifestFile, string: """ - // swift-tools-version: 5.6 + // swift-tools-version: 6.1 import PackageDescription let package = Package( name: "MyPackage", @@ -268,6 +267,16 @@ final class PluginTests: XCTestCase { .package(name: "HelperPackage", path: "VendoredDependencies/HelperPackage") ], targets: [ + .template( + name: "GenerateStuff", + + templateInitializationOptions: .packageInit( + templateType: .executable, + executable: .target(name: "MyLibrary"), + description: "A template that generates a starter executable package" + ), + executable: .target(name: "MyLibrary"), + ), .target( name: "MyLibrary", dependencies: [ @@ -311,6 +320,16 @@ final class PluginTests: XCTestCase { public func Foo() { } """ ) + + let templateSourceFile = packageDir.appending(components: "Sources", "GenerateStuff", "generatestuff.swift") + try localFileSystem.createDirectory(templateSourceFile.parentDirectory, recursive: true) + try localFileSystem.writeFileContents( + templateSourceFile, + string: """ + public func Foo() { } + """ + ) + let printingPluginSourceFile = packageDir.appending(components: "Plugins", "PluginPrintingInfo", "plugin.swift") try localFileSystem.createDirectory(printingPluginSourceFile.parentDirectory, recursive: true) try localFileSystem.writeFileContents( From b50a4fc730aed4a23875be98a8dac5661a7f68c4 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:02:02 -0400 Subject: [PATCH 003/225] fixtures + cleanup --- .../ShowTemplates/app/Package.swift | 33 +++++ .../Plugins/GenerateStuffPlugin/plugin.swift | 29 +++++ .../Templates/GenerateStuff/Template.swift | 53 ++++++++ .../app/Templates/GenerateStuff/main.swift | 53 ++++++++ .../app/Templates/GenerateThings/main.swift | 0 .../PackageCommands/PackageTemplates.swift | 119 ------------------ .../PackageCommands/ShowTemplates.swift | 0 Sources/CoreCommands/SwiftCommandState.swift | 2 +- 8 files changed, 169 insertions(+), 120 deletions(-) create mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Package.swift create mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateStuffPlugin/plugin.swift create mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/Template.swift create mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift create mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift delete mode 100644 Sources/Commands/PackageCommands/PackageTemplates.swift create mode 100644 Sources/Commands/PackageCommands/ShowTemplates.swift diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift new file mode 100644 index 00000000000..bddabe3cf92 --- /dev/null +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version:6.1 +import PackageDescription + +let package = Package( + name: "Dealer", + products: [.template( + name: "GenerateStuff" + ),], + + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2") + + ], + targets: Target.template( + name: "GenerateStuff", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "SystemPackage", package: "swift-system") + + ], + templateInitializationOptions: .packageInit( + templateType: .executable, + templatePermissions: [ + .allowNetworkConnections(scope: .local(ports: [1200]), reason: "why not") + ], + description: "A template that generates a starter executable package" + ) + + ) + +) + diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateStuffPlugin/plugin.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateStuffPlugin/plugin.swift new file mode 100644 index 00000000000..8318ddd1ef0 --- /dev/null +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateStuffPlugin/plugin.swift @@ -0,0 +1,29 @@ +// +// plugin.swift +// app +// +// Created by John Bute on 2025-06-03. +// + +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable=≠≠ +@main + +struct TemplatePlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws { + let tool = try context.tool(named: "GenerateStuff") + let process = Process() + + process.executableURL = URL(filePath: tool.url.path()) + process.arguments = arguments.filter { $0 != "--" } + + try process.run() + process.waitUntilExit() + } +} diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/Template.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/Template.swift new file mode 100644 index 00000000000..24d9af341c4 --- /dev/null +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/Template.swift @@ -0,0 +1,53 @@ +import ArgumentParser +import Foundation +import SystemPackage + +extension FilePath { + static func / (left: FilePath, right: String) -> FilePath { + left.appending(right) + } +} + +extension String { + func write(toFile: FilePath) throws { + try self.write(toFile: toFile.string, atomically: true, encoding: .utf8) + } +} + +//basic structure of a template that uses string interpolation +@main +struct HelloTemplateTool: ParsableCommand { + + //swift argument parser needed to expose arguments to template generator + @Option(help: "The name of your app") + var name: String + + @Flag(help: "Include a README?") + var includeReadme: Bool = false + + //entrypoint of the template executable, that generates just a main.swift and a readme.md + func run() throws { + let fs = FileManager.default + + let rootDir = FilePath(fs.currentDirectoryPath) + + let mainFile = rootDir / "Soures" / name / "main.swift" + + try fs.createDirectory(atPath: mainFile.removingLastComponent().string, withIntermediateDirectories: true) + + try """ + // This is the entry point to your command-line app + print("Hello, \(name)!") + + """.write(toFile: mainFile) + + if includeReadme { + try """ + # \(name) + This is a new Swift app! + """.write(toFile: rootDir / "README.md") + } + + print("Project generated at \(rootDir)") + } +} diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift new file mode 100644 index 00000000000..24d9af341c4 --- /dev/null +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift @@ -0,0 +1,53 @@ +import ArgumentParser +import Foundation +import SystemPackage + +extension FilePath { + static func / (left: FilePath, right: String) -> FilePath { + left.appending(right) + } +} + +extension String { + func write(toFile: FilePath) throws { + try self.write(toFile: toFile.string, atomically: true, encoding: .utf8) + } +} + +//basic structure of a template that uses string interpolation +@main +struct HelloTemplateTool: ParsableCommand { + + //swift argument parser needed to expose arguments to template generator + @Option(help: "The name of your app") + var name: String + + @Flag(help: "Include a README?") + var includeReadme: Bool = false + + //entrypoint of the template executable, that generates just a main.swift and a readme.md + func run() throws { + let fs = FileManager.default + + let rootDir = FilePath(fs.currentDirectoryPath) + + let mainFile = rootDir / "Soures" / name / "main.swift" + + try fs.createDirectory(atPath: mainFile.removingLastComponent().string, withIntermediateDirectories: true) + + try """ + // This is the entry point to your command-line app + print("Hello, \(name)!") + + """.write(toFile: mainFile) + + if includeReadme { + try """ + # \(name) + This is a new Swift app! + """.write(toFile: rootDir / "README.md") + } + + print("Project generated at \(rootDir)") + } +} diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Commands/PackageCommands/PackageTemplates.swift b/Sources/Commands/PackageCommands/PackageTemplates.swift deleted file mode 100644 index 423031a27c8..00000000000 --- a/Sources/Commands/PackageCommands/PackageTemplates.swift +++ /dev/null @@ -1,119 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift open source project -// -// Copyright (c) 2014-2022 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 - -@_spi(SwiftPMInternal) -import CoreCommands - -import PackageModel -import Workspace -import SPMBuildCore -import TSCUtility - - - -extension SwiftPackageCommand { - struct PackageTemplates: SwiftCommand { - public static let configuration = CommandConfiguration( - commandName: "template", - abstract: "Initialize a new package based on a template." - ) - - @OptionGroup(visibility: .hidden) - var globalOptions: GlobalOptions - - @Option( - name: .customLong("template-type"), - help: ArgumentHelp("Package type:", discussion: """ - - registry : Use a template from a package registry. - - git : Use a template from a Git repository. - - local : Use a template from a local directory. - """)) - var templateType: InitTemplatePackage.TemplateType - - @Option(name: .customLong("package-name"), help: "Provide the name for the new package.") - var packageName: String? - - //package-path provides the consumer's package - - @Option( - name: .customLong("template-path"), - help: "Specify the package path to operate on (default current directory). This changes the working directory before any other operation.", - completion: .directory - ) - public var templateDirectory: AbsolutePath - - //options for type git - @Option(help: "The exact package version to depend on.") - var exact: Version? - - @Option(help: "The specific package revision to depend on.") - var revision: String? - - @Option(help: "The branch of the package to depend on.") - var branch: String? - - @Option(help: "The package version to depend on (up to the next major version).") - var from: Version? - - @Option(help: "The package version to depend on (up to the next minor version).") - var upToNextMinorFrom: Version? - - @Option(help: "Specify upper bound on the package version range (exclusive).") - var to: Version? - - - func run(_ swiftCommandState: SwiftCommandState) throws { - - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") - } - - switch self.templateType { - case .local: - try self.generateFromLocalTemplate( - packagePath: templateDirectory, - swiftCommandState: swiftCommandState - ) - case .git: - try self.generateFromGitTemplate() - case .registry: - try self.generateFromRegistryTemplate() - } - - } - - private func generateFromLocalTemplate( - packagePath: AbsolutePath, - swiftCommandState: SwiftCommandState - ) throws { - - let template = InitTemplatePackage(initMode: templateType, packageName: packageName, templatePath: templateDirectory, fileSystem: swiftCommandState.fileSystem) - } - - - private func generateFromGitTemplate( - ) throws { - - } - - private func generateFromRegistryTemplate( - ) throws { - - } - } -} - - -extension InitTemplatePackage.TemplateType: ExpressibleByArgument {} diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/CoreCommands/SwiftCommandState.swift b/Sources/CoreCommands/SwiftCommandState.swift index 386ba5381ca..1df93b439d4 100644 --- a/Sources/CoreCommands/SwiftCommandState.swift +++ b/Sources/CoreCommands/SwiftCommandState.swift @@ -203,7 +203,7 @@ public final class SwiftCommandState { public let options: GlobalOptions /// Path to the root package directory, nil if manifest is not found. - private let packageRoot: AbsolutePath? + private var packageRoot: AbsolutePath? /// Helper function to get package root or throw error if it is not found. public func getPackageRoot() throws -> AbsolutePath { From 90640a0ad167ed3c2f83880bd584dcef624b2735 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:02:45 -0400 Subject: [PATCH 004/225] deleting unnecessary fixtures --- .../app/Templates/GenerateStuff/main.swift | 53 ------------------- .../app/Templates/GenerateThings/main.swift | 8 +++ 2 files changed, 8 insertions(+), 53 deletions(-) delete mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift deleted file mode 100644 index 24d9af341c4..00000000000 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift +++ /dev/null @@ -1,53 +0,0 @@ -import ArgumentParser -import Foundation -import SystemPackage - -extension FilePath { - static func / (left: FilePath, right: String) -> FilePath { - left.appending(right) - } -} - -extension String { - func write(toFile: FilePath) throws { - try self.write(toFile: toFile.string, atomically: true, encoding: .utf8) - } -} - -//basic structure of a template that uses string interpolation -@main -struct HelloTemplateTool: ParsableCommand { - - //swift argument parser needed to expose arguments to template generator - @Option(help: "The name of your app") - var name: String - - @Flag(help: "Include a README?") - var includeReadme: Bool = false - - //entrypoint of the template executable, that generates just a main.swift and a readme.md - func run() throws { - let fs = FileManager.default - - let rootDir = FilePath(fs.currentDirectoryPath) - - let mainFile = rootDir / "Soures" / name / "main.swift" - - try fs.createDirectory(atPath: mainFile.removingLastComponent().string, withIntermediateDirectories: true) - - try """ - // This is the entry point to your command-line app - print("Hello, \(name)!") - - """.write(toFile: mainFile) - - if includeReadme { - try """ - # \(name) - This is a new Swift app! - """.write(toFile: rootDir / "README.md") - } - - print("Project generated at \(rootDir)") - } -} diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift index e69de29bb2d..ba2e9c4f78d 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift @@ -0,0 +1,8 @@ +// +// main.swift +// app +// +// Created by John Bute on 2025-06-03. +// + +print("hello, world!") From d3d659121efa0ec12e2ab7d756194739e9315345 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:03:24 -0400 Subject: [PATCH 005/225] added new dependencies to handle addDependency functionality of templates --- Package.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index a8426ef5761..d567e538aea 100644 --- a/Package.swift +++ b/Package.swift @@ -530,7 +530,8 @@ let package = Package( "SourceControl", "SPMBuildCore", .product(name: "OrderedCollections", package: "swift-collections"), - ], + "PackageModelSyntax", + ] + swiftSyntaxDependencies(["SwiftIDEUtils", "SwiftParser"]), exclude: ["CMakeLists.txt"], swiftSettings: commonExperimentalFeatures + [ .unsafeFlags(["-static"]), From 7ecbc34c26c05c2ed01253b5ce5bf87a0cb09fef Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:06:47 -0400 Subject: [PATCH 006/225] build plan for building products and targets of type templates, will need to revisit in future --- .../Build/BuildDescription/ProductBuildDescription.swift | 8 +++++--- .../BuildDescription/SwiftModuleBuildDescription.swift | 4 ++-- .../BuildManifest/LLBuildManifestBuilder+Clang.swift | 2 +- .../BuildManifest/LLBuildManifestBuilder+Product.swift | 7 ++++++- .../BuildManifest/LLBuildManifestBuilder+Swift.swift | 6 +++--- Sources/Build/BuildOperation.swift | 2 +- Sources/Build/BuildPlan/BuildPlan+Product.swift | 4 ++-- Sources/Build/BuildPlan/BuildPlan.swift | 5 +++-- 8 files changed, 23 insertions(+), 15 deletions(-) diff --git a/Sources/Build/BuildDescription/ProductBuildDescription.swift b/Sources/Build/BuildDescription/ProductBuildDescription.swift index b07da282921..b9c9e78249e 100644 --- a/Sources/Build/BuildDescription/ProductBuildDescription.swift +++ b/Sources/Build/BuildDescription/ProductBuildDescription.swift @@ -222,7 +222,7 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription args += ["-Xlinker", "-install_name", "-Xlinker", relativePath] } args += self.deadStripArguments - case .executable, .snippet: + case .executable, .snippet, .template: //john-to-revisit // Link the Swift stdlib statically, if requested. // TODO: unify this logic with SwiftTargetBuildDescription.stdlibArguments if self.buildParameters.linkingParameters.shouldLinkStaticSwiftStdlib { @@ -245,7 +245,7 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription // Support for linking tests against executables is conditional on the tools // version of the package that defines the executable product. let executableTarget = try product.executableModule - if let target = executableTarget.underlying as? SwiftModule, + if let target = executableTarget.underlying as? SwiftModule, self.toolsVersion >= .v5_5, self.buildParameters.driverParameters.canRenameEntrypointFunctionName, target.supportsTestableExecutablesFeature @@ -256,6 +256,8 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription } case .plugin: throw InternalError("unexpectedly asked to generate linker arguments for a plugin product") + /*case .template: //john-to-do revist + throw InternalError("unexpectedly asked to generate linker arguments for a template product")*/ } if let resourcesPath = self.buildParameters.toolchain.swiftResourcesPath(isStatic: isLinkingStaticStdlib) { @@ -312,7 +314,7 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription switch self.product.type { case .library(let type): useStdlibRpath = type == .dynamic - case .test, .executable, .snippet, .macro: + case .test, .executable, .snippet, .macro, .template: //john-to-revisit useStdlibRpath = true case .plugin: throw InternalError("unexpectedly asked to generate linker arguments for a plugin product") diff --git a/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift b/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift index 69e482aec34..e5c55abcca9 100644 --- a/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift +++ b/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift @@ -146,7 +146,7 @@ public final class SwiftModuleBuildDescription { // If we're an executable and we're not allowing test targets to link against us, we hide the module. let triple = buildParameters.triple let allowLinkingAgainstExecutables = [.coff, .macho, .elf].contains(triple.objectFormat) && self.toolsVersion >= .v5_5 - let dirPath = (target.type == .executable && !allowLinkingAgainstExecutables) ? self.tempsPath : self.modulesPath + let dirPath = ((target.type == .executable || target.type == .template) && !allowLinkingAgainstExecutables) ? self.tempsPath : self.modulesPath //john-to-revisit return dirPath.appending(component: "\(self.target.c99name).swiftmodule") } @@ -201,7 +201,7 @@ public final class SwiftModuleBuildDescription { switch self.target.type { case .library, .test: return true - case .executable, .snippet, .macro: + case .executable, .snippet, .macro, .template: //john-to-revisit // This deactivates heuristics in the Swift compiler that treats single-file modules and source files // named "main.swift" specially w.r.t. whether they can have an entry point. // diff --git a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift index 34476408fff..845fad6cebd 100644 --- a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift +++ b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift @@ -46,7 +46,7 @@ extension LLBuildManifestBuilder { case .product(let product, let productDescription): switch product.type { - case .executable, .snippet, .library(.dynamic), .macro: + case .executable, .snippet, .library(.dynamic), .macro, .template: //john-to-revisit guard let productDescription else { throw InternalError("No build description for product: \(product)") } diff --git a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift index 33eb6f4558f..61ff45102c9 100644 --- a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift +++ b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift @@ -69,6 +69,11 @@ extension LLBuildManifestBuilder { buildProduct.buildParameters.debuggingParameters.shouldEnableDebuggingEntitlement { shouldCodeSign = true linkedBinaryNode = try .file(buildProduct.binaryPath, isMutated: true) + } else if case .template = buildProduct.product.type, //john-to-revisit + buildProduct.buildParameters.triple.isMacOSX, + buildProduct.buildParameters.debuggingParameters.shouldEnableDebuggingEntitlement { + shouldCodeSign = true + linkedBinaryNode = try .file(buildProduct.binaryPath, isMutated: true) } else { shouldCodeSign = false linkedBinaryNode = try .file(buildProduct.binaryPath) @@ -199,7 +204,7 @@ extension ResolvedProduct { return staticLibraryName(for: self.name, buildParameters: buildParameters) case .library(.automatic): throw InternalError("automatic library not supported") - case .executable, .snippet: + case .executable, .snippet, .template: //john-to-revisit return executableName(for: self.name, buildParameters: buildParameters) case .macro: guard let macroModule = self.modules.first else { diff --git a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift index b2679e95b5a..31f5ff1ae4e 100644 --- a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift +++ b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift @@ -442,12 +442,12 @@ extension LLBuildManifestBuilder { } // Depend on the binary for executable targets. - if module.type == .executable && prepareForIndexing == .off { + if (module.type == .executable || module.type == .template) && prepareForIndexing == .off {//john-to-revisit // FIXME: Optimize. Build plan could build a mapping between executable modules // and their products to speed up search here, which is inefficient if the plan // contains a lot of products. if let productDescription = try plan.productMap.values.first(where: { - try $0.product.type == .executable && + try ($0.product.type == .executable || $0.product.type == .template) && //john-to-revisit $0.product.executableModule.id == module.id && $0.destination == description.destination }) { @@ -481,7 +481,7 @@ extension LLBuildManifestBuilder { case .product(let product, let productDescription): switch product.type { - case .executable, .snippet, .library(.dynamic), .macro: + case .executable, .snippet, .library(.dynamic), .macro, .template: //john-to-revisit guard let productDescription else { throw InternalError("No description for product: \(product)") } diff --git a/Sources/Build/BuildOperation.swift b/Sources/Build/BuildOperation.swift index 37761bd2e38..78e8652130b 100644 --- a/Sources/Build/BuildOperation.swift +++ b/Sources/Build/BuildOperation.swift @@ -844,7 +844,7 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS // Look for a target with the same module name as the one that's being imported. if let importedTarget = self._buildPlan?.targets.first(where: { $0.module.c99name == importedModule }) { // For the moment we just check for executables that other targets try to import. - if importedTarget.module.type == .executable { + if importedTarget.module.type == .executable || importedTarget.module.type == .template { //john-to-revisit return "module '\(importedModule)' is the main module of an executable, and cannot be imported by tests and other targets" } if importedTarget.module.type == .macro { diff --git a/Sources/Build/BuildPlan/BuildPlan+Product.swift b/Sources/Build/BuildPlan/BuildPlan+Product.swift index 921d508fcd4..cd7e9e8e36b 100644 --- a/Sources/Build/BuildPlan/BuildPlan+Product.swift +++ b/Sources/Build/BuildPlan/BuildPlan+Product.swift @@ -168,7 +168,7 @@ extension BuildPlan { product: $0.product, context: $0.destination ) } - case .test, .executable, .snippet, .macro: + case .test, .executable, .snippet, .macro, .template: //john-to-revisit return [] } } @@ -243,7 +243,7 @@ extension BuildPlan { // In tool version .v5_5 or greater, we also include executable modules implemented in Swift in // any test products... this is to allow testing of executables. Note that they are also still // built as separate products that the test can invoke as subprocesses. - case .executable, .snippet, .macro: + case .executable, .snippet, .macro, .template: //john-to-revisit if product.modules.contains(id: module.id) { guard let description else { throw InternalError("Could not find a description for module: \(module)") diff --git a/Sources/Build/BuildPlan/BuildPlan.swift b/Sources/Build/BuildPlan/BuildPlan.swift index ccf06d1167d..5e2b0eb878d 100644 --- a/Sources/Build/BuildPlan/BuildPlan.swift +++ b/Sources/Build/BuildPlan/BuildPlan.swift @@ -228,6 +228,7 @@ public class BuildPlan: SPMBuildCore.BuildPlan { /// as targets, but they are not directly included in the build graph. public let pluginDescriptions: [PluginBuildDescription] + /// The build targets. public var targets: AnySequence { AnySequence(self.targetMap.values) @@ -440,7 +441,7 @@ public class BuildPlan: SPMBuildCore.BuildPlan { try module.dependencies.compactMap { switch $0 { case .module(let moduleDependency, _): - if moduleDependency.type == .executable { + if moduleDependency.type == .executable || moduleDependency.type == .template { return graph.product(for: moduleDependency.name) } return nil @@ -1409,6 +1410,6 @@ extension ResolvedProduct { // We shouldn't create product descriptions for automatic libraries, plugins or products which consist solely of // binary targets, because they don't produce any output. fileprivate var shouldCreateProductDescription: Bool { - !self.isAutomaticLibrary && !self.isBinaryOnly && !self.isPlugin + !self.isAutomaticLibrary && !self.isBinaryOnly && !self.isPlugin //john-to-revisit to see if include templates } } From dd53579d0718f7df39954dc6ff3268b98f93a786 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:10:41 -0400 Subject: [PATCH 007/225] fulfilling cases for addproduct, will need to revisit to make sure adding product of type template works --- Sources/Commands/PackageCommands/AddProduct.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Commands/PackageCommands/AddProduct.swift b/Sources/Commands/PackageCommands/AddProduct.swift index 51f8d664de3..489bb9e9ce8 100644 --- a/Sources/Commands/PackageCommands/AddProduct.swift +++ b/Sources/Commands/PackageCommands/AddProduct.swift @@ -33,6 +33,7 @@ extension SwiftPackageCommand { case staticLibrary = "static-library" case dynamicLibrary = "dynamic-library" case plugin + case template } package static let configuration = CommandConfiguration( @@ -86,6 +87,7 @@ extension SwiftPackageCommand { case .dynamicLibrary: .library(.dynamic) case .staticLibrary: .library(.static) case .plugin: .plugin + case .template: .template } let product = try ProductDescription( From db71c64260c4d7a9187f0da4e7bcddeec74cc18b Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:11:18 -0400 Subject: [PATCH 008/225] fulfilling cases for addtarget, will need to revisit to make sure adding target of type template works --- Sources/Commands/PackageCommands/AddTarget.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Commands/PackageCommands/AddTarget.swift b/Sources/Commands/PackageCommands/AddTarget.swift index 413f19363fc..1577ee35b12 100644 --- a/Sources/Commands/PackageCommands/AddTarget.swift +++ b/Sources/Commands/PackageCommands/AddTarget.swift @@ -33,6 +33,7 @@ extension SwiftPackageCommand { case executable case test case macro + case template } package static let configuration = CommandConfiguration( @@ -99,12 +100,13 @@ extension SwiftPackageCommand { verbose: !globalOptions.logging.quiet ) - // Map the target type. + // Map the target type. john-to-revisit let type: TargetDescription.TargetKind = switch self.type { case .library: .regular case .executable: .executable case .test: .test case .macro: .macro + case .template: .template } // Map dependencies From e8aa1458f4194c73d87bbe88355991e35f7881af Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:15:53 -0400 Subject: [PATCH 009/225] show templates in a local package --- .../PackageCommands/ShowTemplates.swift | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index e69de29bb2d..e72298b62ac 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -0,0 +1,90 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation +import PackageModel +import PackageGraph +import Workspace + +struct ShowTemplates: AsyncSwiftCommand { + static let configuration = CommandConfiguration( + abstract: "List the available executables from this package.") + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + @Option(help: "Set the output format.") + var format: ShowTemplatesMode = .flatlist + + func run(_ swiftCommandState: SwiftCommandState) async throws { + let packageGraph = try await swiftCommandState.loadPackageGraph() + let rootPackages = packageGraph.rootPackages.map { $0.identity } + + let templates = packageGraph.allModules.filter({ + $0.type == .template || $0.type == .snippet + }).map { module -> Template in + if !rootPackages.contains(module.packageIdentity) { + return Template(package: module.packageIdentity.description, name: module.name) + } else { + return Template(package: Optional.none, name: module.name) + } + } + + switch self.format { + case .flatlist: + for template in templates.sorted(by: {$0.name < $1.name }) { + if let package = template.package { + print("\(template.name) (\(package))") + } else { + print(template.name) + } + } + + case .json: + let encoder = JSONEncoder() + let data = try encoder.encode(templates) + if let output = String(data: data, encoding: .utf8) { + print(output) + } + } + } + + struct Template: Codable { + var package: String? + var name: String + } + + enum ShowTemplatesMode: String, RawRepresentable, CustomStringConvertible, ExpressibleByArgument, CaseIterable { + case flatlist, json + + public init?(rawValue: String) { + switch rawValue.lowercased() { + case "flatlist": + self = .flatlist + case "json": + self = .json + default: + return nil + } + } + + public var description: String { + switch self { + case .flatlist: return "flatlist" + case .json: return "json" + } + } + } +} From 026654b39eb7081aa50eaafa35d5c3a3bc9cb8c9 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:16:37 -0400 Subject: [PATCH 010/225] fullfilling switch case for snippets --- Sources/Commands/Snippets/Cards/TopCard.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Commands/Snippets/Cards/TopCard.swift b/Sources/Commands/Snippets/Cards/TopCard.swift index 614a20b6080..00363fbdb92 100644 --- a/Sources/Commands/Snippets/Cards/TopCard.swift +++ b/Sources/Commands/Snippets/Cards/TopCard.swift @@ -182,6 +182,8 @@ fileprivate extension Module.Kind { return "snippets" case .macro: return "macros" + case .template: + return "templates" } } } From 082d38aa8a4478ba807c16746956f1ee69109cb8 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:30:33 -0400 Subject: [PATCH 011/225] registering the show-templates command and adding more to init --- Sources/Commands/PackageCommands/Init.swift | 238 ++++++++++++++++-- .../PackageCommands/SwiftPackageCommand.swift | 1 + 2 files changed, 214 insertions(+), 25 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index c5d1336a93d..6de836f5794 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -19,19 +19,27 @@ import CoreCommands import PackageModel import Workspace import SPMBuildCore +import TSCUtility + +import Foundation +import PackageGraph +import SPMBuildCore +import XCBuildSupport +import TSCBasic + extension SwiftPackageCommand { - struct Init: SwiftCommand { + struct Init: AsyncSwiftCommand { public static let configuration = CommandConfiguration( abstract: "Initialize a new package.", - subcommands: [ - PackageTemplates.self - ] ) @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions - + + @OptionGroup(visibility: .hidden) + var buildOptions: BuildCommandOptions + @Option( name: .customLong("type"), help: ArgumentHelp("Package type:", discussion: """ @@ -57,7 +65,47 @@ extension SwiftPackageCommand { // This command should support creating the supplied --package-path if it isn't created. var createPackagePath = true - func run(_ swiftCommandState: SwiftCommandState) throws { + @Option(name: .customLong("template"), help: "Name of a template to initialize the package.") + var template: String = "" + var useTemplates: Bool { !template.isEmpty } + + @Option(name: .customLong("template-type"), help: "Template type: registry, git, local.") + var templateType: InitTemplatePackage.TemplateType? + + @Option(name: .customLong("template-path"), help: "Path to the local template.", completion: .directory) + var templateDirectory: Basics.AbsolutePath? + + // Git-specific options + @Option(help: "The exact package version to depend on.") + var exact: Version? + + @Option(help: "The specific package revision to depend on.") + var revision: String? + + @Option(help: "The branch of the package to depend on.") + var branch: String? + + @Option(help: "The package version to depend on (up to the next major version).") + var from: Version? + + @Option(help: "The package version to depend on (up to the next minor version).") + var upToNextMinorFrom: Version? + + @Option(help: "Specify upper bound on the package version range (exclusive).") + var to: Version? + + //swift package init --template woof --template-type local --template-path path here + + //first check the path and see if the template woof is actually there + //if yes, build and get the templateInitializationOptions from it + // read templateInitializationOptions and parse permissions + type of package to initialize + // once read, initialize barebones package with what is needed, and add dependency to local template product + // swift build, then call --experimental-dump-help on the product + // prompt user + // run the executable with the command line stuff + + //first, + func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") } @@ -67,30 +115,170 @@ extension SwiftPackageCommand { // Testing is on by default, with XCTest only enabled explicitly. // For macros this is reversed, since we don't support testing // macros with Swift Testing yet. - var supportedTestingLibraries = Set() - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.xctest) - } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.swiftTesting) + if useTemplates { + guard let type = templateType else { + throw InternalError("Template path must be specified when using the local template type.") + } + + switch type { + case .local: + + guard let templatePath = templateDirectory else { + throw InternalError("Template path must be specified when using the local template type.") + } + + /// Get the package initialization type based on templateInitializationOptions and check for if the template called is valid. + let templateInitType = try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { workspace, root in + return try await checkConditions(swiftCommandState) + } + + var supportedTemplateTestingLibraries = Set() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (templateInitType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTemplateTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (templateInitType != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTemplateTestingLibraries.insert(.swiftTesting) + } + + let initTemplatePackage = try InitTemplatePackage( + name: packageName, + templateName: template, + initMode: type, + templatePath: templatePath, + fileSystem: swiftCommandState.fileSystem, + packageType: templateInitType, + supportedTestingLibraries: supportedTemplateTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, + + ) + try initTemplatePackage.setupTemplateManifest() + + let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { workspace, root in + + try await swiftCommandState.createBuildSystem( + explicitProduct: buildOptions.product, + traitConfiguration: .init(traitOptions: buildOptions.traits), + shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: swiftCommandState.toolsBuildParameters, + // command result output goes on stdout + // ie "swift build" should output to stdout + outputStream: TSCBasic.stdoutStream + ) + + } + + guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { + throw ExitCode.failure + } + + let _ = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { workspace, root in + do { + try await buildSystem.build(subset: subset) + } catch _ as Diagnostics { + throw ExitCode.failure + } + } + + + case .git: + // Implement or call your Git-based template handler + print("TODO: Handle Git template") + case .registry: + // Implement or call your Registry-based template handler + print("TODO: Handle Registry template") + } + } else { + + var supportedTestingLibraries = Set() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.swiftTesting) + } + + + let initPackage = try InitPackage( + name: packageName, + packageType: initMode, + supportedTestingLibraries: supportedTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, + fileSystem: swiftCommandState.fileSystem + ) + + initPackage.progressReporter = { message in + print(message) + } + + try initPackage.writePackageStructure() } + } + + // first save current activeWorkspace + //second switch activeWorkspace to the template Path + //third revert after conditions have been checked, (we will also get stuff needed for dpeende + private func checkConditions(_ swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType{ + + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() - let initPackage = try InitPackage( - name: packageName, - packageType: initMode, - supportedTestingLibraries: supportedTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, - fileSystem: swiftCommandState.fileSystem + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope ) - initPackage.progressReporter = { message in - print(message) + guard let rootManifest = rootManifests.values.first else { + throw InternalError("invalid manifests at \(root.packages)") + } + + let products = rootManifest.products + let targets = rootManifest.targets + + for product in products { + for targetName in product.targets { + if let target = targets.first(where: { _ in template == targetName }) { + if target.type == .template { + if let options = target.templateInitializationOptions { + + if case let .packageInit(templateType, _, _) = options { + return try .init(from: templateType) + } + } + } + } + } } - try initPackage.writePackageStructure() + throw InternalError("Could not find template \(template)") } } } -extension InitPackage.PackageType: ExpressibleByArgument {} +extension InitPackage.PackageType: ExpressibleByArgument { + init(from templateType: TargetDescription.TemplateType) throws { + switch templateType { + case .executable: + self = .executable + case .library: + self = .library + case .tool: + self = .tool + case .macro: + self = .macro + case .buildToolPlugin: + self = .buildToolPlugin + case .commandPlugin: + self = .commandPlugin + case .empty: + self = .empty + } + } + +} + +extension InitTemplatePackage.TemplateType: ExpressibleByArgument {} diff --git a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift index 06bfbf67111..a63586f1f99 100644 --- a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift +++ b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift @@ -65,6 +65,7 @@ public struct SwiftPackageCommand: AsyncParsableCommand { ShowDependencies.self, ShowExecutables.self, + ShowTemplates.self, ToolsVersionCommand.self, ComputeChecksum.self, ArchiveSource.self, From 4d9ca976de681b9ef69d3f00e90252dbc8cba698 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:30:57 -0400 Subject: [PATCH 012/225] removing testing library options, to revisit and discuss --- Sources/Commands/SwiftBuildCommand.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Commands/SwiftBuildCommand.swift b/Sources/Commands/SwiftBuildCommand.swift index 8bc8e1aaccb..d6a6c7baba0 100644 --- a/Sources/Commands/SwiftBuildCommand.swift +++ b/Sources/Commands/SwiftBuildCommand.swift @@ -103,6 +103,8 @@ struct BuildCommandOptions: ParsableArguments { @Option(help: "Build the specified product.") var product: String? + //John-to-revisit + /* /// Testing library options. /// /// These options are no longer used but are needed by older versions of the @@ -110,6 +112,8 @@ struct BuildCommandOptions: ParsableArguments { @OptionGroup(visibility: .private) var testLibraryOptions: TestLibraryOptions + */ + /// Specifies the traits to build. @OptionGroup(visibility: .hidden) package var traits: TraitOptions From 374f842a4fdfa6dc26e2ac0b1cfcc298b83135bb Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:33:55 -0400 Subject: [PATCH 013/225] pairing templates with executables for now wehen it comes to creating and retuning the build result of the plugin delegate collections, will need to revisit in a future --- Sources/Commands/Utilities/PluginDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Commands/Utilities/PluginDelegate.swift b/Sources/Commands/Utilities/PluginDelegate.swift index 971222e897b..87b0c0f7123 100644 --- a/Sources/Commands/Utilities/PluginDelegate.swift +++ b/Sources/Commands/Utilities/PluginDelegate.swift @@ -197,7 +197,7 @@ final class PluginDelegate: PluginInvocationDelegate { path: $0.binaryPath.pathString, kind: (kind == .dynamic) ? .dynamicLibrary : .staticLibrary ) - case .executable: + case .executable, .template: //john-to-revisit return try .init(path: $0.binaryPath.pathString, kind: .executable) default: return nil From 0326d505860cc8979de223ab77b362689a46dd69 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:34:35 -0400 Subject: [PATCH 014/225] added context switcher for swiftcommandstate --- Sources/CoreCommands/SwiftCommandState.swift | 66 +++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/Sources/CoreCommands/SwiftCommandState.swift b/Sources/CoreCommands/SwiftCommandState.swift index 1df93b439d4..c1ee4fb5cfe 100644 --- a/Sources/CoreCommands/SwiftCommandState.swift +++ b/Sources/CoreCommands/SwiftCommandState.swift @@ -228,7 +228,7 @@ public final class SwiftCommandState { } /// Scratch space (.build) directory. - public let scratchDirectory: AbsolutePath + public var scratchDirectory: AbsolutePath /// Path to the shared security directory public let sharedSecurityDirectory: AbsolutePath @@ -1296,3 +1296,67 @@ extension Basics.Diagnostic { } } +extension SwiftCommandState { + + public func withTemporaryWorkspace( + switchingTo packagePath: AbsolutePath, + createPackagePath: Bool = true, + perform: @escaping (Workspace, PackageGraphRootInput) async throws -> R + ) async throws -> R { + let originalWorkspace = self._workspace + let originalDelegate = self._workspaceDelegate + let originalWorkingDirectory = self.fileSystem.currentWorkingDirectory + let originalLock = self.workspaceLock + let originalLockState = self.workspaceLockState + + // Switch to temp directory + try Self.chdirIfNeeded(packageDirectory: packagePath, createPackagePath: createPackagePath) + + // Reset for new context + self._workspace = nil + self._workspaceDelegate = nil + self.workspaceLock = nil + self.workspaceLockState = .needsLocking + + defer { + if self.workspaceLockState == .locked { + self.releaseLockIfNeeded() + } + + // Restore lock state + self.workspaceLock = originalLock + self.workspaceLockState = originalLockState + + // Restore other context + if let cwd = originalWorkingDirectory { + try? Self.chdirIfNeeded(packageDirectory: cwd, createPackagePath: false) + do { + self.scratchDirectory = try BuildSystemUtilities.getEnvBuildPath(workingDir: cwd) + ?? (packageRoot ?? cwd).appending(component: ".build") + } catch { + self.scratchDirectory = (packageRoot ?? cwd).appending(component: ".build") + } + + } + + self._workspace = originalWorkspace + self._workspaceDelegate = originalDelegate + } + + // Set up new context + self.packageRoot = findPackageRoot(fileSystem: self.fileSystem) + + + if let cwd = self.fileSystem.currentWorkingDirectory { + self.scratchDirectory = try BuildSystemUtilities.getEnvBuildPath(workingDir: cwd) ?? (packageRoot ?? cwd).appending(".build") + + } + + + let tempWorkspace = try self.getActiveWorkspace() + let tempRoot = try self.getWorkspaceRoot() + + return try await perform(tempWorkspace, tempRoot) + } +} + From 76f4e3cc3e75594f3ac1e98a9ad5662a4188e7b0 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:42:57 -0400 Subject: [PATCH 015/225] make templates recognizable by package.swift --- .../PackageDescriptionSerialization.swift | 15 ++- ...geDescriptionSerializationConversion.swift | 23 +++- Sources/PackageDescription/Product.swift | 27 +++- Sources/PackageDescription/Target.swift | 115 ++++++++++++++---- .../Manifest/TargetDescription.swift | 35 +++--- Sources/PackageModel/Product.swift | 16 ++- 6 files changed, 170 insertions(+), 61 deletions(-) diff --git a/Sources/PackageDescription/PackageDescriptionSerialization.swift b/Sources/PackageDescription/PackageDescriptionSerialization.swift index fc18ebaef8b..43da07afe3b 100644 --- a/Sources/PackageDescription/PackageDescriptionSerialization.swift +++ b/Sources/PackageDescription/PackageDescriptionSerialization.swift @@ -212,19 +212,17 @@ enum Serialization { } enum TemplateInitializationOptions: Codable { - case packageInit(templateType: TemplateType, executable: TargetDependency, templatePermissions: [TemplatePermissions]?, description: String) + case packageInit(templateType: TemplateType, templatePermissions: [TemplatePermissions]?, description: String) } enum TemplateType: Codable { - /// A target that contains code for the Swift package's functionality. - case regular - /// A target that contains code for an executable's main module. + case library case executable - /// A target that contains tests for the Swift package's other targets. - case test - /// A target that adapts a library on the system to work with Swift - /// packages. + case tool + case buildToolPlugin + case commandPlugin case `macro` + case empty } enum TemplateNetworkPermissionScope: Codable { @@ -288,6 +286,7 @@ enum Serialization { case executable case library(type: LibraryType) case plugin + case template } let name: String diff --git a/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift b/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift index b8d9219c98a..f59e8d380ba 100644 --- a/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift +++ b/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift @@ -300,18 +300,21 @@ extension Serialization.TemplateInitializationOptions { init(_ usage: PackageDescription.Target.TemplateInitializationOptions) { switch usage { - case .packageInit(let templateType, let executable, let templatePermissions, let description): - self = .packageInit(templateType: .init(templateType), executable: .init(executable), templatePermissions: templatePermissions?.map { .init($0) }, description: description) + case .packageInit(let templateType, let templatePermissions, let description): + self = .packageInit(templateType: .init(templateType), templatePermissions: templatePermissions?.map { .init($0) }, description: description) } } } extension Serialization.TemplateType { init(_ type: PackageDescription.Target.TemplateType) { switch type { - case .regular: self = .regular case .executable: self = .executable case .macro: self = .macro - case .test: self = .test + case .library: self = .library + case .tool: self = .tool + case .buildToolPlugin: self = .buildToolPlugin + case .commandPlugin: self = .commandPlugin + case .empty: self = .empty } } } @@ -398,6 +401,8 @@ extension Serialization.Product { self.init(library) } else if let plugin = product as? PackageDescription.Product.Plugin { self.init(plugin) + } else if let template = product as? PackageDescription.Product.Template { + self.init(template) } else { fatalError("should not be reached") } @@ -430,6 +435,16 @@ extension Serialization.Product { self.settings = [] #endif } + + init(_ template: PackageDescription.Product.Template) { + self.name = template.name + self.targets = template.targets + self.productType = .template + #if ENABLE_APPLE_PRODUCT_TYPES + self.settings = [] + #endif + } + } extension Serialization.Trait { diff --git a/Sources/PackageDescription/Product.swift b/Sources/PackageDescription/Product.swift index e3b6a7352c4..1e21fce6396 100644 --- a/Sources/PackageDescription/Product.swift +++ b/Sources/PackageDescription/Product.swift @@ -120,6 +120,15 @@ public class Product { } } + public final class Template: Product, @unchecked Sendable { + public let targets: [String] + + init(name: String, targets: [String]) { + self.targets = [name] + super.init(name: name) + } + } + /// Creates a library product to allow clients that declare a dependency on /// this package to use the package's functionality. /// @@ -169,11 +178,12 @@ public class Product { return Executable(name: name, targets: targets, settings: settings) } - /// Defines a product that vends a package plugin target for use by clients of the package. - /// - /// It is not necessary to define a product for a plugin that + //john-to-revisit documentation + /// Defines a template that vends a template plugin target and a template executable target for use by clients of the package. + /// + /// It is not necessary to define a product for a template that /// is only used within the same package where you define it. All the targets - /// listed must be plugin targets in the same package as the product. Swift Package Manager + /// listed must be template targets in the same package as the product. Swift Package Manager /// will apply them to any client targets of the product in the order /// they are listed. /// - Parameters: @@ -187,6 +197,15 @@ public class Product { ) -> Product { return Plugin(name: name, targets: targets) } + + @available(_PackageDescription, introduced: 6.0) + public static func template( + name: String, + ) -> Product { + let templatePluginName = "\(name)Plugin" + let executableTemplateName = name + return Product.plugin(name: templatePluginName, targets: [templatePluginName]) + } } diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index 668995142c0..60c2232c19e 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -235,20 +235,18 @@ public final class Target { public var templateInitializationOptions: TemplateInitializationOptions? public enum TemplateType: String { - /// A target that contains code for the Swift package's functionality. - case regular - /// A target that contains code for an executable's main module. case executable - /// A target that contains tests for the Swift package's other targets. - case test - /// A target that adapts a library on the system to work with Swift - /// packages. case `macro` + case library + case tool + case buildToolPlugin + case commandPlugin + case empty } @available(_PackageDescription, introduced: 5.9) public enum TemplateInitializationOptions { - case packageInit(templateType: TemplateType, executable: Dependency, templatePermissions: [TemplatePermissions]? = nil, description: String) + case packageInit(templateType: TemplateType, templatePermissions: [TemplatePermissions]? = nil, description: String) } /// Construct a target. @@ -294,6 +292,7 @@ public final class Target { self.linkerSettings = linkerSettings self.checksum = checksum self.plugins = plugins + self.templateInitializationOptions = templateInitializationOptions switch type { case .regular, .executable, .test: @@ -617,7 +616,8 @@ public final class Target { cxxSettings: [CXXSetting]? = nil, swiftSettings: [SwiftSetting]? = nil, linkerSettings: [LinkerSetting]? = nil, - plugins: [PluginUsage]? = nil + plugins: [PluginUsage]? = nil, + templateInitializationOptions: TemplateInitializationOptions? = nil ) -> Target { return Target( name: name, @@ -633,7 +633,8 @@ public final class Target { cxxSettings: cxxSettings, swiftSettings: swiftSettings, linkerSettings: linkerSettings, - plugins: plugins + plugins: plugins, + templateInitializationOptions: templateInitializationOptions ) } @@ -1271,24 +1272,90 @@ public final class Target { pluginCapability: capability) } + //john-to-revisit documentation @available(_PackageDescription, introduced: 6.0) public static func template( name: String, - templateInitializationOptions: TemplateInitializationOptions, + dependencies: [Dependency] = [], + path: String? = nil, exclude: [String] = [], - executable: Dependency - ) -> Target { - return Target( - name: name, - dependencies: [], - path: nil, - exclude: exclude, - sources: nil, - publicHeadersPath: nil, - type: .template, - packageAccess: false, - templateInitializationOptions: templateInitializationOptions - ) + sources: [String]? = nil, + resources: [Resource]? = nil, + publicHeadersPath: String? = nil, + packageAccess: Bool = true, + cSettings: [CSetting]? = nil, + cxxSettings: [CXXSetting]? = nil, + swiftSettings: [SwiftSetting]? = nil, + linkerSettings: [LinkerSetting]? = nil, + plugins: [PluginUsage]? = nil, + templateInitializationOptions: TemplateInitializationOptions, + ) -> [Target] { + + let templatePluginName = "\(name)Plugin" + let templateExecutableName = name + + let (verb, description): (String, String) + switch templateInitializationOptions { + case .packageInit(_, _, let desc): + verb = "init-\(name.lowercased())" + description = desc + } + + let permissions: [PluginPermission] = { + switch templateInitializationOptions { + case .packageInit(_, let templatePermissions, _): + return templatePermissions?.compactMap { permission in + switch permission { + case .allowNetworkConnections(let scope, let reason): + // Map from TemplateNetworkPermissionScope to PluginNetworkPermissionScope + let pluginScope: PluginNetworkPermissionScope + switch scope { + case .none: + pluginScope = .none + case .local(let ports): + pluginScope = .local(ports: ports) + case .all(let ports): + pluginScope = .all(ports: ports) + case .docker: + pluginScope = .docker + case .unixDomainSocket: + pluginScope = .unixDomainSocket + } + return .allowNetworkConnections(scope: pluginScope, reason: reason) + } + } ?? [] + } + }() + + let templateTarget = Target( + name: templateExecutableName, + dependencies: dependencies, + path: path, + exclude: exclude, + sources: sources, + resources: resources, + publicHeadersPath: publicHeadersPath, + type: .template, + packageAccess: packageAccess, + cSettings: cSettings, + cxxSettings: cxxSettings, + swiftSettings: swiftSettings, + linkerSettings: linkerSettings, + plugins: plugins, + templateInitializationOptions: templateInitializationOptions + ) + + // Plugin target that depends on the template + let pluginTarget = plugin( + name: templatePluginName, + capability: .command( + intent: .custom(verb: verb, description: description), + permissions: permissions + ), + dependencies: [Target.Dependency.target(name: templateExecutableName, condition: nil)] + ) + + return [templateTarget, pluginTarget] } } diff --git a/Sources/PackageModel/Manifest/TargetDescription.swift b/Sources/PackageModel/Manifest/TargetDescription.swift index 6f5fa5068dc..04bbcfcc15e 100644 --- a/Sources/PackageModel/Manifest/TargetDescription.swift +++ b/Sources/PackageModel/Manifest/TargetDescription.swift @@ -198,19 +198,17 @@ public struct TargetDescription: Hashable, Encodable, Sendable { public let templateInitializationOptions: TemplateInitializationOptions? public enum TemplateInitializationOptions: Hashable, Sendable { - case packageInit(templateType: TemplateType, executable: Dependency, templatePermissions: [TemplatePermission]?, description: String) + case packageInit(templateType: TemplateType, templatePermissions: [TemplatePermission]?, description: String) } public enum TemplateType: String, Hashable, Codable, Sendable { - /// A target that contains code for the Swift package's functionality. - case regular - /// A target that contains code for an executable's main module. + case library case executable - /// A target that contains tests for the Swift package's other targets. - case test - /// A target that adapts a library on the system to work with Swift - /// packages. + case tool + case buildToolPlugin + case commandPlugin case `macro` + case empty } public enum TemplateNetworkPermissionScope: Hashable, Codable, Sendable { @@ -553,14 +551,13 @@ public struct TargetDescription: Hashable, Encodable, Sendable { targetType: targetType, propertyName: "checksum", value: checksum ?? "" - ) } - if templateInitializationOptions == nil { throw Error.disallowedPropertyInTarget( + ) } + if templateInitializationOptions == nil { throw Error.disallowedPropertyInTarget( //john-to-revisit targetName: name, targetType: targetType, propertyName: "templateInitializationOptions", - value: String(describing: templateInitializationOptions!) - ) - } + value: String(describing: templateInitializationOptions) + ) } } @@ -714,16 +711,15 @@ extension TargetDescription.TemplateInitializationOptions: Codable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { - case let .packageInit(a1, a2, a3, a4): + case let .packageInit(a1, a2, a3): var unkeyedContainer = container.nestedUnkeyedContainer(forKey: .packageInit) try unkeyedContainer.encode(a1) - try unkeyedContainer.encode(a2) - if let permissions = a3 { - try unkeyedContainer.encode(a3) + if let permissions = a2 { + try unkeyedContainer.encode(permissions) } else { try unkeyedContainer.encodeNil() } - try unkeyedContainer.encode(a4) + try unkeyedContainer.encode(a3) } } @@ -736,10 +732,9 @@ extension TargetDescription.TemplateInitializationOptions: Codable { case .packageInit: var unkeyedValues = try values.nestedUnkeyedContainer(forKey: key) let templateType = try unkeyedValues.decode(TargetDescription.TemplateType.self) - let executable = try unkeyedValues.decode(TargetDescription.Dependency.self) let templatePermissions = try unkeyedValues.decodeIfPresent([TargetDescription.TemplatePermission].self) let description = try unkeyedValues.decode(String.self) - self = .packageInit(templateType: templateType, executable: executable, templatePermissions: templatePermissions ?? nil, description: description) + self = .packageInit(templateType: templateType, templatePermissions: templatePermissions ?? nil, description: description) } } } diff --git a/Sources/PackageModel/Product.swift b/Sources/PackageModel/Product.swift index ac42bc458d6..c6c1fc0092b 100644 --- a/Sources/PackageModel/Product.swift +++ b/Sources/PackageModel/Product.swift @@ -102,10 +102,18 @@ public enum ProductType: Equatable, Hashable, Sendable { /// A macro product. case `macro` + /// A template product + case template + public var isLibrary: Bool { guard case .library = self else { return false } return true } + + public var isTemplate: Bool { + guard case .template = self else {return false} + return true + } } @@ -197,6 +205,8 @@ extension ProductType: CustomStringConvertible { return "plugin" case .macro: return "macro" + case .template: + return "template" } } } @@ -216,7 +226,7 @@ extension ProductFilter: CustomStringConvertible { extension ProductType: Codable { private enum CodingKeys: String, CodingKey { - case library, executable, snippet, plugin, test, `macro` + case library, executable, snippet, plugin, test, `macro`, template } public func encode(to encoder: Encoder) throws { @@ -235,6 +245,8 @@ extension ProductType: Codable { try container.encodeNil(forKey: .test) case .macro: try container.encodeNil(forKey: .macro) + case .template: + try container.encodeNil(forKey: .template) } } @@ -258,6 +270,8 @@ extension ProductType: Codable { self = .plugin case .macro: self = .macro + case .template: + self = .template } } } From 7d86a7b17c5a380441575ac4662175dc25ac926d Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:28:53 -0400 Subject: [PATCH 016/225] dependency graph for swift targets and templates need to be revisited --- Sources/PackageGraph/ModulesGraph+Loading.swift | 2 +- Sources/PackageGraph/ModulesGraph.swift | 4 ++-- Sources/PackageGraph/Resolution/ResolvedProduct.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/PackageGraph/ModulesGraph+Loading.swift b/Sources/PackageGraph/ModulesGraph+Loading.swift index fb4c3a1bc7c..d076f4270df 100644 --- a/Sources/PackageGraph/ModulesGraph+Loading.swift +++ b/Sources/PackageGraph/ModulesGraph+Loading.swift @@ -283,7 +283,7 @@ private func checkAllDependenciesAreUsed( // We continue if the dependency contains executable products to make sure we don't // warn on a valid use-case for a lone dependency: swift run dependency executables. - guard !dependency.products.contains(where: { $0.type == .executable }) else { + guard !dependency.products.contains(where: { $0.type == .executable || $0.type == .template}) else { //john-to-revisit continue } // Skip this check if this dependency is a system module because system module packages diff --git a/Sources/PackageGraph/ModulesGraph.swift b/Sources/PackageGraph/ModulesGraph.swift index 7c7e9800be5..9ed057ad904 100644 --- a/Sources/PackageGraph/ModulesGraph.swift +++ b/Sources/PackageGraph/ModulesGraph.swift @@ -267,8 +267,8 @@ public struct ModulesGraph { }) return try Dictionary(throwingUniqueKeysWithValues: testModuleDeps) }() - - for module in rootModules where module.type == .executable { + + for module in rootModules where module.type == .executable || module.type == .template { //john-to-revisit // Find all dependencies of this module within its package. Note that we do not traverse plugin usages. let dependencies = try topologicalSortIdentifiable(module.dependencies, successors: { $0.dependencies.compactMap{ $0.module }.filter{ $0.type != .plugin }.map{ .module($0, conditions: []) } diff --git a/Sources/PackageGraph/Resolution/ResolvedProduct.swift b/Sources/PackageGraph/Resolution/ResolvedProduct.swift index c6cbbe3bf1d..6a81089e8c1 100644 --- a/Sources/PackageGraph/Resolution/ResolvedProduct.swift +++ b/Sources/PackageGraph/Resolution/ResolvedProduct.swift @@ -58,7 +58,7 @@ public struct ResolvedProduct { /// Note: This property is only valid for executable products. public var executableModule: ResolvedModule { get throws { - guard self.type == .executable || self.type == .snippet || self.type == .macro else { + guard self.type == .executable || self.type == .snippet || self.type == .macro || self.type == .template else { //john-to-revisit throw InternalError("`executableTarget` should only be called for executable targets") } guard let underlyingExecutableModule = modules.map(\.underlying).executables.first, From aefc478ca753dbb75c5fb122d16233badbf2d0e5 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:29:35 -0400 Subject: [PATCH 017/225] diagnostics added for template targets + products, will need to add tests for this too --- Sources/PackageLoading/Diagnostics.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/PackageLoading/Diagnostics.swift b/Sources/PackageLoading/Diagnostics.swift index eb3bbde4777..debc3030b52 100644 --- a/Sources/PackageLoading/Diagnostics.swift +++ b/Sources/PackageLoading/Diagnostics.swift @@ -38,7 +38,7 @@ extension Basics.Diagnostic { case .library(.automatic): typeString = "" case .executable, .snippet, .plugin, .test, .macro, - .library(.dynamic), .library(.static): + .library(.dynamic), .library(.static), .template: //john-to-revisit typeString = " (\(product.type))" } @@ -99,10 +99,21 @@ extension Basics.Diagnostic { .error("plugin product '\(product)' should have at least one plugin target") } + static func templateProductWithNoTargets(product: String) -> Self { + .error("template product '\(product)' should have at least one plugin target") + } + static func pluginProductWithNonPluginTargets(product: String, otherTargets: [String]) -> Self { .error("plugin product '\(product)' should have only plugin targets (it has \(otherTargets.map{ "'\($0)'" }.joined(separator: ", ")))") } + static func templateProductWithNonTemplateTargets(product: String, otherTargets: [String]) -> Self { + .error("template product `\(product)` should have only template targets (it has \(otherTargets.map{ "'\($0)'" }.joined(separator: ", ")))") + } + + static func templateProductWithMultipleTemplates(product: String) -> Self { + .error("template product `\(product)` should have only one template target") + } static var noLibraryTargetsForREPL: Self { .error("unable to synthesize a REPL product as there are no library targets in the package") } From e93ebd5b05252d44b48410b7a3327561a74f0980 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:30:11 -0400 Subject: [PATCH 018/225] manifest loading and json parsing --- .../PackageLoading/ManifestJSONParser.swift | 20 ++++++++++++------- Sources/PackageLoading/ManifestLoader.swift | 1 - 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Sources/PackageLoading/ManifestJSONParser.swift b/Sources/PackageLoading/ManifestJSONParser.swift index 567011c47d6..5f1a1a6553c 100644 --- a/Sources/PackageLoading/ManifestJSONParser.swift +++ b/Sources/PackageLoading/ManifestJSONParser.swift @@ -499,7 +499,7 @@ extension ProductDescription { switch product.productType { case .executable: productType = .executable - case .plugin: + case .plugin, .template: productType = .plugin case .library(let type): productType = .library(.init(type)) @@ -638,8 +638,8 @@ extension TargetDescription.PluginUsage { extension TargetDescription.TemplateInitializationOptions { init (_ usage: Serialization.TemplateInitializationOptions, identityResolver: IdentityResolver) throws { switch usage { - case .packageInit(let templateType, let executable, let templatePermissions, let description): - self = .packageInit(templateType: .init(templateType), executable: try .init(executable, identityResolver: identityResolver), templatePermissions: templatePermissions?.map { .init($0) }, description: description) + case .packageInit(let templateType, let templatePermissions, let description): + self = .packageInit(templateType: .init(templateType), templatePermissions: templatePermissions?.map { .init($0) }, description: description) } } } @@ -647,14 +647,20 @@ extension TargetDescription.TemplateInitializationOptions { extension TargetDescription.TemplateType { init(_ type: Serialization.TemplateType) { switch type { - case .regular: - self = .regular + case .library: + self = .library case .executable: self = .executable - case .test: - self = .test + case .tool: + self = .tool + case .buildToolPlugin: + self = .buildToolPlugin + case .commandPlugin: + self = .commandPlugin case .macro: self = .macro + case .empty: + self = .empty } } } diff --git a/Sources/PackageLoading/ManifestLoader.swift b/Sources/PackageLoading/ManifestLoader.swift index ea17a3007ac..c7c31ff9174 100644 --- a/Sources/PackageLoading/ManifestLoader.swift +++ b/Sources/PackageLoading/ManifestLoader.swift @@ -944,7 +944,6 @@ public final class ManifestLoader: ManifestLoaderProtocol { // Read the JSON output that was emitted by libPackageDescription. let jsonOutput: String = try localFileSystem.readFileContents(jsonOutputFile) evaluationResult.manifestJSON = jsonOutput - print(jsonOutput) // withTemporaryDirectory handles cleanup automatically return evaluationResult } From 27b194ff67e9215a20cec1dc76b0e53b6323c018 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:30:39 -0400 Subject: [PATCH 019/225] templates directory check added, as template executables should not be in sources --- Sources/PackageLoading/PackageBuilder.swift | 78 +++++++++++++++++---- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/Sources/PackageLoading/PackageBuilder.swift b/Sources/PackageLoading/PackageBuilder.swift index 112e59a77f6..22f6dc4b6e9 100644 --- a/Sources/PackageLoading/PackageBuilder.swift +++ b/Sources/PackageLoading/PackageBuilder.swift @@ -113,7 +113,18 @@ extension ModuleError: CustomStringConvertible { let packages = packages.map(\.description).sorted().joined(separator: "', '") return "multiple packages ('\(packages)') declare targets with a conflicting name: '\(target)’; target names need to be unique across the package graph" case .moduleNotFound(let target, let type, let shouldSuggestRelaxedSourceDir): - let folderName = (type == .test) ? "Tests" : (type == .plugin) ? "Plugins" : "Sources" + var folderName = "" + switch type { + case .test: + folderName = "Tests" + case .plugin: + folderName = "Plugins" + case .template: + folderName = "Templates" + default: + folderName = "Sources" + } + var clauses = ["Source files for target \(target) should be located under '\(folderName)/\(target)'"] if shouldSuggestRelaxedSourceDir { clauses.append("'\(folderName)'") @@ -316,7 +327,8 @@ public final class PackageBuilder { public static let predefinedTestDirectories = ["Tests", "Sources", "Source", "src", "srcs"] /// Predefined plugin directories, in order of preference. public static let predefinedPluginDirectories = ["Plugins"] - + /// Predefinded template directories, in order of preference + public static let predefinedTemplateDirectories = ["Templates", "Template"] /// The identity for the package being constructed. private let identity: PackageIdentity @@ -557,7 +569,7 @@ public final class PackageBuilder { /// Finds the predefined directories for regular targets, test targets, and plugin targets. private func findPredefinedTargetDirectory() - -> (targetDir: String, testTargetDir: String, pluginTargetDir: String) + -> (targetDir: String, testTargetDir: String, pluginTargetDir: String, templateTargetDir: String) { let targetDir = PackageBuilder.predefinedSourceDirectories.first(where: { self.fileSystem.isDirectory(self.packagePath.appending(component: $0)) @@ -571,7 +583,11 @@ public final class PackageBuilder { self.fileSystem.isDirectory(self.packagePath.appending(component: $0)) }) ?? PackageBuilder.predefinedPluginDirectories[0] - return (targetDir, testTargetDir, pluginTargetDir) + let templateTargetDir = PackageBuilder.predefinedTemplateDirectories.first(where: { + self.fileSystem.isDirectory(self.packagePath.appending(component: $0)) + }) ?? PackageBuilder.predefinedTemplateDirectories[0] + + return (targetDir, testTargetDir, pluginTargetDir, templateTargetDir) } /// Construct targets according to PackageDescription 4 conventions. @@ -592,6 +608,10 @@ public final class PackageBuilder { path: packagePath.appending(component: predefinedDirs.pluginTargetDir) ) + let predefinedTemplateTargetDirectory = PredefinedTargetDirectory( + fs: fileSystem, + path: packagePath.appending(component: predefinedDirs.templateTargetDir) + ) /// Returns the path of the given target. func findPath(for target: TargetDescription) throws -> AbsolutePath { if target.type == .binary { @@ -621,14 +641,19 @@ public final class PackageBuilder { } // Check if target is present in the predefined directory. - let predefinedDir: PredefinedTargetDirectory = switch target.type { - case .test: - predefinedTestTargetDirectory - case .plugin: - predefinedPluginTargetDirectory - default: - predefinedTargetDirectory - } + let predefinedDir: PredefinedTargetDirectory = { + switch target.type { + case .test: + predefinedTestTargetDirectory + case .plugin: + predefinedPluginTargetDirectory + case .template: + predefinedTemplateTargetDirectory + default: + predefinedTargetDirectory + } + }() + let path = predefinedDir.path.appending(component: target.name) // Return the path if the predefined directory contains it. @@ -1035,6 +1060,8 @@ public final class PackageBuilder { moduleKind = .executable case .macro: moduleKind = .macro + case .template: //john-to-revisit + moduleKind = .template default: moduleKind = sources.computeModuleKind() if moduleKind == .executable && self.manifest.toolsVersion >= .v5_4 && self @@ -1571,6 +1598,10 @@ public final class PackageBuilder { guard self.validatePluginProduct(product, with: modules) else { continue } + case .template: //john-to-revisit + guard self.validateTemplateProduct(product, with: modules) else { + continue + } } try append(Product(package: self.identity, name: product.name, type: product.type, modules: modules)) @@ -1585,7 +1616,7 @@ public final class PackageBuilder { switch product.type { case .library, .plugin, .test, .macro: return [] - case .executable, .snippet: + case .executable, .snippet, .template: //john-to-revisit return product.targets } }) @@ -1731,6 +1762,27 @@ public final class PackageBuilder { return true } + private func validateTemplateProduct(_ product: ProductDescription, with targets: [Module]) -> Bool { + let nonTemplateTargets = targets.filter { $0.type != .template } + guard nonTemplateTargets.isEmpty else { + self.observabilityScope + .emit(.templateProductWithNonTemplateTargets(product: product.name, + otherTargets: nonTemplateTargets.map(\.name))) + return false + } + guard !targets.isEmpty else { + self.observabilityScope.emit(.templateProductWithNoTargets(product: product.name)) + return false + } + + guard targets.count == 1 else { + self.observabilityScope.emit(.templateProductWithMultipleTemplates(product: product.name)) + return false + } + + return true + } + /// Returns the first suggested predefined source directory for a given target type. public static func suggestedPredefinedSourceDirectory(type: TargetDescription.TargetKind) -> String { // These are static constants, safe to access by index; the first choice is preferred. From 21e0e38e93b4f8c0fbe3c24e1e43c0235f2fb6e2 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:31:32 -0400 Subject: [PATCH 020/225] added templates as a type of module, will need to revisit to edit behavior as an executable --- .../ManifestSourceGeneration.swift | 20 +++++++++++-------- Sources/PackageModel/Module/Module.swift | 3 ++- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Sources/PackageModel/ManifestSourceGeneration.swift b/Sources/PackageModel/ManifestSourceGeneration.swift index b22cd65edf1..3d7f0ce0f5f 100644 --- a/Sources/PackageModel/ManifestSourceGeneration.swift +++ b/Sources/PackageModel/ManifestSourceGeneration.swift @@ -257,6 +257,8 @@ fileprivate extension SourceCodeFragment { self.init(enum: "test", subnodes: params, multiline: true) case .macro: self.init(enum: "macro", subnodes: params, multiline: true) + case .template: + self.init(enum: "template", subnodes: params, multiline: true) } } } @@ -543,23 +545,25 @@ fileprivate extension SourceCodeFragment { init(from templateInitializationOptions: TargetDescription.TemplateInitializationOptions) { switch templateInitializationOptions { - case .packageInit(let templateType, let executable, let templatePermissions, let description): + case .packageInit(let templateType, let templatePermissions, let description): var params: [SourceCodeFragment] = [] switch templateType { - case .regular: + case .library: self.init(enum: "target", subnodes: params, multiline: true) case .executable: self.init(enum: "executableTarget", subnodes: params, multiline: true) - case .test: - self.init(enum: "testTarget", subnodes: params, multiline: true) + case .tool: + self.init(enum: "tool", subnodes: params, multiline: true) + case .buildToolPlugin: + self.init(enum: "buildToolPlugin", subnodes: params, multiline: true) + case .commandPlugin: + self.init(enum: "commandPlugin", subnodes: params, multiline: true) case .macro: self.init(enum: "macro", subnodes: params, multiline: true) + case .empty: + self.init(enum: "empty", subnodes: params, multiline: true) } - // Template type as an enum - - // Executable fragment - params.append(SourceCodeFragment(key: "executable", subnode: .init(from: executable))) // Permissions, if any if let permissions = templatePermissions { diff --git a/Sources/PackageModel/Module/Module.swift b/Sources/PackageModel/Module/Module.swift index 786e99de9f3..0537e191fac 100644 --- a/Sources/PackageModel/Module/Module.swift +++ b/Sources/PackageModel/Module/Module.swift @@ -29,6 +29,7 @@ public class Module { case plugin case snippet case `macro` + case template } /// A group a module belongs to that allows customizing access boundaries. A module is treated as @@ -307,7 +308,7 @@ public extension Sequence where Iterator.Element == Module { switch $0.type { case .binary: return ($0 as? BinaryModule)?.containsExecutable == true - case .executable, .snippet, .macro: + case .executable, .snippet, .macro, .template: //john-to-revisit return true default: return false From 5aa6b9594a4677c9deb8b84efc6e926255ee2741 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:31:57 -0400 Subject: [PATCH 021/225] added templates as a type of module, will need to revisit to edit behavior as an executable pt2 --- Sources/PackageModel/Module/SwiftModule.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/PackageModel/Module/SwiftModule.swift b/Sources/PackageModel/Module/SwiftModule.swift index 93b083b1ebf..b60974c16e1 100644 --- a/Sources/PackageModel/Module/SwiftModule.swift +++ b/Sources/PackageModel/Module/SwiftModule.swift @@ -138,11 +138,11 @@ public final class SwiftModule: Module { } public var supportsTestableExecutablesFeature: Bool { - // Exclude macros from testable executables if they are built as dylibs. + // Exclude macros from testable executables if they are built as dylibs. john-to-revisit #if BUILD_MACROS_AS_DYLIBS - return type == .executable || type == .snippet + return type == .executable || type == .snippet || type == .template #else - return type == .executable || type == .macro || type == .snippet + return type == .executable || type == .macro || type == .snippet || type == .template #endif } } From 9a9904805a0f52284ad303f5e30f4e6dfaef283f Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:32:34 -0400 Subject: [PATCH 022/225] changed tools version when generating a new project, will need to revert back to major, minor once demo completed --- Sources/PackageModel/ToolsVersion.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PackageModel/ToolsVersion.swift b/Sources/PackageModel/ToolsVersion.swift index cded5bf9467..37e12c6703a 100644 --- a/Sources/PackageModel/ToolsVersion.swift +++ b/Sources/PackageModel/ToolsVersion.swift @@ -79,7 +79,7 @@ public struct ToolsVersion: Equatable, Hashable, Codable, Sendable { /// Returns the tools version with zeroed patch number. public var zeroedPatch: ToolsVersion { - return ToolsVersion(version: Version(major, minor, 0)) + return ToolsVersion(version: Version(6, 1, 0)) //john-to-revisit working on 6.1.0, just for using to test. revert to major, minor when finished } /// The underlying backing store. From 8d555f3dd8313cb7c943a671bf389ec332fbf63c Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:33:35 -0400 Subject: [PATCH 023/225] target and product descriptions syntax --- Sources/PackageModelSyntax/ProductDescription+Syntax.swift | 1 + Sources/PackageModelSyntax/TargetDescription+Syntax.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/PackageModelSyntax/ProductDescription+Syntax.swift b/Sources/PackageModelSyntax/ProductDescription+Syntax.swift index 614d5db8bf4..d12cef5e31b 100644 --- a/Sources/PackageModelSyntax/ProductDescription+Syntax.swift +++ b/Sources/PackageModelSyntax/ProductDescription+Syntax.swift @@ -29,6 +29,7 @@ extension ProductDescription: ManifestSyntaxRepresentable { case .plugin: "plugin" case .snippet: "snippet" case .test: "test" + case .template: "template" } } diff --git a/Sources/PackageModelSyntax/TargetDescription+Syntax.swift b/Sources/PackageModelSyntax/TargetDescription+Syntax.swift index ea4b252d0ae..9f5ff2299ae 100644 --- a/Sources/PackageModelSyntax/TargetDescription+Syntax.swift +++ b/Sources/PackageModelSyntax/TargetDescription+Syntax.swift @@ -27,7 +27,7 @@ extension TargetDescription: ManifestSyntaxRepresentable { case .regular: "target" case .system: "systemLibrary" case .test: "testTarget" - case .template: "templateTarget" + case .template: "template" } } From 1def8d8cd9f2046700371fab4fdf40f5e5acc88c Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:34:28 -0400 Subject: [PATCH 024/225] build parameters for templates, will need to revisit --- Sources/SPMBuildCore/BuildParameters/BuildParameters.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift b/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift index f82f050328b..8451d9c724e 100644 --- a/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift +++ b/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift @@ -302,9 +302,15 @@ public struct BuildParameters: Encodable { try RelativePath(validating: "\(name)\(self.suffix)\(self.triple.executableExtension)") } + package func templatePath(for name: String) throws -> Basics.RelativePath { + try RelativePath(validating: "\(name)\(self.suffix)\(self.triple.executableExtension)") //John-to-revisit + } + /// Returns the path to the binary of a product for the current build parameters, relative to the build directory. public func binaryRelativePath(for product: ResolvedProduct) throws -> Basics.RelativePath { switch product.type { + case .template: + return try templatePath(for: product.name) //john-to-revisit case .executable, .snippet: return try executablePath(for: product.name) case .library(.static): From 9d54614bed7dbdc25fab1a8566a4eda79c61ae94 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:34:51 -0400 Subject: [PATCH 025/225] plugins support for executing templates --- Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift | 2 +- Sources/SPMBuildCore/Plugins/PluginInvocation.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift b/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift index 83b3b90321d..783c67b7c27 100644 --- a/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift +++ b/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift @@ -335,7 +335,7 @@ fileprivate extension WireInput.Target.TargetInfo.SourceModuleKind { switch kind { case .library: self = .generic - case .executable: + case .executable, .template: //john-to-revisit self = .executable case .snippet: self = .snippet diff --git a/Sources/SPMBuildCore/Plugins/PluginInvocation.swift b/Sources/SPMBuildCore/Plugins/PluginInvocation.swift index d86ef9ecad7..db345bceb8c 100644 --- a/Sources/SPMBuildCore/Plugins/PluginInvocation.swift +++ b/Sources/SPMBuildCore/Plugins/PluginInvocation.swift @@ -696,7 +696,7 @@ fileprivate func collectAccessibleTools( } } // For an executable target we create a `builtTool`. - else if executableOrBinaryModule.type == .executable { + else if executableOrBinaryModule.type == .executable || executableOrBinaryModule.type == .template { //john-to-revisit return try [.builtTool(name: builtToolName, path: RelativePath(validating: executableOrBinaryModule.name))] } else { From c23948ca52cf8183a7322a423e5293cb8ff99df6 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:35:33 -0400 Subject: [PATCH 026/225] buildsupport for templates, will need to revisit --- .../PackagePIFBuilder+Helpers.swift | 13 +++++++------ Sources/SwiftBuildSupport/PackagePIFBuilder.swift | 10 +++++++++- .../PackagePIFProjectBuilder+Modules.swift | 7 +++++++ .../PackagePIFProjectBuilder+Products.swift | 3 +++ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift b/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift index 90cc33e8832..57f8b45ef9a 100644 --- a/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift +++ b/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift @@ -186,7 +186,7 @@ extension PackageModel.Module { switch self.type { case .executable, .snippet: true - case .library, .test, .macro, .systemModule, .plugin, .binary: + case .library, .test, .macro, .systemModule, .plugin, .binary, .template: //john-to-revisit false } } @@ -195,7 +195,7 @@ extension PackageModel.Module { switch self.type { case .binary: true - case .library, .executable, .snippet, .test, .plugin, .macro, .systemModule: + case .library, .executable, .snippet, .test, .plugin, .macro, .systemModule, .template: false } } @@ -205,7 +205,7 @@ extension PackageModel.Module { switch self.type { case .library, .executable, .snippet, .test, .macro: true - case .systemModule, .plugin, .binary: + case .systemModule, .plugin, .binary, .template: //john-to-revisit false } } @@ -220,6 +220,7 @@ extension PackageModel.ProductType { case .library: .library case .plugin: .plugin case .macro: .macro + case .template: .template } } } @@ -680,7 +681,7 @@ extension PackageGraph.ResolvedProduct { /// (e.g., executables have one executable module, test bundles have one test module, etc). var isMainModuleProduct: Bool { switch self.type { - case .executable, .snippet, .test: + case .executable, .snippet, .test, .template: //john-to-revisit true case .library, .macro, .plugin: false @@ -698,7 +699,7 @@ extension PackageGraph.ResolvedProduct { var isExecutable: Bool { switch self.type { - case .executable, .snippet: + case .executable, .snippet, .template: //john-to-revisit true case .library, .test, .plugin, .macro: false @@ -741,7 +742,7 @@ extension PackageGraph.ResolvedProduct { /// Shoud we link this product dependency? var isLinkable: Bool { switch self.type { - case .library, .executable, .snippet, .test, .macro: + case .library, .executable, .snippet, .test, .macro, .template: //john-to-revisit true case .plugin: false diff --git a/Sources/SwiftBuildSupport/PackagePIFBuilder.swift b/Sources/SwiftBuildSupport/PackagePIFBuilder.swift index a4769ca3140..40f6744d6d9 100644 --- a/Sources/SwiftBuildSupport/PackagePIFBuilder.swift +++ b/Sources/SwiftBuildSupport/PackagePIFBuilder.swift @@ -434,6 +434,9 @@ public final class PackagePIFBuilder { case .snippet, .macro: break // TODO: Double-check what's going on here as we skip snippet modules too (rdar://147705448) + case .template: + // john-to-revisit: makeTemplateproduct + try projectBuilder.makeMainModuleProduct(product) } } @@ -470,6 +473,11 @@ public final class PackagePIFBuilder { case .macro: try projectBuilder.makeMacroModule(module) + + case .template: + // Skip template modules — they represent tools, not code to compile. john-to-revisit + break + } } @@ -650,7 +658,7 @@ extension PackagePIFBuilder.LinkedPackageBinary { case .library, .binary, .macro: self.init(name: module.name, packageName: packageName, type: .target) - case .systemModule, .plugin: + case .systemModule, .plugin, .template: //john-to-revisit return nil } } diff --git a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift index a0e0c7c3049..15f3fb6e131 100644 --- a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift +++ b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift @@ -95,7 +95,12 @@ extension PackagePIFProjectBuilder { platformFilters: dependencyPlatformFilters ) log(.debug, indent: 1, "Added dependency on target '\(dependencyGUID)'") + case .template: //john-to-revisit + // Template targets are used as tooling, not build dependencies. + // Plugins will invoke them when needed. + break } + case .product(let productDependency, let packageConditions): // Do not add a dependency for binary-only executable products since they are not part of the build. @@ -696,6 +701,8 @@ extension PackagePIFProjectBuilder { indent: 1, "Added \(shouldLinkProduct ? "linked " : "")dependency on target '\(moduleDependency.pifTargetGUID)'" ) + case .template: //john-to-revisit + break } case .product(let productDependency, let packageConditions): diff --git a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift index 9525b69ba76..c0ff0bfbb43 100644 --- a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift +++ b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift @@ -446,6 +446,9 @@ extension PackagePIFProjectBuilder { indent: 1, "Added \(shouldLinkProduct ? "linked " : "")dependency on target '\(dependencyGUID)'" ) + case .template: + //john-to-revisit + break } case .product(let productDependency, let packageConditions): From 84e7e2a7c7cc118270eb88a0d3066b3b41f44fd6 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:35:56 -0400 Subject: [PATCH 027/225] template creation workhorse --- Sources/Workspace/InitTemplatePackage.swift | 158 +++++++++++++------- 1 file changed, 103 insertions(+), 55 deletions(-) diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 81506213928..f250164f31e 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -4,6 +4,7 @@ // // Created by John Bute on 2025-05-13. // + import Basics import PackageModel import SPMBuildCore @@ -13,11 +14,60 @@ import Basics import PackageModel import SPMBuildCore import TSCUtility +import System +import PackageModelSyntax +import TSCBasic +import SwiftParser + public final class InitTemplatePackage { var initMode: TemplateType + public var supportedTestingLibraries: Set + + + let templateName: String + /// The file system to use + let fileSystem: FileSystem + + /// Where to create the new package + let destinationPath: Basics.AbsolutePath + + /// Configuration from the used toolchain. + let installedSwiftPMConfiguration: InstalledSwiftPMConfiguration + + var packageName: String + + + var templatePath: Basics.AbsolutePath + + let packageType: InitPackage.PackageType + + public struct InitPackageOptions { + /// The type of package to create. + public var packageType: InitPackage.PackageType + + /// The set of supported testing libraries to include in the package. + public var supportedTestingLibraries: Set + + /// The list of platforms in the manifest. + /// + /// Note: This should only contain Apple platforms right now. + public var platforms: [SupportedPlatform] + + public init( + packageType: InitPackage.PackageType, + supportedTestingLibraries: Set, + platforms: [SupportedPlatform] = [] + ) { + self.packageType = packageType + self.supportedTestingLibraries = supportedTestingLibraries + self.platforms = platforms + } + } + + public enum TemplateType: String, CustomStringConvertible { case local = "local" @@ -29,76 +79,74 @@ public final class InitTemplatePackage { } } - var packageName: String? - - var templatePath: AbsolutePath - let fileSystem: FileSystem - public init(initMode: InitTemplatePackage.TemplateType, packageName: String? = nil, templatePath: AbsolutePath, fileSystem: FileSystem) { + + public init( + name: String, + templateName: String, + initMode: TemplateType, + templatePath: Basics.AbsolutePath, + fileSystem: FileSystem, + packageType: InitPackage.PackageType, + supportedTestingLibraries: Set, + destinationPath: Basics.AbsolutePath, + installedSwiftPMConfiguration: InstalledSwiftPMConfiguration, + ) { + self.packageName = name self.initMode = initMode - self.packageName = packageName self.templatePath = templatePath + self.packageType = packageType + self.supportedTestingLibraries = supportedTestingLibraries + self.destinationPath = destinationPath + self.installedSwiftPMConfiguration = installedSwiftPMConfiguration self.fileSystem = fileSystem + self.templateName = templateName } - - - private func checkTemplateExists(templatePath: AbsolutePath) throws { - //Checks if there is a package in directory, if it contains a .template command line-tool and if it contains a /template folder. - - //check if the path does exist - guard self.fileSystem.exists(templatePath) else { - throw TemplateError.invalidPath - } - // Check if Package.swift exists in the directory - let manifest = templatePath.appending(component: Manifest.filename) - guard self.fileSystem.exists(manifest) else { - throw TemplateError.invalidPath - } - //check if package.swift contains a .plugin - - //check if it contains a template folder - + public func setupTemplateManifest() throws { + // initialize empty swift package + let initializedPackage = try InitPackage(name: self.packageName, options: .init(packageType: self.packageType, supportedTestingLibraries: self.supportedTestingLibraries), destinationPath: self.destinationPath, installedSwiftPMConfiguration: self.installedSwiftPMConfiguration, fileSystem: self.fileSystem) + try initializedPackage.writePackageStructure() + try initializePackageFromTemplate() + + //try build + // try --experimental-help-dump + //prompt + //run the executable. } -/* - func initPackage(_ swiftCommandState: SwiftCommandState) throws { - //Logic here for initializing initial package (should find better way to organize this but for now) - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") - } + private func initializePackageFromTemplate() throws { + try addTemplateDepenency() + } - let packageName = self.packageName ?? cwd.basename + private func addTemplateDepenency() throws { - // Testing is on by default, with XCTest only enabled explicitly. - // For macros this is reversed, since we don't support testing - // macros with Swift Testing yet. - var supportedTestingLibraries = Set() - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.xctest) - } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.swiftTesting) + + let manifestPath = destinationPath.appending(component: Manifest.filename) + let manifestContents: ByteString + + do { + manifestContents = try fileSystem.readFileContents(manifestPath) + } catch { + throw StringError("Cannot fin package manifest in \(manifestPath)") } - let initPackage = try InitPackage( - name: packageName, - packageType: initMode, - supportedTestingLibraries: supportedTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, - fileSystem: swiftCommandState.fileSystem - ) - initPackage.progressReporter = { message in - print(message) + let manifestSyntax = manifestContents.withData { data in + data.withUnsafeBytes { buffer in + buffer.withMemoryRebound(to: UInt8.self) { buffer in + Parser.parse(source: buffer) + } + } } - try initPackage.writePackageStructure() + + let editResult = try AddPackageDependency.addPackageDependency( + .fileSystem(name: nil, path: self.templatePath.pathString), to: manifestSyntax) + + try editResult.applyEdits(to: fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: false) } - */ + } @@ -113,7 +161,7 @@ extension TemplateError: CustomStringConvertible { switch self { case .manifestAlreadyExists: return "a manifest file already exists in this directory" - case let .invalidPath: + case .invalidPath: return "Path does not exist, or is invalid." } } From eb495d7c5eb279f3d6acf9735693080ba758c8f1 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:36:23 -0400 Subject: [PATCH 028/225] build support for xcbuild --- Sources/XCBuildSupport/PIFBuilder.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/XCBuildSupport/PIFBuilder.swift b/Sources/XCBuildSupport/PIFBuilder.swift index 283379fedd1..04ffe69d565 100644 --- a/Sources/XCBuildSupport/PIFBuilder.swift +++ b/Sources/XCBuildSupport/PIFBuilder.swift @@ -386,7 +386,7 @@ final class PackagePIFProjectBuilder: PIFProjectBuilder { private func addTarget(for product: ResolvedProduct) throws { switch product.type { - case .executable, .snippet, .test: + case .executable, .snippet, .test, .template: //john-to-revisit try self.addMainModuleTarget(for: product) case .library: self.addLibraryTarget(for: product) @@ -414,6 +414,8 @@ final class PackagePIFProjectBuilder: PIFProjectBuilder { case .macro: // Macros are not supported when using XCBuild, similar to package plugins. return + case .template: //john-to-revisit + return } } @@ -1613,6 +1615,8 @@ extension ProductType { .plugin case .macro: .macro + case .template: + .template } } } From 19382728bc2418af6f2f9a7b62be896ea02443de Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:37:18 -0400 Subject: [PATCH 029/225] reverting plugin test to normal --- Tests/CommandsTests/PackageCommandTests.swift | 31 +++++++++++++++++++ Tests/FunctionalTests/PluginTests.swift | 10 ------ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index 21dc52bcf6d..524f2306e47 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -637,6 +637,37 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase { } } + func testShowTemplates() async throws { //john-to-revisit + + try await fixture(name: "Miscellaneous/ShowTemplates") { fixturePath in + let packageRoot = fixturePath.appending("app") + let (textOutput, _) = try await self.execute(["show-templates", "--format=flatlist"], packagePath: packageRoot) + XCTAssert(textOutput.contains("GenerateStuff\n")) + XCTAssert(textOutput.contains("GenerateThings\n")) + + let (jsonOutput, _) = try await self.execute(["show-templates", "--format=json"], packagePath: packageRoot) + let json = try JSON(bytes: ByteString(encodingAsUTF8: jsonOutput)) + guard case let .array(contents) = json else { XCTFail("unexpected result"); return } + + XCTAssertEqual(2, contents.count) + + guard case let first = contents.first else { XCTFail("unexpected result"); return } + guard case let .dictionary(generateStuff) = first else { XCTFail("unexpected result"); return } + guard case let .string(generateStuffName)? = generateStuff["name"] else { XCTFail("unexpected result"); return } + XCTAssertEqual(generateStuffName, "GenerateThings") + if case let .string(package)? = generateStuff["package"] { + XCTFail("unexpected package for dealer (should be unset): \(package)") + return + } + + guard case let last = contents.last else { XCTFail("unexpected result"); return } + guard case let .dictionary(generateThings) = last else { XCTFail("unexpected result"); return } + guard case let .string(generateThingsName)? = generateThings["name"] else { XCTFail("unexpected result"); return } + XCTAssertEqual(generateThingsName, "GenerateStuff") + } + } + + func testShowDependencies() async throws { try await fixture(name: "DependencyResolution/External/Complex") { fixturePath in let packageRoot = fixturePath.appending("app") diff --git a/Tests/FunctionalTests/PluginTests.swift b/Tests/FunctionalTests/PluginTests.swift index 52ca343c9bd..7ac6d21fa29 100644 --- a/Tests/FunctionalTests/PluginTests.swift +++ b/Tests/FunctionalTests/PluginTests.swift @@ -267,16 +267,6 @@ final class PluginTests: XCTestCase { .package(name: "HelperPackage", path: "VendoredDependencies/HelperPackage") ], targets: [ - .template( - name: "GenerateStuff", - - templateInitializationOptions: .packageInit( - templateType: .executable, - executable: .target(name: "MyLibrary"), - description: "A template that generates a starter executable package" - ), - executable: .target(name: "MyLibrary"), - ), .target( name: "MyLibrary", dependencies: [ From 5e7ba4a2e9326b9e56b61cde64e4cbf7ed811f8e Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 14:16:27 -0400 Subject: [PATCH 030/225] printing out --experimental-dump-help --- .../Miscellaneous/ShowTemplates/app/Package.swift | 7 +++---- .../{GenerateStuffPlugin => looPlugin}/plugin.swift | 4 +++- .../app/Templates/GenerateThings/main.swift | 8 -------- .../Templates/{GenerateStuff => loo}/Template.swift | 0 Sources/Build/BuildOperation.swift | 1 + Sources/Commands/PackageCommands/Init.swift | 5 +++++ Sources/Commands/PackageCommands/PluginCommand.swift | 6 ++++-- Sources/PackageDescription/Product.swift | 12 +++++++++--- Sources/PackageDescription/Target.swift | 5 ++--- 9 files changed, 27 insertions(+), 21 deletions(-) rename Fixtures/Miscellaneous/ShowTemplates/app/Plugins/{GenerateStuffPlugin => looPlugin}/plugin.swift (87%) delete mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift rename Fixtures/Miscellaneous/ShowTemplates/app/Templates/{GenerateStuff => loo}/Template.swift (100%) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift index bddabe3cf92..13ffaf45b03 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -3,9 +3,8 @@ import PackageDescription let package = Package( name: "Dealer", - products: [.template( - name: "GenerateStuff" - ),], + products: Product.template(name: "loo") + , dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), @@ -13,7 +12,7 @@ let package = Package( ], targets: Target.template( - name: "GenerateStuff", + name: "loo", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "SystemPackage", package: "swift-system") diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateStuffPlugin/plugin.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift similarity index 87% rename from Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateStuffPlugin/plugin.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift index 8318ddd1ef0..8e88b2ec74d 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateStuffPlugin/plugin.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift @@ -17,7 +17,9 @@ struct TemplatePlugin: CommandPlugin { context: PluginContext, arguments: [String] ) async throws { - let tool = try context.tool(named: "GenerateStuff") + + print(arguments) + let tool = try context.tool(named: "loo") let process = Process() process.executableURL = URL(filePath: tool.url.path()) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift deleted file mode 100644 index ba2e9c4f78d..00000000000 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// main.swift -// app -// -// Created by John Bute on 2025-06-03. -// - -print("hello, world!") diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/Template.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/loo/Template.swift similarity index 100% rename from Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/Template.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Templates/loo/Template.swift diff --git a/Sources/Build/BuildOperation.swift b/Sources/Build/BuildOperation.swift index 78e8652130b..b8ce059f42b 100644 --- a/Sources/Build/BuildOperation.swift +++ b/Sources/Build/BuildOperation.swift @@ -602,6 +602,7 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS let product = graph.product(for: productName) + print("computeLLBuildTargetName") guard let product else { observabilityScope.emit(error: "no product named '\(productName)'") throw Diagnostics.fatalError diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 6de836f5794..f3c55079045 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -191,6 +191,11 @@ extension SwiftPackageCommand { // Implement or call your Registry-based template handler print("TODO: Handle Registry template") } + + + let parsedOptions = try PluginCommand.PluginOptions.parse(["--allow-writing-to-package-directory"]) + try await PluginCommand.run(command: template, options: parsedOptions, arguments: ["--","--experimental-dump-help"], swiftCommandState: swiftCommandState) + } else { var supportedTestingLibraries = Set() diff --git a/Sources/Commands/PackageCommands/PluginCommand.swift b/Sources/Commands/PackageCommands/PluginCommand.swift index 8a1b7e90212..07afd5ac4c4 100644 --- a/Sources/Commands/PackageCommands/PluginCommand.swift +++ b/Sources/Commands/PackageCommands/PluginCommand.swift @@ -37,7 +37,7 @@ struct PluginCommand: AsyncSwiftCommand { ) var listCommands: Bool = false - struct PluginOptions: ParsableArguments { + public struct PluginOptions: ParsableArguments { @Flag( name: .customLong("allow-writing-to-package-directory"), help: "Allow the plugin to write to the package directory." @@ -363,7 +363,7 @@ struct PluginCommand: AsyncSwiftCommand { let allowNetworkConnectionsCopy = allowNetworkConnections let buildEnvironment = buildParameters.buildEnvironment - let _ = try await pluginTarget.invoke( + let pluginOutput = try await pluginTarget.invoke( action: .performCommand(package: package, arguments: arguments), buildEnvironment: buildEnvironment, scriptRunner: pluginScriptRunner, @@ -383,6 +383,8 @@ struct PluginCommand: AsyncSwiftCommand { delegate: pluginDelegate ) + print("plugin Output:", pluginOutput) + // TODO: We should also emit a final line of output regarding the result. } diff --git a/Sources/PackageDescription/Product.swift b/Sources/PackageDescription/Product.swift index 1e21fce6396..d17a83cd487 100644 --- a/Sources/PackageDescription/Product.swift +++ b/Sources/PackageDescription/Product.swift @@ -201,10 +201,16 @@ public class Product { @available(_PackageDescription, introduced: 6.0) public static func template( name: String, - ) -> Product { + ) -> [Product] { let templatePluginName = "\(name)Plugin" - let executableTemplateName = name - return Product.plugin(name: templatePluginName, targets: [templatePluginName]) + return [Product.plugin(name: templatePluginName, targets: [templatePluginName]), Product.template(name: name)] + + } + + private static func template( + name: String + ) -> Product { + return Executable(name: name, targets: [name], settings: []) } } diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index 60c2232c19e..46d26410c50 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -1272,7 +1272,6 @@ public final class Target { pluginCapability: capability) } - //john-to-revisit documentation @available(_PackageDescription, introduced: 6.0) public static func template( name: String, @@ -1292,12 +1291,12 @@ public final class Target { ) -> [Target] { let templatePluginName = "\(name)Plugin" - let templateExecutableName = name + let templateExecutableName = "\(name)" let (verb, description): (String, String) switch templateInitializationOptions { case .packageInit(_, _, let desc): - verb = "init-\(name.lowercased())" + verb = templateExecutableName description = desc } From 5722eb215ae9f8fd1b30a4afe31c1d4445380697 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 15:52:36 -0400 Subject: [PATCH 031/225] capturing --experimental-dump-help and parsing it --- .../app/Plugins/looPlugin/plugin.swift | 2 - Sources/Build/BuildOperation.swift | 1 - Sources/Commands/PackageCommands/Init.swift | 71 ++++++++++++++++++- .../PackageCommands/PluginCommand.swift | 2 - 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift index 8e88b2ec74d..eff35a92b01 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift @@ -17,8 +17,6 @@ struct TemplatePlugin: CommandPlugin { context: PluginContext, arguments: [String] ) async throws { - - print(arguments) let tool = try context.tool(named: "loo") let process = Process() diff --git a/Sources/Build/BuildOperation.swift b/Sources/Build/BuildOperation.swift index b8ce059f42b..78e8652130b 100644 --- a/Sources/Build/BuildOperation.swift +++ b/Sources/Build/BuildOperation.swift @@ -602,7 +602,6 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS let product = graph.product(for: productName) - print("computeLLBuildTargetName") guard let product else { observabilityScope.emit(error: "no product named '\(productName)'") throw Diagnostics.fatalError diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index f3c55079045..4488d30da57 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -27,6 +27,7 @@ import SPMBuildCore import XCBuildSupport import TSCBasic +import ArgumentParserToolInfo extension SwiftPackageCommand { struct Init: AsyncSwiftCommand { @@ -192,10 +193,50 @@ extension SwiftPackageCommand { print("TODO: Handle Registry template") } - + /* let parsedOptions = try PluginCommand.PluginOptions.parse(["--allow-writing-to-package-directory"]) + + try await PluginCommand.run(command: template, options: parsedOptions, arguments: ["--","--experimental-dump-help"], swiftCommandState: swiftCommandState) + */ + + //will need to revisit this + let arguments = [ + "/Users/johnbute/Desktop/swift-pm-template/.build/arm64-apple-macosx/debug/swift-package", "plugin", template, "--allow-network-connections","local:1200", + "--", "--experimental-dump-help" + ] + let process = AsyncProcess(arguments: arguments) + + try process.launch() + + let processResult = try await process.waitUntilExit() + + + guard processResult.exitStatus == .terminated(code: 0) else { + throw try StringError(processResult.utf8stderrOutput()) + } + + + switch processResult.output { + case .success(let outputBytes): + let outputString = String(decoding: outputBytes, as: UTF8.self) + + + guard let data = outputString.data(using: .utf8) else { + fatalError("Could not convert output string to Data") + } + + do { + let schema = try JSONDecoder().decode(ToolInfoV0.self, from: data) + } + + case .failure(let error): + print("Failed to get output:", error) + } + + + } else { var supportedTestingLibraries = Set() @@ -226,6 +267,34 @@ extension SwiftPackageCommand { } } + + private func captureStdout(_ block: () async throws -> Void) async throws -> String { + let originalStdout = dup(fileno(stdout)) + + let pipe = Pipe() + let readHandle = pipe.fileHandleForReading + let writeHandle = pipe.fileHandleForWriting + + dup2(writeHandle.fileDescriptor, fileno(stdout)) + + + var output = "" + let outputQueue = DispatchQueue(label: "outputQueue") + let group = DispatchGroup() + group.enter() + + outputQueue.async { + let data = readHandle.readDataToEndOfFile() + output = String(data: data, encoding: .utf8) ?? "" + } + + + fflush(stdout) + writeHandle.closeFile() + + dup2(originalStdout, fileno(stdout)) + return output + } // first save current activeWorkspace //second switch activeWorkspace to the template Path //third revert after conditions have been checked, (we will also get stuff needed for dpeende diff --git a/Sources/Commands/PackageCommands/PluginCommand.swift b/Sources/Commands/PackageCommands/PluginCommand.swift index 07afd5ac4c4..f0c497ce1c0 100644 --- a/Sources/Commands/PackageCommands/PluginCommand.swift +++ b/Sources/Commands/PackageCommands/PluginCommand.swift @@ -383,8 +383,6 @@ struct PluginCommand: AsyncSwiftCommand { delegate: pluginDelegate ) - print("plugin Output:", pluginOutput) - // TODO: We should also emit a final line of output regarding the result. } From 4bb98cc1b686f52f35b785ed4386f173387118f4 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Thu, 5 Jun 2025 16:25:06 -0400 Subject: [PATCH 032/225] Move new package description API to the array types and update version to 999.0.0 --- .../ShowTemplates/app/Package.swift | 10 +++------- Sources/PackageDescription/Product.swift | 17 +++++++++-------- Sources/PackageDescription/Target.swift | 12 +++++++----- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift index 13ffaf45b03..060e3a35029 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -1,17 +1,15 @@ -// swift-tools-version:6.1 +// swift-tools-version:999.0.0 import PackageDescription let package = Package( name: "Dealer", - products: Product.template(name: "loo") - , - + products: .template(name: "loo"), dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2") ], - targets: Target.template( + targets: .template( name: "loo", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), @@ -25,8 +23,6 @@ let package = Package( ], description: "A template that generates a starter executable package" ) - ) - ) diff --git a/Sources/PackageDescription/Product.swift b/Sources/PackageDescription/Product.swift index d17a83cd487..1df7491021f 100644 --- a/Sources/PackageDescription/Product.swift +++ b/Sources/PackageDescription/Product.swift @@ -198,19 +198,20 @@ public class Product { return Plugin(name: name, targets: targets) } - @available(_PackageDescription, introduced: 6.0) + fileprivate static func template( + name: String + ) -> Product { + return Executable(name: name, targets: [name], settings: []) + } +} + +public extension [Product] { + @available(_PackageDescription, introduced: 999.0.0) public static func template( name: String, ) -> [Product] { let templatePluginName = "\(name)Plugin" return [Product.plugin(name: templatePluginName, targets: [templatePluginName]), Product.template(name: name)] - - } - - private static func template( - name: String - ) -> Product { - return Executable(name: name, targets: [name], settings: []) } } diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index 46d26410c50..7ac5335ab58 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -1271,11 +1271,13 @@ public final class Target { packageAccess: packageAccess, pluginCapability: capability) } +} - @available(_PackageDescription, introduced: 6.0) +public extension [Target] { + @available(_PackageDescription, introduced: 999.0.0) public static func template( name: String, - dependencies: [Dependency] = [], + dependencies: [Target.Dependency] = [], path: String? = nil, exclude: [String] = [], sources: [String]? = nil, @@ -1286,8 +1288,8 @@ public final class Target { cxxSettings: [CXXSetting]? = nil, swiftSettings: [SwiftSetting]? = nil, linkerSettings: [LinkerSetting]? = nil, - plugins: [PluginUsage]? = nil, - templateInitializationOptions: TemplateInitializationOptions, + plugins: [Target.PluginUsage]? = nil, + templateInitializationOptions: Target.TemplateInitializationOptions, ) -> [Target] { let templatePluginName = "\(name)Plugin" @@ -1345,7 +1347,7 @@ public final class Target { ) // Plugin target that depends on the template - let pluginTarget = plugin( + let pluginTarget = Target.plugin( name: templatePluginName, capability: .command( intent: .custom(verb: verb, description: description), From 536802dbc815c1523d7dc312bfa857a2f8852758 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Fri, 6 Jun 2025 12:32:10 -0400 Subject: [PATCH 033/225] Fix show-executables subcommand so that it does not show templates Fix show-templates subcommand so that it does not show snippets Make the swift-package binary development environment independent --- Sources/Commands/PackageCommands/Init.swift | 2 +- Sources/Commands/PackageCommands/ShowExecutables.swift | 2 ++ Sources/Commands/PackageCommands/ShowTemplates.swift | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 4488d30da57..e97cd9239a0 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -203,7 +203,7 @@ extension SwiftPackageCommand { //will need to revisit this let arguments = [ - "/Users/johnbute/Desktop/swift-pm-template/.build/arm64-apple-macosx/debug/swift-package", "plugin", template, "--allow-network-connections","local:1200", + CommandLine.arguments[0], "plugin", template, "--allow-network-connections","local:1200", "--", "--experimental-dump-help" ] let process = AsyncProcess(arguments: arguments) diff --git a/Sources/Commands/PackageCommands/ShowExecutables.swift b/Sources/Commands/PackageCommands/ShowExecutables.swift index c1e50248b19..3195c2780cb 100644 --- a/Sources/Commands/PackageCommands/ShowExecutables.swift +++ b/Sources/Commands/PackageCommands/ShowExecutables.swift @@ -34,6 +34,8 @@ struct ShowExecutables: AsyncSwiftCommand { let executables = packageGraph.allProducts.filter({ $0.type == .executable || $0.type == .snippet + }).filter({ + $0.modules.allSatisfy( {$0.type != .template}) }).map { product -> Executable in if !rootPackages.contains(product.packageIdentity) { return Executable(package: product.packageIdentity.description, name: product.name) diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index e72298b62ac..023744c4478 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -33,7 +33,7 @@ struct ShowTemplates: AsyncSwiftCommand { let rootPackages = packageGraph.rootPackages.map { $0.identity } let templates = packageGraph.allModules.filter({ - $0.type == .template || $0.type == .snippet + $0.type == .template }).map { module -> Template in if !rootPackages.contains(module.packageIdentity) { return Template(package: module.packageIdentity.description, name: module.name) From 9079977ff511c58d56982d9f07d1ead62858e06a Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Fri, 6 Jun 2025 15:45:04 -0400 Subject: [PATCH 034/225] Use in-process API to run experimental dump help and decode JSON --- Sources/Commands/PackageCommands/Init.swift | 217 ++++++++++++++---- .../Commands/Utilities/PluginDelegate.swift | 15 +- 2 files changed, 186 insertions(+), 46 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 4488d30da57..120fc2b80a7 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -193,50 +193,23 @@ extension SwiftPackageCommand { print("TODO: Handle Registry template") } - /* - let parsedOptions = try PluginCommand.PluginOptions.parse(["--allow-writing-to-package-directory"]) - - - try await PluginCommand.run(command: template, options: parsedOptions, arguments: ["--","--experimental-dump-help"], swiftCommandState: swiftCommandState) - - */ - - //will need to revisit this - let arguments = [ - "/Users/johnbute/Desktop/swift-pm-template/.build/arm64-apple-macosx/debug/swift-package", "plugin", template, "--allow-network-connections","local:1200", - "--", "--experimental-dump-help" - ] - let process = AsyncProcess(arguments: arguments) - - try process.launch() - - let processResult = try await process.waitUntilExit() - - - guard processResult.exitStatus == .terminated(code: 0) else { - throw try StringError(processResult.utf8stderrOutput()) - } - - - switch processResult.output { - case .success(let outputBytes): - let outputString = String(decoding: outputBytes, as: UTF8.self) - - - guard let data = outputString.data(using: .utf8) else { - fatalError("Could not convert output string to Data") - } + let packageGraph = try await swiftCommandState.loadPackageGraph() + let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) + + let output = try await Self.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages[packageGraph.rootPackages.startIndex], + packageGraph: packageGraph, + //allowNetworkConnections: [.local(ports: [1200])], + arguments: ["--experimental-dump-help"], + swiftCommandState: swiftCommandState + ) - do { - let schema = try JSONDecoder().decode(ToolInfoV0.self, from: data) - } - - case .failure(let error): - print("Failed to get output:", error) + do { + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + print("OUTPUT: \(toolInfo)") } - - } else { var supportedTestingLibraries = Set() @@ -267,6 +240,168 @@ extension SwiftPackageCommand { } } + static func run( + plugin: ResolvedModule, + package: ResolvedPackage, + packageGraph: ModulesGraph, + allowNetworkConnections: [SandboxNetworkPermission] = [], + arguments: [String], + swiftCommandState: SwiftCommandState + ) async throws -> Data { + let pluginTarget = plugin.underlying as! PluginModule + + // The `plugins` directory is inside the workspace's main data directory, and contains all temporary files related to this plugin in the workspace. + let pluginsDir = try swiftCommandState.getActiveWorkspace().location.pluginWorkingDirectory + .appending(component: plugin.name) + + // The `cache` directory is in the plugin’s directory and is where the plugin script runner caches compiled plugin binaries and any other derived information for this plugin. + let pluginScriptRunner = try swiftCommandState.getPluginScriptRunner( + customPluginsDir: pluginsDir + ) + + // The `outputs` directory contains subdirectories for each combination of package and command plugin. Each usage of a plugin has an output directory that is writable by the plugin, where it can write additional files, and to which it can configure tools to write their outputs, etc. + let outputDir = pluginsDir.appending("outputs") + + // Determine the set of directories under which plugins are allowed to write. We always include the output directory. + var writableDirectories = [outputDir] + + // FIXME: decide whether this permission needs to be explicitly requested by the plugin target, or not + print("WRITABLE DIRECTORY: \(package.path)") + writableDirectories.append(package.path) + + var allowNetworkConnections = allowNetworkConnections + + // If the plugin requires permissions, we ask the user for approval. + if case .command(_, let permissions) = pluginTarget.capability { + try permissions.forEach { + let permissionString: String + let reasonString: String + let remedyOption: String + + switch $0 { + case .writeToPackageDirectory(let reason): + //guard !options.allowWritingToPackageDirectory else { return } // permission already granted + permissionString = "write to the package directory" + reasonString = reason + remedyOption = "--allow-writing-to-package-directory" + case .allowNetworkConnections(let scope, let reason): + guard scope != .none else { return } // no need to prompt + //guard options.allowNetworkConnections != .init(scope) else { return } // permission already granted + + switch scope { + case .all, .local: + let portsString = scope.ports + .isEmpty ? "on all ports" : + "on ports: \(scope.ports.map { "\($0)" }.joined(separator: ", "))" + permissionString = "allow \(scope.label) network connections \(portsString)" + case .docker, .unixDomainSocket: + permissionString = "allow \(scope.label) connections" + case .none: + permissionString = "" // should not be reached + } + + reasonString = reason + // FIXME compute the correct reason for the type of network connection + remedyOption = + "--allow-network-connections 'Network connection is needed'" + } + + let problem = "Plugin ‘\(plugin.name)’ wants permission to \(permissionString)." + let reason = "Stated reason: “\(reasonString)”." + if swiftCommandState.outputStream.isTTY { + // We can ask the user directly, so we do so. + let query = "Allow this plugin to \(permissionString)?" + swiftCommandState.outputStream.write("\(problem)\n\(reason)\n\(query) (yes/no) ".utf8) + swiftCommandState.outputStream.flush() + let answer = readLine(strippingNewline: true) + // Throw an error if we didn't get permission. + if answer?.lowercased() != "yes" { + throw StringError("Plugin was denied permission to \(permissionString).") + } + } else { + // We can't ask the user, so emit an error suggesting passing the flag. + let remedy = "Use `\(remedyOption)` to allow this." + throw StringError([problem, reason, remedy].joined(separator: "\n")) + } + + switch $0 { + case .writeToPackageDirectory: + // Otherwise append the directory to the list of allowed ones. + writableDirectories.append(package.path) + case .allowNetworkConnections(let scope, _): + allowNetworkConnections.append(.init(scope)) + } + } + } + + // Make sure that the package path is read-only unless it's covered by any of the explicitly writable directories. + let readOnlyDirectories = writableDirectories + .contains { package.path.isDescendantOfOrEqual(to: $0) } ? [] : [package.path] + + // Use the directory containing the compiler as an additional search directory, and add the $PATH. + let toolSearchDirs = [try swiftCommandState.getTargetToolchain().swiftCompilerPath.parentDirectory] + + getEnvSearchPaths(pathString: Environment.current[.path], currentWorkingDirectory: .none) + + let buildParameters = try swiftCommandState.toolsBuildParameters + // Build or bring up-to-date any executable host-side tools on which this plugin depends. Add them and any binary dependencies to the tool-names-to-path map. + let buildSystem = try await swiftCommandState.createBuildSystem( + explicitBuildSystem: .native, + traitConfiguration: .init(), + cacheBuildManifest: false, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: buildParameters, + packageGraphLoader: { packageGraph } + ) + + let accessibleTools = try await plugin.preparePluginTools( + fileSystem: swiftCommandState.fileSystem, + environment: buildParameters.buildEnvironment, + for: try pluginScriptRunner.hostTriple + ) { name, _ in + // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies are not supported within a package, so if the tool happens to be from the same package, we instead find the executable that corresponds to the product. There is always one, because of autogeneration of implicit executables with the same name as the target if there isn't an explicit one. + try await buildSystem.build(subset: .product(name, for: .host)) + if let builtTool = try buildSystem.buildPlan.buildProducts.first(where: { + $0.product.name == name && $0.buildParameters.destination == .host + }) { + return try builtTool.binaryPath + } else { + return nil + } + } + + // Set up a delegate to handle callbacks from the command plugin. + let pluginDelegate = PluginDelegate(swiftCommandState: swiftCommandState, plugin: pluginTarget, echoOutput: false) + let delegateQueue = DispatchQueue(label: "plugin-invocation") + + // Run the command plugin. + + // TODO: use region based isolation when swift 6 is available + let writableDirectoriesCopy = writableDirectories + let allowNetworkConnectionsCopy = allowNetworkConnections + + let buildEnvironment = buildParameters.buildEnvironment + try await pluginTarget.invoke( + action: .performCommand(package: package, arguments: arguments), + buildEnvironment: buildEnvironment, + scriptRunner: pluginScriptRunner, + workingDirectory: swiftCommandState.originalWorkingDirectory, + outputDirectory: outputDir, + toolSearchDirectories: toolSearchDirs, + accessibleTools: accessibleTools, + writableDirectories: writableDirectoriesCopy, + readOnlyDirectories: readOnlyDirectories, + allowNetworkConnections: allowNetworkConnectionsCopy, + pkgConfigDirectories: swiftCommandState.options.locations.pkgConfigDirectories, + sdkRootPath: buildParameters.toolchain.sdkRootPath, + fileSystem: swiftCommandState.fileSystem, + modulesGraph: packageGraph, + observabilityScope: swiftCommandState.observabilityScope, + callbackQueue: delegateQueue, + delegate: pluginDelegate + ) + + return pluginDelegate.lineBufferedOutput + } private func captureStdout(_ block: () async throws -> Void) async throws -> String { let originalStdout = dup(fileno(stdout)) diff --git a/Sources/Commands/Utilities/PluginDelegate.swift b/Sources/Commands/Utilities/PluginDelegate.swift index 87b0c0f7123..b0048561cbc 100644 --- a/Sources/Commands/Utilities/PluginDelegate.swift +++ b/Sources/Commands/Utilities/PluginDelegate.swift @@ -27,11 +27,13 @@ final class PluginDelegate: PluginInvocationDelegate { let swiftCommandState: SwiftCommandState let plugin: PluginModule var lineBufferedOutput: Data + let echoOutput: Bool - init(swiftCommandState: SwiftCommandState, plugin: PluginModule) { + init(swiftCommandState: SwiftCommandState, plugin: PluginModule, echoOutput: Bool = true) { self.swiftCommandState = swiftCommandState self.plugin = plugin self.lineBufferedOutput = Data() + self.echoOutput = echoOutput } func pluginCompilationStarted(commandLine: [String], environment: [String: String]) { @@ -45,10 +47,13 @@ final class PluginDelegate: PluginInvocationDelegate { func pluginEmittedOutput(_ data: Data) { lineBufferedOutput += data - while let newlineIdx = lineBufferedOutput.firstIndex(of: UInt8(ascii: "\n")) { - let lineData = lineBufferedOutput.prefix(upTo: newlineIdx) - print(String(decoding: lineData, as: UTF8.self)) - lineBufferedOutput = lineBufferedOutput.suffix(from: newlineIdx.advanced(by: 1)) + + if echoOutput { + while let newlineIdx = lineBufferedOutput.firstIndex(of: UInt8(ascii: "\n")) { + let lineData = lineBufferedOutput.prefix(upTo: newlineIdx) + print(String(decoding: lineData, as: UTF8.self)) + lineBufferedOutput = lineBufferedOutput.suffix(from: newlineIdx.advanced(by: 1)) + } } } From 50890f82b24d9156ce19c5cf47d7b4069570b416 Mon Sep 17 00:00:00 2001 From: John Bute Date: Sat, 7 Jun 2025 11:55:41 -0400 Subject: [PATCH 035/225] fixtures update --- Fixtures/Miscellaneous/ShowTemplates/app/Package.swift | 4 ++-- .../app/Plugins/{looPlugin => kooPlugin}/plugin.swift | 2 +- .../ShowTemplates/app/Templates/{loo => koo}/Template.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename Fixtures/Miscellaneous/ShowTemplates/app/Plugins/{looPlugin => kooPlugin}/plugin.swift (91%) rename Fixtures/Miscellaneous/ShowTemplates/app/Templates/{loo => koo}/Template.swift (95%) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift index 13ffaf45b03..b6704e45506 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "Dealer", - products: Product.template(name: "loo") + products: Product.template(name: "koo") , dependencies: [ @@ -12,7 +12,7 @@ let package = Package( ], targets: Target.template( - name: "loo", + name: "koo", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "SystemPackage", package: "swift-system") diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/kooPlugin/plugin.swift similarity index 91% rename from Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Plugins/kooPlugin/plugin.swift index eff35a92b01..0c66b436fc8 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/kooPlugin/plugin.swift @@ -17,7 +17,7 @@ struct TemplatePlugin: CommandPlugin { context: PluginContext, arguments: [String] ) async throws { - let tool = try context.tool(named: "loo") + let tool = try context.tool(named: "koo") let process = Process() process.executableURL = URL(filePath: tool.url.path()) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/loo/Template.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/koo/Template.swift similarity index 95% rename from Fixtures/Miscellaneous/ShowTemplates/app/Templates/loo/Template.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Templates/koo/Template.swift index 24d9af341c4..e310f9a1e3d 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/loo/Template.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/koo/Template.swift @@ -31,7 +31,7 @@ struct HelloTemplateTool: ParsableCommand { let rootDir = FilePath(fs.currentDirectoryPath) - let mainFile = rootDir / "Soures" / name / "main.swift" + let mainFile = rootDir / "Generated" / name / "main.swift" try fs.createDirectory(atPath: mainFile.removingLastComponent().string, withIntermediateDirectories: true) From d2cdc43382c9800d19abef88418c7e0339264d57 Mon Sep 17 00:00:00 2001 From: John Bute Date: Sat, 7 Jun 2025 11:56:37 -0400 Subject: [PATCH 036/225] Updating package.swift to include Argumnetparser dependency for workspace --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index d567e538aea..43186f0aded 100644 --- a/Package.swift +++ b/Package.swift @@ -530,6 +530,7 @@ let package = Package( "SourceControl", "SPMBuildCore", .product(name: "OrderedCollections", package: "swift-collections"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), "PackageModelSyntax", ] + swiftSyntaxDependencies(["SwiftIDEUtils", "SwiftParser"]), exclude: ["CMakeLists.txt"], From 0b6eec2aed92b0b53297ecbaab044ccc5775a131 Mon Sep 17 00:00:00 2001 From: John Bute Date: Sat, 7 Jun 2025 12:01:34 -0400 Subject: [PATCH 037/225] argument parsing dump-help --- Sources/Commands/PackageCommands/Init.swift | 113 +++++++++----------- Sources/Workspace/InitTemplatePackage.swift | 108 ++++++++++++++++++- 2 files changed, 156 insertions(+), 65 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 4488d30da57..31a5490c9f1 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -180,63 +180,76 @@ extension SwiftPackageCommand { do { try await buildSystem.build(subset: subset) } catch _ as Diagnostics { - throw ExitCode.failure + throw ExitCode.failure + } } - } + /* + let parsedOptions = try PluginCommand.PluginOptions.parse(["--allow-writing-to-package-directory"]) - case .git: - // Implement or call your Git-based template handler - print("TODO: Handle Git template") - case .registry: - // Implement or call your Registry-based template handler - print("TODO: Handle Registry template") - } - /* - let parsedOptions = try PluginCommand.PluginOptions.parse(["--allow-writing-to-package-directory"]) + try await PluginCommand.run(command: template, options: parsedOptions, arguments: ["--","--experimental-dump-help"], swiftCommandState: swiftCommandState) + + */ + //will need to revisit this + let arguments = [ + "/Users/johnbute/Desktop/swift-pm-template/.build/arm64-apple-macosx/debug/swift-package", "plugin", template, "--allow-network-connections","local:1200", + "--", "--experimental-dump-help" + ] + let process = AsyncProcess(arguments: arguments) - try await PluginCommand.run(command: template, options: parsedOptions, arguments: ["--","--experimental-dump-help"], swiftCommandState: swiftCommandState) + try process.launch() - */ + let processResult = try await process.waitUntilExit() - //will need to revisit this - let arguments = [ - "/Users/johnbute/Desktop/swift-pm-template/.build/arm64-apple-macosx/debug/swift-package", "plugin", template, "--allow-network-connections","local:1200", - "--", "--experimental-dump-help" - ] - let process = AsyncProcess(arguments: arguments) - try process.launch() + guard processResult.exitStatus == .terminated(code: 0) else { + throw try StringError(processResult.utf8stderrOutput()) + } - let processResult = try await process.waitUntilExit() + switch processResult.output { + case .success(let outputBytes): + let outputString = String(decoding: outputBytes, as: UTF8.self) - guard processResult.exitStatus == .terminated(code: 0) else { - throw try StringError(processResult.utf8stderrOutput()) - } + guard let data = outputString.data(using: .utf8) else { + fatalError("Could not convert output string to Data") + } + + do { + let schema = try JSONDecoder().decode(ToolInfoV0.self, from: data) + let response = try initTemplatePackage.promptUser(tool: schema) - switch processResult.output { - case .success(let outputBytes): - let outputString = String(decoding: outputBytes, as: UTF8.self) + let parsedOptions = try PluginCommand.PluginOptions.parse(["--allow-writing-to-package-directory"]) - guard let data = outputString.data(using: .utf8) else { - fatalError("Could not convert output string to Data") - } - - do { - let schema = try JSONDecoder().decode(ToolInfoV0.self, from: data) - } - case .failure(let error): - print("Failed to get output:", error) + try await PluginCommand.run(command: template, options: parsedOptions, arguments: response, swiftCommandState: swiftCommandState) + + } catch { + print(error) + } + + case .failure(let error): + print("Failed to get output:", error) + } + + + + + case .git: + // Implement or call your Git-based template handler + print("TODO: Handle Git template") + case .registry: + // Implement or call your Registry-based template handler + print("TODO: Handle Registry template") } + } else { var supportedTestingLibraries = Set() @@ -267,34 +280,6 @@ extension SwiftPackageCommand { } } - - private func captureStdout(_ block: () async throws -> Void) async throws -> String { - let originalStdout = dup(fileno(stdout)) - - let pipe = Pipe() - let readHandle = pipe.fileHandleForReading - let writeHandle = pipe.fileHandleForWriting - - dup2(writeHandle.fileDescriptor, fileno(stdout)) - - - var output = "" - let outputQueue = DispatchQueue(label: "outputQueue") - let group = DispatchGroup() - group.enter() - - outputQueue.async { - let data = readHandle.readDataToEndOfFile() - output = String(data: data, encoding: .utf8) ?? "" - } - - - fflush(stdout) - writeHandle.closeFile() - - dup2(originalStdout, fileno(stdout)) - return output - } // first save current activeWorkspace //second switch activeWorkspace to the template Path //third revert after conditions have been checked, (we will also get stuff needed for dpeende diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index f250164f31e..8d83ec84ff8 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -18,7 +18,7 @@ import System import PackageModelSyntax import TSCBasic import SwiftParser - +import ArgumentParserToolInfo public final class InitTemplatePackage { @@ -147,12 +147,114 @@ public final class InitTemplatePackage { try editResult.applyEdits(to: fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: false) } + + public func promptUser(tool: ToolInfoV0) throws -> [String] { + let arguments = try convertArguments(from: tool.command) + + let responses = UserPrompter.prompt(for: arguments) + + let commandLine = buildCommandLine(from: responses) + + return commandLine + } + + private func convertArguments(from command: CommandInfoV0) throws -> [ArgumentInfoV0] { + guard let rawArgs = command.arguments else { + throw TemplateError.noArguments + } + return rawArgs + } + + + private struct UserPrompter { + + static func prompt(for arguments: [ArgumentInfoV0]) -> [ArgumentResponse] { + return arguments + .filter { $0.valueName != "help" } + .map { arg in + let defaultText = arg.defaultValue.map { " (default: \($0))" } ?? "" + let promptMessage = "\(arg.abstract ?? "")\(defaultText):" + + var values: [String] = [] + + switch arg.kind { + case .flag: + let confirmed = promptForConfirmation(prompt: promptMessage, + defaultBehavior: arg.defaultValue?.lowercased() == "true") + values = [confirmed ? "true" : "false"] + + case .option, .positional: + print(promptMessage) + + if arg.isRepeating { + while let input = readLine(), !input.isEmpty { + values.append(input) + } + if values.isEmpty, let def = arg.defaultValue { + values = [def] + } + } else { + let input = readLine() + if let input = input, !input.isEmpty { + values = [input] + } else if let def = arg.defaultValue { + values = [def] + } else if arg.isOptional == false { + fatalError("Required argument '\(arg.valueName)' not provided.") + } + } + } + + return ArgumentResponse(argument: arg, values: values) + } + } + } + func buildCommandLine(from responses: [ArgumentResponse]) -> [String] { + return responses.flatMap(\.commandLineFragments) + } + + + + private static func promptForConfirmation(prompt: String, defaultBehavior: Bool?) -> Bool { + let suffix = defaultBehavior == true ? " [Y/n]" : defaultBehavior == false ? " [y/N]" : " [y/n]" + print(prompt + suffix, terminator: " ") + guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + return defaultBehavior ?? false + } + + switch input { + case "y", "yes": return true + case "n", "no": return false + default: return defaultBehavior ?? false + } + } + + struct ArgumentResponse { + let argument: ArgumentInfoV0 + let values: [String] + + var commandLineFragments: [String] { + guard let name = argument.valueName else { + return values + } + + switch argument.kind { + case .flag: + return values.first == "true" ? ["--\(name)"] : [] + case .option: + return values.flatMap { ["--\(name)", $0] } + case .positional: + return values + } + } + } } private enum TemplateError: Swift.Error { case invalidPath case manifestAlreadyExists + case noArguments } @@ -163,6 +265,10 @@ extension TemplateError: CustomStringConvertible { return "a manifest file already exists in this directory" case .invalidPath: return "Path does not exist, or is invalid." + case .noArguments: + return "Template has no arguments" } } } + + From e2c8172c3fa4b2f6d7d824905ccf78de9378e9bf Mon Sep 17 00:00:00 2001 From: John Bute Date: Mon, 9 Jun 2025 10:28:01 -0400 Subject: [PATCH 038/225] running the executable --- .../ShowTemplates/app/Package.swift | 15 +- .../{kooPlugin => dooPlugin}/plugin.swift | 2 +- .../app/Templates/{koo => doo}/Template.swift | 2 + Sources/Commands/PackageCommands/Init.swift | 220 ++++++++---------- Sources/PackageDescription/Product.swift | 2 +- 5 files changed, 99 insertions(+), 142 deletions(-) rename Fixtures/Miscellaneous/ShowTemplates/app/Plugins/{kooPlugin => dooPlugin}/plugin.swift (91%) rename Fixtures/Miscellaneous/ShowTemplates/app/Templates/{koo => doo}/Template.swift (97%) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift index 77d2a34dfb3..7680cba36ec 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -3,25 +3,14 @@ import PackageDescription let package = Package( name: "Dealer", -<<<<<<< HEAD - products: Product.template(name: "koo") - , - -======= - products: .template(name: "loo"), ->>>>>>> origin/inbetween + products: Product.template(name: "doo"), dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2") ], -<<<<<<< HEAD targets: Target.template( - name: "koo", -======= - targets: .template( - name: "loo", ->>>>>>> origin/inbetween + name: "doo", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "SystemPackage", package: "swift-system") diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/kooPlugin/plugin.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/dooPlugin/plugin.swift similarity index 91% rename from Fixtures/Miscellaneous/ShowTemplates/app/Plugins/kooPlugin/plugin.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Plugins/dooPlugin/plugin.swift index 0c66b436fc8..00950ba3014 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/kooPlugin/plugin.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/dooPlugin/plugin.swift @@ -17,7 +17,7 @@ struct TemplatePlugin: CommandPlugin { context: PluginContext, arguments: [String] ) async throws { - let tool = try context.tool(named: "koo") + let tool = try context.tool(named: "doo") let process = Process() process.executableURL = URL(filePath: tool.url.path()) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/koo/Template.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/doo/Template.swift similarity index 97% rename from Fixtures/Miscellaneous/ShowTemplates/app/Templates/koo/Template.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Templates/doo/Template.swift index e310f9a1e3d..f5dec1c76bc 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/koo/Template.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/doo/Template.swift @@ -27,6 +27,8 @@ struct HelloTemplateTool: ParsableCommand { //entrypoint of the template executable, that generates just a main.swift and a readme.md func run() throws { + + print("we got here") let fs = FileManager.default let rootDir = FilePath(fs.currentDirectoryPath) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 1d96cfefeb9..ffa1e68f289 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -105,6 +105,30 @@ extension SwiftPackageCommand { // prompt user // run the executable with the command line stuff + /// Returns the resolved template path for a given template source. + func resolveTemplatePath() async throws -> Basics.AbsolutePath { + switch templateType { + case .local: + guard let path = templateDirectory else { + throw InternalError("Template path must be specified for local templates.") + } + return path + + case .git: + // TODO: Cache logic and smarter hashing + throw StringError("git-based templates not yet implemented") + + case .registry: + // TODO: Lookup and download from registry + throw StringError("Registry-based templates not yet implemented") + + case .none: + throw InternalError("Missing --template-type for --template") + } + } + + + //first, func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { @@ -117,151 +141,87 @@ extension SwiftPackageCommand { // For macros this is reversed, since we don't support testing // macros with Swift Testing yet. if useTemplates { - guard let type = templateType else { - throw InternalError("Template path must be specified when using the local template type.") - } - - switch type { - case .local: - - guard let templatePath = templateDirectory else { - throw InternalError("Template path must be specified when using the local template type.") - } + let resolvedTemplatePath = try await resolveTemplatePath() - /// Get the package initialization type based on templateInitializationOptions and check for if the template called is valid. - let templateInitType = try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { workspace, root in - return try await checkConditions(swiftCommandState) - } + let templateInitType = try await swiftCommandState.withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { workspace, root in + return try await checkConditions(swiftCommandState) + } - var supportedTemplateTestingLibraries = Set() - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (templateInitType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { - supportedTemplateTestingLibraries.insert(.xctest) - } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (templateInitType != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { - supportedTemplateTestingLibraries.insert(.swiftTesting) - } + var supportedTemplateTestingLibraries = Set() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (templateInitType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTemplateTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (templateInitType != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTemplateTestingLibraries.insert(.swiftTesting) + } - let initTemplatePackage = try InitTemplatePackage( - name: packageName, - templateName: template, - initMode: type, - templatePath: templatePath, - fileSystem: swiftCommandState.fileSystem, - packageType: templateInitType, - supportedTestingLibraries: supportedTemplateTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, + let initTemplatePackage = try InitTemplatePackage( + name: packageName, + templateName: template, + initMode: templateType ?? .local, + templatePath: resolvedTemplatePath, + fileSystem: swiftCommandState.fileSystem, + packageType: templateInitType, + supportedTestingLibraries: supportedTemplateTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration + ) + try initTemplatePackage.setupTemplateManifest() + + // Build system setup + let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + try await swiftCommandState.createBuildSystem( + explicitProduct: buildOptions.product, + traitConfiguration: .init(traitOptions: buildOptions.traits), + shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: swiftCommandState.toolsBuildParameters, + outputStream: TSCBasic.stdoutStream ) - try initTemplatePackage.setupTemplateManifest() - - let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { workspace, root in - - try await swiftCommandState.createBuildSystem( - explicitProduct: buildOptions.product, - traitConfiguration: .init(traitOptions: buildOptions.traits), - shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: swiftCommandState.toolsBuildParameters, - // command result output goes on stdout - // ie "swift build" should output to stdout - outputStream: TSCBasic.stdoutStream - ) + } - } + guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { + throw ExitCode.failure + } - guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { + try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + do { + try await buildSystem.build(subset: subset) + } catch _ as Diagnostics { throw ExitCode.failure } - - let _ = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { workspace, root in - do { - try await buildSystem.build(subset: subset) - } catch _ as Diagnostics { - throw ExitCode.failure - } - } - - /* - let parsedOptions = try PluginCommand.PluginOptions.parse(["--allow-writing-to-package-directory"]) - - - try await PluginCommand.run(command: template, options: parsedOptions, arguments: ["--","--experimental-dump-help"], swiftCommandState: swiftCommandState) - - */ - - //will need to revisit this - let arguments = [ - "/Users/johnbute/Desktop/swift-pm-template/.build/arm64-apple-macosx/debug/swift-package", "plugin", template, "--allow-network-connections","local:1200", - "--", "--experimental-dump-help" - ] - let process = AsyncProcess(arguments: arguments) - - try process.launch() - - let processResult = try await process.waitUntilExit() - - - guard processResult.exitStatus == .terminated(code: 0) else { - throw try StringError(processResult.utf8stderrOutput()) - } - - - switch processResult.output { - case .success(let outputBytes): - let outputString = String(decoding: outputBytes, as: UTF8.self) - - - guard let data = outputString.data(using: .utf8) else { - fatalError("Could not convert output string to Data") - } - - do { - let schema = try JSONDecoder().decode(ToolInfoV0.self, from: data) - let response = try initTemplatePackage.promptUser(tool: schema) - - - let parsedOptions = try PluginCommand.PluginOptions.parse(["--allow-writing-to-package-directory"]) - - - try await PluginCommand.run(command: template, options: parsedOptions, arguments: response, swiftCommandState: swiftCommandState) - - } catch { - print(error) - } - - case .failure(let error): - print("Failed to get output:", error) - } - - - - - case .git: - // Implement or call your Git-based template handler - print("TODO: Handle Git template") - case .registry: - // Implement or call your Registry-based template handler - print("TODO: Handle Registry template") } let packageGraph = try await swiftCommandState.loadPackageGraph() let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) + + let output = try await Self.run( plugin: matchingPlugins[0], - package: packageGraph.rootPackages[packageGraph.rootPackages.startIndex], + package: packageGraph.rootPackages.first!, packageGraph: packageGraph, - //allowNetworkConnections: [.local(ports: [1200])], - arguments: ["--experimental-dump-help"], + arguments: ["--", "--experimental-dump-help"], swiftCommandState: swiftCommandState ) - + + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + let response = try initTemplatePackage.promptUser(tool: toolInfo) do { - let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - print("OUTPUT: \(toolInfo)") + + let _ = try await Self.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: response, + swiftCommandState: swiftCommandState, + shouldPrint: true + ) + + } } else { @@ -321,7 +281,6 @@ extension SwiftPackageCommand { var writableDirectories = [outputDir] // FIXME: decide whether this permission needs to be explicitly requested by the plugin target, or not - print("WRITABLE DIRECTORY: \(package.path)") writableDirectories.append(package.path) var allowNetworkConnections = allowNetworkConnections @@ -434,12 +393,19 @@ extension SwiftPackageCommand { let writableDirectoriesCopy = writableDirectories let allowNetworkConnectionsCopy = allowNetworkConnections + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") + } + + guard let workingDirectory = swiftCommandState.options.locations.packageDirectory ?? swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find current working directory") + } let buildEnvironment = buildParameters.buildEnvironment try await pluginTarget.invoke( action: .performCommand(package: package, arguments: arguments), buildEnvironment: buildEnvironment, scriptRunner: pluginScriptRunner, - workingDirectory: swiftCommandState.originalWorkingDirectory, + workingDirectory: workingDirectory, outputDirectory: outputDir, toolSearchDirectories: toolSearchDirs, accessibleTools: accessibleTools, diff --git a/Sources/PackageDescription/Product.swift b/Sources/PackageDescription/Product.swift index 1df7491021f..05df231980e 100644 --- a/Sources/PackageDescription/Product.swift +++ b/Sources/PackageDescription/Product.swift @@ -207,7 +207,7 @@ public class Product { public extension [Product] { @available(_PackageDescription, introduced: 999.0.0) - public static func template( + static func template( name: String, ) -> [Product] { let templatePluginName = "\(name)Plugin" From 4f89b89813cf99475581ddcdf2b919b5937ad062 Mon Sep 17 00:00:00 2001 From: John Bute Date: Mon, 9 Jun 2025 13:07:15 -0400 Subject: [PATCH 039/225] fixed syntax issues + added functionality for arguments with enums --- Sources/Commands/PackageCommands/Init.swift | 3 +-- Sources/Workspace/InitTemplatePackage.swift | 13 +++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index ffa1e68f289..ca2dfc82ba8 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -217,8 +217,7 @@ extension SwiftPackageCommand { package: packageGraph.rootPackages.first!, packageGraph: packageGraph, arguments: response, - swiftCommandState: swiftCommandState, - shouldPrint: true + swiftCommandState: swiftCommandState ) diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 8d83ec84ff8..2a8a4a50ba1 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -170,10 +170,11 @@ public final class InitTemplatePackage { static func prompt(for arguments: [ArgumentInfoV0]) -> [ArgumentResponse] { return arguments - .filter { $0.valueName != "help" } + .filter { $0.valueName != "help" && $0.shouldDisplay != false } .map { arg in let defaultText = arg.defaultValue.map { " (default: \($0))" } ?? "" - let promptMessage = "\(arg.abstract ?? "")\(defaultText):" + let allValuesText = (arg.allValues?.isEmpty == false) ? " [\(arg.allValues!.joined(separator: ", "))]" : "" + let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(defaultText):" var values: [String] = [] @@ -188,6 +189,10 @@ public final class InitTemplatePackage { if arg.isRepeating { while let input = readLine(), !input.isEmpty { + if let allowed = arg.allValues, !allowed.contains(input) { + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + continue + } values.append(input) } if values.isEmpty, let def = arg.defaultValue { @@ -196,6 +201,10 @@ public final class InitTemplatePackage { } else { let input = readLine() if let input = input, !input.isEmpty { + if let allowed = arg.allValues, !allowed.contains(input) { + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + exit(1) + } values = [input] } else if let def = arg.defaultValue { values = [def] From 7b798636889baf85f93673f56151576b9bfe8d67 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Tue, 10 Jun 2025 11:24:17 -0400 Subject: [PATCH 040/225] Remove template target and product types and use the template init options instead --- .../ProductBuildDescription.swift | 6 +- .../SwiftModuleBuildDescription.swift | 4 +- .../LLBuildManifestBuilder+Clang.swift | 2 +- .../LLBuildManifestBuilder+Product.swift | 7 +- .../LLBuildManifestBuilder+Swift.swift | 6 +- Sources/Build/BuildOperation.swift | 2 +- .../Build/BuildPlan/BuildPlan+Product.swift | 4 +- Sources/Build/BuildPlan/BuildPlan+Test.swift | 3 +- Sources/Build/BuildPlan/BuildPlan.swift | 4 +- .../Commands/PackageCommands/AddProduct.swift | 2 - .../Commands/PackageCommands/AddTarget.swift | 4 +- Sources/Commands/PackageCommands/Init.swift | 8 +- .../PackageCommands/ShowExecutables.swift | 2 +- .../PackageCommands/ShowTemplates.swift | 2 +- Sources/Commands/Snippets/Cards/TopCard.swift | 2 - Sources/Commands/SwiftBuildCommand.swift | 1 - .../Commands/Utilities/PluginDelegate.swift | 2 +- .../PackageDescriptionSerialization.swift | 2 - ...geDescriptionSerializationConversion.swift | 13 ---- Sources/PackageDescription/Product.swift | 16 +--- Sources/PackageDescription/Target.swift | 28 ++++--- .../PackageGraph/ModulesGraph+Loading.swift | 2 +- Sources/PackageGraph/ModulesGraph.swift | 2 +- .../Resolution/ResolvedProduct.swift | 2 +- Sources/PackageLoading/Diagnostics.swift | 2 +- .../PackageLoading/ManifestJSONParser.swift | 4 +- Sources/PackageLoading/ManifestLoader.swift | 2 +- Sources/PackageLoading/PackageBuilder.swift | 73 +++++------------- .../Manifest/TargetDescription.swift | 76 ++++++++----------- .../ManifestSourceGeneration.swift | 4 - .../PackageModel/Module/BinaryModule.swift | 3 +- Sources/PackageModel/Module/ClangModule.swift | 6 +- Sources/PackageModel/Module/Module.swift | 10 ++- .../PackageModel/Module/PluginModule.swift | 3 +- Sources/PackageModel/Module/SwiftModule.swift | 18 +++-- .../Module/SystemLibraryModule.swift | 3 +- Sources/PackageModel/Product.swift | 16 +--- Sources/PackageModel/ToolsVersion.swift | 2 +- Sources/PackageModelSyntax/AddTarget.swift | 4 +- .../ProductDescription+Syntax.swift | 1 - .../TargetDescription+Syntax.swift | 1 - .../BuildParameters/BuildParameters.swift | 6 -- .../Plugins/PluginContextSerializer.swift | 2 +- .../Plugins/PluginInvocation.swift | 2 +- .../PackagePIFBuilder+Helpers.swift | 13 ++-- .../SwiftBuildSupport/PackagePIFBuilder.swift | 10 +-- .../PackagePIFProjectBuilder+Modules.swift | 6 -- .../PackagePIFProjectBuilder+Products.swift | 3 - Sources/XCBuildSupport/PIFBuilder.swift | 6 +- .../ResolvedModule+Mock.swift | 3 +- .../ClangTargetBuildDescriptionTests.swift | 3 +- Tests/FunctionalTests/PluginTests.swift | 12 +-- 52 files changed, 146 insertions(+), 274 deletions(-) diff --git a/Sources/Build/BuildDescription/ProductBuildDescription.swift b/Sources/Build/BuildDescription/ProductBuildDescription.swift index b9c9e78249e..e0645775233 100644 --- a/Sources/Build/BuildDescription/ProductBuildDescription.swift +++ b/Sources/Build/BuildDescription/ProductBuildDescription.swift @@ -222,7 +222,7 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription args += ["-Xlinker", "-install_name", "-Xlinker", relativePath] } args += self.deadStripArguments - case .executable, .snippet, .template: //john-to-revisit + case .executable, .snippet: // Link the Swift stdlib statically, if requested. // TODO: unify this logic with SwiftTargetBuildDescription.stdlibArguments if self.buildParameters.linkingParameters.shouldLinkStaticSwiftStdlib { @@ -256,8 +256,6 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription } case .plugin: throw InternalError("unexpectedly asked to generate linker arguments for a plugin product") - /*case .template: //john-to-do revist - throw InternalError("unexpectedly asked to generate linker arguments for a template product")*/ } if let resourcesPath = self.buildParameters.toolchain.swiftResourcesPath(isStatic: isLinkingStaticStdlib) { @@ -314,7 +312,7 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription switch self.product.type { case .library(let type): useStdlibRpath = type == .dynamic - case .test, .executable, .snippet, .macro, .template: //john-to-revisit + case .test, .executable, .snippet, .macro: useStdlibRpath = true case .plugin: throw InternalError("unexpectedly asked to generate linker arguments for a plugin product") diff --git a/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift b/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift index e5c55abcca9..ddad0d86498 100644 --- a/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift +++ b/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift @@ -146,7 +146,7 @@ public final class SwiftModuleBuildDescription { // If we're an executable and we're not allowing test targets to link against us, we hide the module. let triple = buildParameters.triple let allowLinkingAgainstExecutables = [.coff, .macho, .elf].contains(triple.objectFormat) && self.toolsVersion >= .v5_5 - let dirPath = ((target.type == .executable || target.type == .template) && !allowLinkingAgainstExecutables) ? self.tempsPath : self.modulesPath //john-to-revisit + let dirPath = ((target.type == .executable) && !allowLinkingAgainstExecutables) ? self.tempsPath : self.modulesPath return dirPath.appending(component: "\(self.target.c99name).swiftmodule") } @@ -201,7 +201,7 @@ public final class SwiftModuleBuildDescription { switch self.target.type { case .library, .test: return true - case .executable, .snippet, .macro, .template: //john-to-revisit + case .executable, .snippet, .macro: //john-to-revisit // This deactivates heuristics in the Swift compiler that treats single-file modules and source files // named "main.swift" specially w.r.t. whether they can have an entry point. // diff --git a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift index 845fad6cebd..34476408fff 100644 --- a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift +++ b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift @@ -46,7 +46,7 @@ extension LLBuildManifestBuilder { case .product(let product, let productDescription): switch product.type { - case .executable, .snippet, .library(.dynamic), .macro, .template: //john-to-revisit + case .executable, .snippet, .library(.dynamic), .macro: guard let productDescription else { throw InternalError("No build description for product: \(product)") } diff --git a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift index 61ff45102c9..33eb6f4558f 100644 --- a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift +++ b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift @@ -69,11 +69,6 @@ extension LLBuildManifestBuilder { buildProduct.buildParameters.debuggingParameters.shouldEnableDebuggingEntitlement { shouldCodeSign = true linkedBinaryNode = try .file(buildProduct.binaryPath, isMutated: true) - } else if case .template = buildProduct.product.type, //john-to-revisit - buildProduct.buildParameters.triple.isMacOSX, - buildProduct.buildParameters.debuggingParameters.shouldEnableDebuggingEntitlement { - shouldCodeSign = true - linkedBinaryNode = try .file(buildProduct.binaryPath, isMutated: true) } else { shouldCodeSign = false linkedBinaryNode = try .file(buildProduct.binaryPath) @@ -204,7 +199,7 @@ extension ResolvedProduct { return staticLibraryName(for: self.name, buildParameters: buildParameters) case .library(.automatic): throw InternalError("automatic library not supported") - case .executable, .snippet, .template: //john-to-revisit + case .executable, .snippet: return executableName(for: self.name, buildParameters: buildParameters) case .macro: guard let macroModule = self.modules.first else { diff --git a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift index 31f5ff1ae4e..b2679e95b5a 100644 --- a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift +++ b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift @@ -442,12 +442,12 @@ extension LLBuildManifestBuilder { } // Depend on the binary for executable targets. - if (module.type == .executable || module.type == .template) && prepareForIndexing == .off {//john-to-revisit + if module.type == .executable && prepareForIndexing == .off { // FIXME: Optimize. Build plan could build a mapping between executable modules // and their products to speed up search here, which is inefficient if the plan // contains a lot of products. if let productDescription = try plan.productMap.values.first(where: { - try ($0.product.type == .executable || $0.product.type == .template) && //john-to-revisit + try $0.product.type == .executable && $0.product.executableModule.id == module.id && $0.destination == description.destination }) { @@ -481,7 +481,7 @@ extension LLBuildManifestBuilder { case .product(let product, let productDescription): switch product.type { - case .executable, .snippet, .library(.dynamic), .macro, .template: //john-to-revisit + case .executable, .snippet, .library(.dynamic), .macro: guard let productDescription else { throw InternalError("No description for product: \(product)") } diff --git a/Sources/Build/BuildOperation.swift b/Sources/Build/BuildOperation.swift index 78e8652130b..37761bd2e38 100644 --- a/Sources/Build/BuildOperation.swift +++ b/Sources/Build/BuildOperation.swift @@ -844,7 +844,7 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS // Look for a target with the same module name as the one that's being imported. if let importedTarget = self._buildPlan?.targets.first(where: { $0.module.c99name == importedModule }) { // For the moment we just check for executables that other targets try to import. - if importedTarget.module.type == .executable || importedTarget.module.type == .template { //john-to-revisit + if importedTarget.module.type == .executable { return "module '\(importedModule)' is the main module of an executable, and cannot be imported by tests and other targets" } if importedTarget.module.type == .macro { diff --git a/Sources/Build/BuildPlan/BuildPlan+Product.swift b/Sources/Build/BuildPlan/BuildPlan+Product.swift index cd7e9e8e36b..921d508fcd4 100644 --- a/Sources/Build/BuildPlan/BuildPlan+Product.swift +++ b/Sources/Build/BuildPlan/BuildPlan+Product.swift @@ -168,7 +168,7 @@ extension BuildPlan { product: $0.product, context: $0.destination ) } - case .test, .executable, .snippet, .macro, .template: //john-to-revisit + case .test, .executable, .snippet, .macro: return [] } } @@ -243,7 +243,7 @@ extension BuildPlan { // In tool version .v5_5 or greater, we also include executable modules implemented in Swift in // any test products... this is to allow testing of executables. Note that they are also still // built as separate products that the test can invoke as subprocesses. - case .executable, .snippet, .macro, .template: //john-to-revisit + case .executable, .snippet, .macro: if product.modules.contains(id: module.id) { guard let description else { throw InternalError("Could not find a description for module: \(module)") diff --git a/Sources/Build/BuildPlan/BuildPlan+Test.swift b/Sources/Build/BuildPlan/BuildPlan+Test.swift index 8bb0042761f..b1267a0627f 100644 --- a/Sources/Build/BuildPlan/BuildPlan+Test.swift +++ b/Sources/Build/BuildPlan/BuildPlan+Test.swift @@ -287,7 +287,8 @@ private extension PackageModel.SwiftModule { dependencies: dependencies, packageAccess: packageAccess, buildSettings: buildSettings, - usesUnsafeFlags: false + usesUnsafeFlags: false, + template: false // test entry points are not templates ) } } diff --git a/Sources/Build/BuildPlan/BuildPlan.swift b/Sources/Build/BuildPlan/BuildPlan.swift index 5e2b0eb878d..c8be70ceff3 100644 --- a/Sources/Build/BuildPlan/BuildPlan.swift +++ b/Sources/Build/BuildPlan/BuildPlan.swift @@ -441,7 +441,7 @@ public class BuildPlan: SPMBuildCore.BuildPlan { try module.dependencies.compactMap { switch $0 { case .module(let moduleDependency, _): - if moduleDependency.type == .executable || moduleDependency.type == .template { + if moduleDependency.type == .executable { return graph.product(for: moduleDependency.name) } return nil @@ -1410,6 +1410,6 @@ extension ResolvedProduct { // We shouldn't create product descriptions for automatic libraries, plugins or products which consist solely of // binary targets, because they don't produce any output. fileprivate var shouldCreateProductDescription: Bool { - !self.isAutomaticLibrary && !self.isBinaryOnly && !self.isPlugin //john-to-revisit to see if include templates + !self.isAutomaticLibrary && !self.isBinaryOnly && !self.isPlugin } } diff --git a/Sources/Commands/PackageCommands/AddProduct.swift b/Sources/Commands/PackageCommands/AddProduct.swift index 489bb9e9ce8..51f8d664de3 100644 --- a/Sources/Commands/PackageCommands/AddProduct.swift +++ b/Sources/Commands/PackageCommands/AddProduct.swift @@ -33,7 +33,6 @@ extension SwiftPackageCommand { case staticLibrary = "static-library" case dynamicLibrary = "dynamic-library" case plugin - case template } package static let configuration = CommandConfiguration( @@ -87,7 +86,6 @@ extension SwiftPackageCommand { case .dynamicLibrary: .library(.dynamic) case .staticLibrary: .library(.static) case .plugin: .plugin - case .template: .template } let product = try ProductDescription( diff --git a/Sources/Commands/PackageCommands/AddTarget.swift b/Sources/Commands/PackageCommands/AddTarget.swift index 1577ee35b12..413f19363fc 100644 --- a/Sources/Commands/PackageCommands/AddTarget.swift +++ b/Sources/Commands/PackageCommands/AddTarget.swift @@ -33,7 +33,6 @@ extension SwiftPackageCommand { case executable case test case macro - case template } package static let configuration = CommandConfiguration( @@ -100,13 +99,12 @@ extension SwiftPackageCommand { verbose: !globalOptions.logging.quiet ) - // Map the target type. john-to-revisit + // Map the target type. let type: TargetDescription.TargetKind = switch self.type { case .library: .regular case .executable: .executable case .test: .test case .macro: .macro - case .template: .template } // Map dependencies diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index ca2dfc82ba8..503ce3349c5 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -445,12 +445,10 @@ extension SwiftPackageCommand { for product in products { for targetName in product.targets { if let target = targets.first(where: { _ in template == targetName }) { - if target.type == .template { - if let options = target.templateInitializationOptions { + if let options = target.templateInitializationOptions { - if case let .packageInit(templateType, _, _) = options { - return try .init(from: templateType) - } + if case let .packageInit(templateType, _, _) = options { + return try .init(from: templateType) } } } diff --git a/Sources/Commands/PackageCommands/ShowExecutables.swift b/Sources/Commands/PackageCommands/ShowExecutables.swift index 3195c2780cb..18c0c26532d 100644 --- a/Sources/Commands/PackageCommands/ShowExecutables.swift +++ b/Sources/Commands/PackageCommands/ShowExecutables.swift @@ -35,7 +35,7 @@ struct ShowExecutables: AsyncSwiftCommand { let executables = packageGraph.allProducts.filter({ $0.type == .executable || $0.type == .snippet }).filter({ - $0.modules.allSatisfy( {$0.type != .template}) + $0.modules.allSatisfy( { !$0.underlying.template }) }).map { product -> Executable in if !rootPackages.contains(product.packageIdentity) { return Executable(package: product.packageIdentity.description, name: product.name) diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 023744c4478..b304db93d8a 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -33,7 +33,7 @@ struct ShowTemplates: AsyncSwiftCommand { let rootPackages = packageGraph.rootPackages.map { $0.identity } let templates = packageGraph.allModules.filter({ - $0.type == .template + $0.underlying.template }).map { module -> Template in if !rootPackages.contains(module.packageIdentity) { return Template(package: module.packageIdentity.description, name: module.name) diff --git a/Sources/Commands/Snippets/Cards/TopCard.swift b/Sources/Commands/Snippets/Cards/TopCard.swift index 00363fbdb92..614a20b6080 100644 --- a/Sources/Commands/Snippets/Cards/TopCard.swift +++ b/Sources/Commands/Snippets/Cards/TopCard.swift @@ -182,8 +182,6 @@ fileprivate extension Module.Kind { return "snippets" case .macro: return "macros" - case .template: - return "templates" } } } diff --git a/Sources/Commands/SwiftBuildCommand.swift b/Sources/Commands/SwiftBuildCommand.swift index d6a6c7baba0..bd2ba81a9e0 100644 --- a/Sources/Commands/SwiftBuildCommand.swift +++ b/Sources/Commands/SwiftBuildCommand.swift @@ -111,7 +111,6 @@ struct BuildCommandOptions: ParsableArguments { /// Swift VSCode plugin. They will be removed in a future update. @OptionGroup(visibility: .private) var testLibraryOptions: TestLibraryOptions - */ /// Specifies the traits to build. diff --git a/Sources/Commands/Utilities/PluginDelegate.swift b/Sources/Commands/Utilities/PluginDelegate.swift index b0048561cbc..f14861050c9 100644 --- a/Sources/Commands/Utilities/PluginDelegate.swift +++ b/Sources/Commands/Utilities/PluginDelegate.swift @@ -202,7 +202,7 @@ final class PluginDelegate: PluginInvocationDelegate { path: $0.binaryPath.pathString, kind: (kind == .dynamic) ? .dynamicLibrary : .staticLibrary ) - case .executable, .template: //john-to-revisit + case .executable: return try .init(path: $0.binaryPath.pathString, kind: .executable) default: return nil diff --git a/Sources/PackageDescription/PackageDescriptionSerialization.swift b/Sources/PackageDescription/PackageDescriptionSerialization.swift index 43da07afe3b..a4c730e6d17 100644 --- a/Sources/PackageDescription/PackageDescriptionSerialization.swift +++ b/Sources/PackageDescription/PackageDescriptionSerialization.swift @@ -180,7 +180,6 @@ enum Serialization { case binary case plugin case `macro` - case template } enum PluginCapability: Codable { @@ -286,7 +285,6 @@ enum Serialization { case executable case library(type: LibraryType) case plugin - case template } let name: String diff --git a/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift b/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift index f59e8d380ba..06d1663602c 100644 --- a/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift +++ b/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift @@ -237,7 +237,6 @@ extension Serialization.TargetType { case .binary: self = .binary case .plugin: self = .plugin case .macro: self = .macro - case .template: self = .template } } } @@ -401,8 +400,6 @@ extension Serialization.Product { self.init(library) } else if let plugin = product as? PackageDescription.Product.Plugin { self.init(plugin) - } else if let template = product as? PackageDescription.Product.Template { - self.init(template) } else { fatalError("should not be reached") } @@ -435,16 +432,6 @@ extension Serialization.Product { self.settings = [] #endif } - - init(_ template: PackageDescription.Product.Template) { - self.name = template.name - self.targets = template.targets - self.productType = .template - #if ENABLE_APPLE_PRODUCT_TYPES - self.settings = [] - #endif - } - } extension Serialization.Trait { diff --git a/Sources/PackageDescription/Product.swift b/Sources/PackageDescription/Product.swift index 05df231980e..1f7efbeae3b 100644 --- a/Sources/PackageDescription/Product.swift +++ b/Sources/PackageDescription/Product.swift @@ -120,15 +120,6 @@ public class Product { } } - public final class Template: Product, @unchecked Sendable { - public let targets: [String] - - init(name: String, targets: [String]) { - self.targets = [name] - super.init(name: name) - } - } - /// Creates a library product to allow clients that declare a dependency on /// this package to use the package's functionality. /// @@ -178,12 +169,11 @@ public class Product { return Executable(name: name, targets: targets, settings: settings) } - //john-to-revisit documentation - /// Defines a template that vends a template plugin target and a template executable target for use by clients of the package. + /// Defines a product that vends a package plugin target for use by clients of the package. /// - /// It is not necessary to define a product for a template that + /// It is not necessary to define a product for a plugin that /// is only used within the same package where you define it. All the targets - /// listed must be template targets in the same package as the product. Swift Package Manager + /// listed must be plugin targets in the same package as the product. Swift Package Manager /// will apply them to any client targets of the product in the order /// they are listed. /// - Parameters: diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index 7ac5335ab58..cf7cfd8e853 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -39,8 +39,6 @@ public final class Target { case plugin /// A target that provides a Swift macro. case `macro` - /// A target that provides a Swift template - case template } /// The different types of a target's dependency on another entity. @@ -295,7 +293,7 @@ public final class Target { self.templateInitializationOptions = templateInitializationOptions switch type { - case .regular, .executable, .test: + case .regular, .test: precondition( url == nil && pkgConfig == nil && @@ -304,6 +302,14 @@ public final class Target { checksum == nil && templateInitializationOptions == nil ) + case .executable: + precondition( + url == nil && + pkgConfig == nil && + providers == nil && + pluginCapability == nil && + checksum == nil + ) case .system: precondition( url == nil && @@ -365,14 +371,6 @@ public final class Target { cxxSettings == nil && templateInitializationOptions == nil ) - case .template: - precondition( - url == nil && - pkgConfig == nil && - providers == nil && - pluginCapability == nil && - checksum == nil - ) } } @@ -1336,7 +1334,7 @@ public extension [Target] { sources: sources, resources: resources, publicHeadersPath: publicHeadersPath, - type: .template, + type: .executable, packageAccess: packageAccess, cSettings: cSettings, cxxSettings: cxxSettings, @@ -1689,7 +1687,7 @@ extension Target.PluginUsage: ExpressibleByStringLiteral { /// The type of permission a plug-in requires. /// /// Supported types are ``allowNetworkConnections(scope:reason:)`` and ``writeToPackageDirectory(reason:)``. -@available(_PackageDescription, introduced: 6.0) +@available(_PackageDescription, introduced: 999.0) public enum TemplatePermissions { /// Create a permission to make network connections. /// @@ -1697,7 +1695,7 @@ public enum TemplatePermissions { /// to the user at the time of request for approval, explaining why the plug-in is requesting access. /// - Parameter scope: The scope of the permission. /// - Parameter reason: A reason why the permission is needed. This is shown to the user when permission is sought. - @available(_PackageDescription, introduced: 6.0) + @available(_PackageDescription, introduced: 999.0) case allowNetworkConnections(scope: TemplateNetworkPermissionScope, reason: String) } @@ -1705,7 +1703,7 @@ public enum TemplatePermissions { /// The scope of a network permission. /// /// The scope can be none, local connections only, or all connections. -@available(_PackageDescription, introduced: 5.9) +@available(_PackageDescription, introduced: 999.0) public enum TemplateNetworkPermissionScope { /// Do not allow network access. case none diff --git a/Sources/PackageGraph/ModulesGraph+Loading.swift b/Sources/PackageGraph/ModulesGraph+Loading.swift index d076f4270df..fb4c3a1bc7c 100644 --- a/Sources/PackageGraph/ModulesGraph+Loading.swift +++ b/Sources/PackageGraph/ModulesGraph+Loading.swift @@ -283,7 +283,7 @@ private func checkAllDependenciesAreUsed( // We continue if the dependency contains executable products to make sure we don't // warn on a valid use-case for a lone dependency: swift run dependency executables. - guard !dependency.products.contains(where: { $0.type == .executable || $0.type == .template}) else { //john-to-revisit + guard !dependency.products.contains(where: { $0.type == .executable }) else { continue } // Skip this check if this dependency is a system module because system module packages diff --git a/Sources/PackageGraph/ModulesGraph.swift b/Sources/PackageGraph/ModulesGraph.swift index 9ed057ad904..73c4458efeb 100644 --- a/Sources/PackageGraph/ModulesGraph.swift +++ b/Sources/PackageGraph/ModulesGraph.swift @@ -268,7 +268,7 @@ public struct ModulesGraph { return try Dictionary(throwingUniqueKeysWithValues: testModuleDeps) }() - for module in rootModules where module.type == .executable || module.type == .template { //john-to-revisit + for module in rootModules where module.type == .executable { // Find all dependencies of this module within its package. Note that we do not traverse plugin usages. let dependencies = try topologicalSortIdentifiable(module.dependencies, successors: { $0.dependencies.compactMap{ $0.module }.filter{ $0.type != .plugin }.map{ .module($0, conditions: []) } diff --git a/Sources/PackageGraph/Resolution/ResolvedProduct.swift b/Sources/PackageGraph/Resolution/ResolvedProduct.swift index 6a81089e8c1..c6cbbe3bf1d 100644 --- a/Sources/PackageGraph/Resolution/ResolvedProduct.swift +++ b/Sources/PackageGraph/Resolution/ResolvedProduct.swift @@ -58,7 +58,7 @@ public struct ResolvedProduct { /// Note: This property is only valid for executable products. public var executableModule: ResolvedModule { get throws { - guard self.type == .executable || self.type == .snippet || self.type == .macro || self.type == .template else { //john-to-revisit + guard self.type == .executable || self.type == .snippet || self.type == .macro else { throw InternalError("`executableTarget` should only be called for executable targets") } guard let underlyingExecutableModule = modules.map(\.underlying).executables.first, diff --git a/Sources/PackageLoading/Diagnostics.swift b/Sources/PackageLoading/Diagnostics.swift index debc3030b52..5ec694a4403 100644 --- a/Sources/PackageLoading/Diagnostics.swift +++ b/Sources/PackageLoading/Diagnostics.swift @@ -38,7 +38,7 @@ extension Basics.Diagnostic { case .library(.automatic): typeString = "" case .executable, .snippet, .plugin, .test, .macro, - .library(.dynamic), .library(.static), .template: //john-to-revisit + .library(.dynamic), .library(.static): typeString = " (\(product.type))" } diff --git a/Sources/PackageLoading/ManifestJSONParser.swift b/Sources/PackageLoading/ManifestJSONParser.swift index 5f1a1a6553c..11bc187dbdc 100644 --- a/Sources/PackageLoading/ManifestJSONParser.swift +++ b/Sources/PackageLoading/ManifestJSONParser.swift @@ -499,7 +499,7 @@ extension ProductDescription { switch product.productType { case .executable: productType = .executable - case .plugin, .template: + case .plugin: productType = .plugin case .library(let type): productType = .library(.init(type)) @@ -568,8 +568,6 @@ extension TargetDescription.TargetKind { self = .plugin case .macro: self = .macro - case .template: - self = .template } } } diff --git a/Sources/PackageLoading/ManifestLoader.swift b/Sources/PackageLoading/ManifestLoader.swift index c7c31ff9174..76d576fb3d5 100644 --- a/Sources/PackageLoading/ManifestLoader.swift +++ b/Sources/PackageLoading/ManifestLoader.swift @@ -940,10 +940,10 @@ public final class ManifestLoader: ManifestLoaderProtocol { return evaluationResult // Return the result containing the error output } - // Read the JSON output that was emitted by libPackageDescription. let jsonOutput: String = try localFileSystem.readFileContents(jsonOutputFile) evaluationResult.manifestJSON = jsonOutput + // withTemporaryDirectory handles cleanup automatically return evaluationResult } diff --git a/Sources/PackageLoading/PackageBuilder.swift b/Sources/PackageLoading/PackageBuilder.swift index 22f6dc4b6e9..396d45910a0 100644 --- a/Sources/PackageLoading/PackageBuilder.swift +++ b/Sources/PackageLoading/PackageBuilder.swift @@ -34,7 +34,7 @@ public enum ModuleError: Swift.Error { case duplicateModule(moduleName: String, packages: [PackageIdentity]) /// The referenced target could not be found. - case moduleNotFound(String, TargetDescription.TargetKind, shouldSuggestRelaxedSourceDir: Bool) + case moduleNotFound(String, TargetDescription.TargetKind, shouldSuggestRelaxedSourceDir: Bool, expectedLocation: String) /// The artifact for the binary target could not be found. case artifactNotFound(moduleName: String, expectedArtifactName: String) @@ -112,22 +112,10 @@ extension ModuleError: CustomStringConvertible { case .duplicateModule(let target, let packages): let packages = packages.map(\.description).sorted().joined(separator: "', '") return "multiple packages ('\(packages)') declare targets with a conflicting name: '\(target)’; target names need to be unique across the package graph" - case .moduleNotFound(let target, let type, let shouldSuggestRelaxedSourceDir): - var folderName = "" - switch type { - case .test: - folderName = "Tests" - case .plugin: - folderName = "Plugins" - case .template: - folderName = "Templates" - default: - folderName = "Sources" - } - - var clauses = ["Source files for target \(target) should be located under '\(folderName)/\(target)'"] + case .moduleNotFound(let target, let type, let shouldSuggestRelaxedSourceDir, let expectedLocation): + var clauses = ["Source files for target \(target) should be located under '\(expectedLocation)/\(target)'"] if shouldSuggestRelaxedSourceDir { - clauses.append("'\(folderName)'") + clauses.append("'\(expectedLocation)'") } clauses.append("or a custom sources path can be set with the 'path' property in Package.swift") return clauses.joined(separator: ", ") @@ -607,7 +595,6 @@ public final class PackageBuilder { fs: fileSystem, path: packagePath.appending(component: predefinedDirs.pluginTargetDir) ) - let predefinedTemplateTargetDirectory = PredefinedTargetDirectory( fs: fileSystem, path: packagePath.appending(component: predefinedDirs.templateTargetDir) @@ -647,8 +634,12 @@ public final class PackageBuilder { predefinedTestTargetDirectory case .plugin: predefinedPluginTargetDirectory - case .template: - predefinedTemplateTargetDirectory + case .executable: + if target.templateInitializationOptions != nil { + predefinedTemplateTargetDirectory + } else { + predefinedTargetDirectory + } default: predefinedTargetDirectory } @@ -679,7 +670,8 @@ public final class PackageBuilder { target.name, target.type, shouldSuggestRelaxedSourceDir: self.manifest - .shouldSuggestRelaxedSourceDir(type: target.type) + .shouldSuggestRelaxedSourceDir(type: target.type), + expectedLocation: path.pathString ) } @@ -725,7 +717,8 @@ public final class PackageBuilder { throw ModuleError.moduleNotFound( missingModuleName, type, - shouldSuggestRelaxedSourceDir: self.manifest.shouldSuggestRelaxedSourceDir(type: type) + shouldSuggestRelaxedSourceDir: self.manifest.shouldSuggestRelaxedSourceDir(type: type), + expectedLocation: "Sources" // FIXME: this should provide the expected location of the module here ) } @@ -1060,8 +1053,6 @@ public final class PackageBuilder { moduleKind = .executable case .macro: moduleKind = .macro - case .template: //john-to-revisit - moduleKind = .template default: moduleKind = sources.computeModuleKind() if moduleKind == .executable && self.manifest.toolsVersion >= .v5_4 && self @@ -1090,7 +1081,8 @@ public final class PackageBuilder { declaredSwiftVersions: self.declaredSwiftVersions(), buildSettings: buildSettings, buildSettingsDescription: manifestTarget.settings, - usesUnsafeFlags: manifestTarget.usesUnsafeFlags + usesUnsafeFlags: manifestTarget.usesUnsafeFlags, + template: manifestTarget.templateInitializationOptions != nil ) } else { // It's not a Swift target, so it's a Clang target (those are the only two types of source target currently @@ -1135,7 +1127,8 @@ public final class PackageBuilder { dependencies: dependencies, buildSettings: buildSettings, buildSettingsDescription: manifestTarget.settings, - usesUnsafeFlags: manifestTarget.usesUnsafeFlags + usesUnsafeFlags: manifestTarget.usesUnsafeFlags, + template: manifestTarget.templateInitializationOptions != nil ) } } @@ -1598,10 +1591,6 @@ public final class PackageBuilder { guard self.validatePluginProduct(product, with: modules) else { continue } - case .template: //john-to-revisit - guard self.validateTemplateProduct(product, with: modules) else { - continue - } } try append(Product(package: self.identity, name: product.name, type: product.type, modules: modules)) @@ -1616,7 +1605,7 @@ public final class PackageBuilder { switch product.type { case .library, .plugin, .test, .macro: return [] - case .executable, .snippet, .template: //john-to-revisit + case .executable, .snippet: return product.targets } }) @@ -1762,27 +1751,6 @@ public final class PackageBuilder { return true } - private func validateTemplateProduct(_ product: ProductDescription, with targets: [Module]) -> Bool { - let nonTemplateTargets = targets.filter { $0.type != .template } - guard nonTemplateTargets.isEmpty else { - self.observabilityScope - .emit(.templateProductWithNonTemplateTargets(product: product.name, - otherTargets: nonTemplateTargets.map(\.name))) - return false - } - guard !targets.isEmpty else { - self.observabilityScope.emit(.templateProductWithNoTargets(product: product.name)) - return false - } - - guard targets.count == 1 else { - self.observabilityScope.emit(.templateProductWithMultipleTemplates(product: product.name)) - return false - } - - return true - } - /// Returns the first suggested predefined source directory for a given target type. public static func suggestedPredefinedSourceDirectory(type: TargetDescription.TargetKind) -> String { // These are static constants, safe to access by index; the first choice is preferred. @@ -1933,7 +1901,8 @@ extension PackageBuilder { packageAccess: false, buildSettings: buildSettings, buildSettingsDescription: targetDescription.settings, - usesUnsafeFlags: false + usesUnsafeFlags: false, + template: false // Snippets are not templates ) } } diff --git a/Sources/PackageModel/Manifest/TargetDescription.swift b/Sources/PackageModel/Manifest/TargetDescription.swift index 04bbcfcc15e..6db668c04a2 100644 --- a/Sources/PackageModel/Manifest/TargetDescription.swift +++ b/Sources/PackageModel/Manifest/TargetDescription.swift @@ -24,7 +24,6 @@ public struct TargetDescription: Hashable, Encodable, Sendable { case binary case plugin case `macro` - case template } /// Represents a target's dependency on another entity. @@ -255,7 +254,7 @@ public struct TargetDescription: Hashable, Encodable, Sendable { ) throws { let targetType = String(describing: type) switch type { - case .regular, .executable, .test: + case .regular, .test: if url != nil { throw Error.disallowedPropertyInTarget( targetName: name, targetType: targetType, @@ -293,7 +292,37 @@ public struct TargetDescription: Hashable, Encodable, Sendable { value: String(describing: templateInitializationOptions!) ) } - + case .executable: + if url != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "url", + value: url ?? "" + ) } + if pkgConfig != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "pkgConfig", + value: pkgConfig ?? "" + ) } + if providers != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "providers", + value: String(describing: providers!) + ) } + if pluginCapability != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "pluginCapability", + value: String(describing: pluginCapability!) + ) } + if checksum != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "checksum", + value: checksum ?? "" + ) } case .system: if !dependencies.isEmpty { throw Error.disallowedPropertyInTarget( targetName: name, @@ -520,45 +549,6 @@ public struct TargetDescription: Hashable, Encodable, Sendable { value: String(describing: templateInitializationOptions!) ) } - case .template: - // List forbidden properties for `.template` targets - if url != nil { throw Error.disallowedPropertyInTarget( - targetName: name, - targetType: targetType, - propertyName: "url", - value: url ?? "" - ) } - if pkgConfig != nil { throw Error.disallowedPropertyInTarget( - targetName: name, - targetType: targetType, - propertyName: "pkgConfig", - value: pkgConfig ?? "" - ) } - if providers != nil { throw Error.disallowedPropertyInTarget( - targetName: name, - targetType: targetType, - propertyName: "providers", - value: String(describing: providers!) - ) } - if pluginCapability != nil { throw Error.disallowedPropertyInTarget( - targetName: name, - targetType: targetType, - propertyName: "pluginCapability", - value: String(describing: pluginCapability!) - ) } - if checksum != nil { throw Error.disallowedPropertyInTarget( - targetName: name, - targetType: targetType, - propertyName: "checksum", - value: checksum ?? "" - ) } - if templateInitializationOptions == nil { throw Error.disallowedPropertyInTarget( //john-to-revisit - targetName: name, - targetType: targetType, - propertyName: "templateInitializationOptions", - value: String(describing: templateInitializationOptions) - ) } - } self.name = name @@ -759,5 +749,3 @@ private enum Error: LocalizedError, Equatable { } } } - - diff --git a/Sources/PackageModel/ManifestSourceGeneration.swift b/Sources/PackageModel/ManifestSourceGeneration.swift index 3d7f0ce0f5f..3ea0514d1ed 100644 --- a/Sources/PackageModel/ManifestSourceGeneration.swift +++ b/Sources/PackageModel/ManifestSourceGeneration.swift @@ -257,8 +257,6 @@ fileprivate extension SourceCodeFragment { self.init(enum: "test", subnodes: params, multiline: true) case .macro: self.init(enum: "macro", subnodes: params, multiline: true) - case .template: - self.init(enum: "template", subnodes: params, multiline: true) } } } @@ -361,8 +359,6 @@ fileprivate extension SourceCodeFragment { self.init(enum: "plugin", subnodes: params, multiline: true) case .macro: self.init(enum: "macro", subnodes: params, multiline: true) - case .template: - self.init(enum: "template", subnodes: params, multiline: true) } } diff --git a/Sources/PackageModel/Module/BinaryModule.swift b/Sources/PackageModel/Module/BinaryModule.swift index ec5ea395727..fd986ab9aec 100644 --- a/Sources/PackageModel/Module/BinaryModule.swift +++ b/Sources/PackageModel/Module/BinaryModule.swift @@ -49,7 +49,8 @@ public final class BinaryModule: Module { buildSettings: .init(), buildSettingsDescription: [], pluginUsages: [], - usesUnsafeFlags: false + usesUnsafeFlags: false, + template: false // TODO: determine whether binary modules can be templates or not ) } diff --git a/Sources/PackageModel/Module/ClangModule.swift b/Sources/PackageModel/Module/ClangModule.swift index bf066a6ecea..13deaa81875 100644 --- a/Sources/PackageModel/Module/ClangModule.swift +++ b/Sources/PackageModel/Module/ClangModule.swift @@ -61,7 +61,8 @@ public final class ClangModule: Module { dependencies: [Module.Dependency] = [], buildSettings: BuildSettings.AssignmentTable = .init(), buildSettingsDescription: [TargetBuildSettingDescription.Setting] = [], - usesUnsafeFlags: Bool + usesUnsafeFlags: Bool, + template: Bool ) throws { guard includeDir.isDescendantOfOrEqual(to: sources.root) else { throw StringError("\(includeDir) should be contained in the source root \(sources.root)") @@ -86,7 +87,8 @@ public final class ClangModule: Module { buildSettings: buildSettings, buildSettingsDescription: buildSettingsDescription, pluginUsages: [], - usesUnsafeFlags: usesUnsafeFlags + usesUnsafeFlags: usesUnsafeFlags, + template: template ) } } diff --git a/Sources/PackageModel/Module/Module.swift b/Sources/PackageModel/Module/Module.swift index 0537e191fac..5b5fb6c8f7b 100644 --- a/Sources/PackageModel/Module/Module.swift +++ b/Sources/PackageModel/Module/Module.swift @@ -29,7 +29,6 @@ public class Module { case plugin case snippet case `macro` - case template } /// A group a module belongs to that allows customizing access boundaries. A module is treated as @@ -243,6 +242,9 @@ public class Module { /// Whether or not this target uses any custom unsafe flags. public let usesUnsafeFlags: Bool + /// Whether or not this is a module that represents a template + public let template: Bool + init( name: String, potentialBundleName: String? = nil, @@ -257,7 +259,8 @@ public class Module { buildSettings: BuildSettings.AssignmentTable, buildSettingsDescription: [TargetBuildSettingDescription.Setting], pluginUsages: [PluginUsage], - usesUnsafeFlags: Bool + usesUnsafeFlags: Bool, + template: Bool ) { self.name = name self.potentialBundleName = potentialBundleName @@ -274,6 +277,7 @@ public class Module { self.buildSettingsDescription = buildSettingsDescription self.pluginUsages = pluginUsages self.usesUnsafeFlags = usesUnsafeFlags + self.template = template } @_spi(SwiftPMInternal) @@ -308,7 +312,7 @@ public extension Sequence where Iterator.Element == Module { switch $0.type { case .binary: return ($0 as? BinaryModule)?.containsExecutable == true - case .executable, .snippet, .macro, .template: //john-to-revisit + case .executable, .snippet, .macro: return true default: return false diff --git a/Sources/PackageModel/Module/PluginModule.swift b/Sources/PackageModel/Module/PluginModule.swift index 94c5d8eed1c..f3204b555dc 100644 --- a/Sources/PackageModel/Module/PluginModule.swift +++ b/Sources/PackageModel/Module/PluginModule.swift @@ -45,7 +45,8 @@ public final class PluginModule: Module { buildSettings: .init(), buildSettingsDescription: [], pluginUsages: [], - usesUnsafeFlags: false + usesUnsafeFlags: false, + template: false // Plugins cannot themselves be a template ) } } diff --git a/Sources/PackageModel/Module/SwiftModule.swift b/Sources/PackageModel/Module/SwiftModule.swift index b60974c16e1..64b6234d868 100644 --- a/Sources/PackageModel/Module/SwiftModule.swift +++ b/Sources/PackageModel/Module/SwiftModule.swift @@ -46,7 +46,8 @@ public final class SwiftModule: Module { buildSettings: buildSettings, buildSettingsDescription: [], pluginUsages: [], - usesUnsafeFlags: false + usesUnsafeFlags: false, + template: false // test modules cannot be templates ) } @@ -68,7 +69,8 @@ public final class SwiftModule: Module { buildSettings: BuildSettings.AssignmentTable = .init(), buildSettingsDescription: [TargetBuildSettingDescription.Setting] = [], pluginUsages: [PluginUsage] = [], - usesUnsafeFlags: Bool + usesUnsafeFlags: Bool, + template: Bool ) { self.declaredSwiftVersions = declaredSwiftVersions super.init( @@ -85,7 +87,8 @@ public final class SwiftModule: Module { buildSettings: buildSettings, buildSettingsDescription: buildSettingsDescription, pluginUsages: pluginUsages, - usesUnsafeFlags: usesUnsafeFlags + usesUnsafeFlags: usesUnsafeFlags, + template: template ) } @@ -133,16 +136,17 @@ public final class SwiftModule: Module { buildSettings: buildSettings, buildSettingsDescription: [], pluginUsages: [], - usesUnsafeFlags: false + usesUnsafeFlags: false, + template: false // Modules from test entry point files are not templates ) } public var supportsTestableExecutablesFeature: Bool { - // Exclude macros from testable executables if they are built as dylibs. john-to-revisit + // Exclude macros from testable executables if they are built as dylibs. #if BUILD_MACROS_AS_DYLIBS - return type == .executable || type == .snippet || type == .template + return type == .executable || type == .snippet #else - return type == .executable || type == .macro || type == .snippet || type == .template + return type == .executable || type == .macro || type == .snippet #endif } } diff --git a/Sources/PackageModel/Module/SystemLibraryModule.swift b/Sources/PackageModel/Module/SystemLibraryModule.swift index b1d17abcbac..4410250cb95 100644 --- a/Sources/PackageModel/Module/SystemLibraryModule.swift +++ b/Sources/PackageModel/Module/SystemLibraryModule.swift @@ -49,7 +49,8 @@ public final class SystemLibraryModule: Module { buildSettings: .init(), buildSettingsDescription: [], pluginUsages: [], - usesUnsafeFlags: false + usesUnsafeFlags: false, + template: false // System libraries are not templates ) } } diff --git a/Sources/PackageModel/Product.swift b/Sources/PackageModel/Product.swift index c6c1fc0092b..ac42bc458d6 100644 --- a/Sources/PackageModel/Product.swift +++ b/Sources/PackageModel/Product.swift @@ -102,18 +102,10 @@ public enum ProductType: Equatable, Hashable, Sendable { /// A macro product. case `macro` - /// A template product - case template - public var isLibrary: Bool { guard case .library = self else { return false } return true } - - public var isTemplate: Bool { - guard case .template = self else {return false} - return true - } } @@ -205,8 +197,6 @@ extension ProductType: CustomStringConvertible { return "plugin" case .macro: return "macro" - case .template: - return "template" } } } @@ -226,7 +216,7 @@ extension ProductFilter: CustomStringConvertible { extension ProductType: Codable { private enum CodingKeys: String, CodingKey { - case library, executable, snippet, plugin, test, `macro`, template + case library, executable, snippet, plugin, test, `macro` } public func encode(to encoder: Encoder) throws { @@ -245,8 +235,6 @@ extension ProductType: Codable { try container.encodeNil(forKey: .test) case .macro: try container.encodeNil(forKey: .macro) - case .template: - try container.encodeNil(forKey: .template) } } @@ -270,8 +258,6 @@ extension ProductType: Codable { self = .plugin case .macro: self = .macro - case .template: - self = .template } } } diff --git a/Sources/PackageModel/ToolsVersion.swift b/Sources/PackageModel/ToolsVersion.swift index 37e12c6703a..cded5bf9467 100644 --- a/Sources/PackageModel/ToolsVersion.swift +++ b/Sources/PackageModel/ToolsVersion.swift @@ -79,7 +79,7 @@ public struct ToolsVersion: Equatable, Hashable, Codable, Sendable { /// Returns the tools version with zeroed patch number. public var zeroedPatch: ToolsVersion { - return ToolsVersion(version: Version(6, 1, 0)) //john-to-revisit working on 6.1.0, just for using to test. revert to major, minor when finished + return ToolsVersion(version: Version(major, minor, 0)) } /// The underlying backing store. diff --git a/Sources/PackageModelSyntax/AddTarget.swift b/Sources/PackageModelSyntax/AddTarget.swift index 2382087a6ad..b6a081a7d67 100644 --- a/Sources/PackageModelSyntax/AddTarget.swift +++ b/Sources/PackageModelSyntax/AddTarget.swift @@ -161,7 +161,7 @@ public enum AddTarget { ) let outerDirectory: String? = switch target.type { - case .binary, .plugin, .system, .template: nil + case .binary, .plugin, .system: nil case .executable, .regular, .macro: "Sources" case .test: "Tests" } @@ -275,7 +275,7 @@ public enum AddTarget { } let sourceFileText: SourceFileSyntax = switch target.type { - case .binary, .plugin, .system, .template: + case .binary, .plugin, .system: fatalError("should have exited above") case .macro: diff --git a/Sources/PackageModelSyntax/ProductDescription+Syntax.swift b/Sources/PackageModelSyntax/ProductDescription+Syntax.swift index d12cef5e31b..614d5db8bf4 100644 --- a/Sources/PackageModelSyntax/ProductDescription+Syntax.swift +++ b/Sources/PackageModelSyntax/ProductDescription+Syntax.swift @@ -29,7 +29,6 @@ extension ProductDescription: ManifestSyntaxRepresentable { case .plugin: "plugin" case .snippet: "snippet" case .test: "test" - case .template: "template" } } diff --git a/Sources/PackageModelSyntax/TargetDescription+Syntax.swift b/Sources/PackageModelSyntax/TargetDescription+Syntax.swift index 9f5ff2299ae..eed59e5fcae 100644 --- a/Sources/PackageModelSyntax/TargetDescription+Syntax.swift +++ b/Sources/PackageModelSyntax/TargetDescription+Syntax.swift @@ -27,7 +27,6 @@ extension TargetDescription: ManifestSyntaxRepresentable { case .regular: "target" case .system: "systemLibrary" case .test: "testTarget" - case .template: "template" } } diff --git a/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift b/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift index 8451d9c724e..f82f050328b 100644 --- a/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift +++ b/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift @@ -302,15 +302,9 @@ public struct BuildParameters: Encodable { try RelativePath(validating: "\(name)\(self.suffix)\(self.triple.executableExtension)") } - package func templatePath(for name: String) throws -> Basics.RelativePath { - try RelativePath(validating: "\(name)\(self.suffix)\(self.triple.executableExtension)") //John-to-revisit - } - /// Returns the path to the binary of a product for the current build parameters, relative to the build directory. public func binaryRelativePath(for product: ResolvedProduct) throws -> Basics.RelativePath { switch product.type { - case .template: - return try templatePath(for: product.name) //john-to-revisit case .executable, .snippet: return try executablePath(for: product.name) case .library(.static): diff --git a/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift b/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift index 783c67b7c27..83b3b90321d 100644 --- a/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift +++ b/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift @@ -335,7 +335,7 @@ fileprivate extension WireInput.Target.TargetInfo.SourceModuleKind { switch kind { case .library: self = .generic - case .executable, .template: //john-to-revisit + case .executable: self = .executable case .snippet: self = .snippet diff --git a/Sources/SPMBuildCore/Plugins/PluginInvocation.swift b/Sources/SPMBuildCore/Plugins/PluginInvocation.swift index db345bceb8c..d86ef9ecad7 100644 --- a/Sources/SPMBuildCore/Plugins/PluginInvocation.swift +++ b/Sources/SPMBuildCore/Plugins/PluginInvocation.swift @@ -696,7 +696,7 @@ fileprivate func collectAccessibleTools( } } // For an executable target we create a `builtTool`. - else if executableOrBinaryModule.type == .executable || executableOrBinaryModule.type == .template { //john-to-revisit + else if executableOrBinaryModule.type == .executable { return try [.builtTool(name: builtToolName, path: RelativePath(validating: executableOrBinaryModule.name))] } else { diff --git a/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift b/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift index 57f8b45ef9a..90cc33e8832 100644 --- a/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift +++ b/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift @@ -186,7 +186,7 @@ extension PackageModel.Module { switch self.type { case .executable, .snippet: true - case .library, .test, .macro, .systemModule, .plugin, .binary, .template: //john-to-revisit + case .library, .test, .macro, .systemModule, .plugin, .binary: false } } @@ -195,7 +195,7 @@ extension PackageModel.Module { switch self.type { case .binary: true - case .library, .executable, .snippet, .test, .plugin, .macro, .systemModule, .template: + case .library, .executable, .snippet, .test, .plugin, .macro, .systemModule: false } } @@ -205,7 +205,7 @@ extension PackageModel.Module { switch self.type { case .library, .executable, .snippet, .test, .macro: true - case .systemModule, .plugin, .binary, .template: //john-to-revisit + case .systemModule, .plugin, .binary: false } } @@ -220,7 +220,6 @@ extension PackageModel.ProductType { case .library: .library case .plugin: .plugin case .macro: .macro - case .template: .template } } } @@ -681,7 +680,7 @@ extension PackageGraph.ResolvedProduct { /// (e.g., executables have one executable module, test bundles have one test module, etc). var isMainModuleProduct: Bool { switch self.type { - case .executable, .snippet, .test, .template: //john-to-revisit + case .executable, .snippet, .test: true case .library, .macro, .plugin: false @@ -699,7 +698,7 @@ extension PackageGraph.ResolvedProduct { var isExecutable: Bool { switch self.type { - case .executable, .snippet, .template: //john-to-revisit + case .executable, .snippet: true case .library, .test, .plugin, .macro: false @@ -742,7 +741,7 @@ extension PackageGraph.ResolvedProduct { /// Shoud we link this product dependency? var isLinkable: Bool { switch self.type { - case .library, .executable, .snippet, .test, .macro, .template: //john-to-revisit + case .library, .executable, .snippet, .test, .macro: true case .plugin: false diff --git a/Sources/SwiftBuildSupport/PackagePIFBuilder.swift b/Sources/SwiftBuildSupport/PackagePIFBuilder.swift index 40f6744d6d9..a4769ca3140 100644 --- a/Sources/SwiftBuildSupport/PackagePIFBuilder.swift +++ b/Sources/SwiftBuildSupport/PackagePIFBuilder.swift @@ -434,9 +434,6 @@ public final class PackagePIFBuilder { case .snippet, .macro: break // TODO: Double-check what's going on here as we skip snippet modules too (rdar://147705448) - case .template: - // john-to-revisit: makeTemplateproduct - try projectBuilder.makeMainModuleProduct(product) } } @@ -473,11 +470,6 @@ public final class PackagePIFBuilder { case .macro: try projectBuilder.makeMacroModule(module) - - case .template: - // Skip template modules — they represent tools, not code to compile. john-to-revisit - break - } } @@ -658,7 +650,7 @@ extension PackagePIFBuilder.LinkedPackageBinary { case .library, .binary, .macro: self.init(name: module.name, packageName: packageName, type: .target) - case .systemModule, .plugin, .template: //john-to-revisit + case .systemModule, .plugin: return nil } } diff --git a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift index 15f3fb6e131..e5962a3026a 100644 --- a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift +++ b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift @@ -95,10 +95,6 @@ extension PackagePIFProjectBuilder { platformFilters: dependencyPlatformFilters ) log(.debug, indent: 1, "Added dependency on target '\(dependencyGUID)'") - case .template: //john-to-revisit - // Template targets are used as tooling, not build dependencies. - // Plugins will invoke them when needed. - break } @@ -701,8 +697,6 @@ extension PackagePIFProjectBuilder { indent: 1, "Added \(shouldLinkProduct ? "linked " : "")dependency on target '\(moduleDependency.pifTargetGUID)'" ) - case .template: //john-to-revisit - break } case .product(let productDependency, let packageConditions): diff --git a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift index c0ff0bfbb43..9525b69ba76 100644 --- a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift +++ b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift @@ -446,9 +446,6 @@ extension PackagePIFProjectBuilder { indent: 1, "Added \(shouldLinkProduct ? "linked " : "")dependency on target '\(dependencyGUID)'" ) - case .template: - //john-to-revisit - break } case .product(let productDependency, let packageConditions): diff --git a/Sources/XCBuildSupport/PIFBuilder.swift b/Sources/XCBuildSupport/PIFBuilder.swift index 04ffe69d565..283379fedd1 100644 --- a/Sources/XCBuildSupport/PIFBuilder.swift +++ b/Sources/XCBuildSupport/PIFBuilder.swift @@ -386,7 +386,7 @@ final class PackagePIFProjectBuilder: PIFProjectBuilder { private func addTarget(for product: ResolvedProduct) throws { switch product.type { - case .executable, .snippet, .test, .template: //john-to-revisit + case .executable, .snippet, .test: try self.addMainModuleTarget(for: product) case .library: self.addLibraryTarget(for: product) @@ -414,8 +414,6 @@ final class PackagePIFProjectBuilder: PIFProjectBuilder { case .macro: // Macros are not supported when using XCBuild, similar to package plugins. return - case .template: //john-to-revisit - return } } @@ -1615,8 +1613,6 @@ extension ProductType { .plugin case .macro: .macro - case .template: - .template } } } diff --git a/Sources/_InternalTestSupport/ResolvedModule+Mock.swift b/Sources/_InternalTestSupport/ResolvedModule+Mock.swift index 24df377acbb..014bc604600 100644 --- a/Sources/_InternalTestSupport/ResolvedModule+Mock.swift +++ b/Sources/_InternalTestSupport/ResolvedModule+Mock.swift @@ -29,7 +29,8 @@ extension ResolvedModule { sources: Sources(paths: [], root: "/"), dependencies: [], packageAccess: false, - usesUnsafeFlags: false + usesUnsafeFlags: false, + template: false ), dependencies: deps.map { .module($0, conditions: conditions) }, defaultLocalization: nil, diff --git a/Tests/BuildTests/ClangTargetBuildDescriptionTests.swift b/Tests/BuildTests/ClangTargetBuildDescriptionTests.swift index 317bd624c95..2112145f6c5 100644 --- a/Tests/BuildTests/ClangTargetBuildDescriptionTests.swift +++ b/Tests/BuildTests/ClangTargetBuildDescriptionTests.swift @@ -61,7 +61,8 @@ final class ClangTargetBuildDescriptionTests: XCTestCase { type: .library, path: .root, sources: .init(paths: [.root.appending(component: "foo.c")], root: .root), - usesUnsafeFlags: false + usesUnsafeFlags: false, + template: false ) } diff --git a/Tests/FunctionalTests/PluginTests.swift b/Tests/FunctionalTests/PluginTests.swift index 7ac6d21fa29..c03f6bf3e18 100644 --- a/Tests/FunctionalTests/PluginTests.swift +++ b/Tests/FunctionalTests/PluginTests.swift @@ -246,6 +246,7 @@ final class PluginTests: XCTestCase { } func testCommandPluginInvocation() async throws { + try XCTSkipIf(true, "test is disabled because it isn't stable, see rdar://117870608") // Only run the test if the environment in which we're running actually supports Swift concurrency (which the plugin APIs require). try XCTSkipIf(!UserToolchain.default.supportsSwiftConcurrency(), "skipping because test environment doesn't support concurrency") @@ -259,7 +260,7 @@ final class PluginTests: XCTestCase { try localFileSystem.writeFileContents( manifestFile, string: """ - // swift-tools-version: 6.1 + // swift-tools-version: 5.6 import PackageDescription let package = Package( name: "MyPackage", @@ -311,15 +312,6 @@ final class PluginTests: XCTestCase { """ ) - let templateSourceFile = packageDir.appending(components: "Sources", "GenerateStuff", "generatestuff.swift") - try localFileSystem.createDirectory(templateSourceFile.parentDirectory, recursive: true) - try localFileSystem.writeFileContents( - templateSourceFile, - string: """ - public func Foo() { } - """ - ) - let printingPluginSourceFile = packageDir.appending(components: "Plugins", "PluginPrintingInfo", "plugin.swift") try localFileSystem.createDirectory(printingPluginSourceFile.parentDirectory, recursive: true) try localFileSystem.writeFileContents( From 67b03d250f914973103d32dbc9b62fec7a0cdc66 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Tue, 10 Jun 2025 11:29:21 -0400 Subject: [PATCH 041/225] Match whitespace and formatting to original main branch --- Sources/Build/BuildDescription/ProductBuildDescription.swift | 2 +- .../Build/BuildDescription/SwiftModuleBuildDescription.swift | 4 ++-- Sources/Build/BuildPlan/BuildPlan.swift | 1 - Sources/Commands/PackageCommands/PluginCommand.swift | 4 ++-- Sources/PackageDescription/Product.swift | 2 +- Sources/PackageGraph/ModulesGraph.swift | 2 +- Sources/PackageLoading/Diagnostics.swift | 2 +- .../SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift | 1 - Tests/FunctionalTests/PluginTests.swift | 1 - 9 files changed, 8 insertions(+), 11 deletions(-) diff --git a/Sources/Build/BuildDescription/ProductBuildDescription.swift b/Sources/Build/BuildDescription/ProductBuildDescription.swift index e0645775233..b07da282921 100644 --- a/Sources/Build/BuildDescription/ProductBuildDescription.swift +++ b/Sources/Build/BuildDescription/ProductBuildDescription.swift @@ -245,7 +245,7 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription // Support for linking tests against executables is conditional on the tools // version of the package that defines the executable product. let executableTarget = try product.executableModule - if let target = executableTarget.underlying as? SwiftModule, + if let target = executableTarget.underlying as? SwiftModule, self.toolsVersion >= .v5_5, self.buildParameters.driverParameters.canRenameEntrypointFunctionName, target.supportsTestableExecutablesFeature diff --git a/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift b/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift index ddad0d86498..69e482aec34 100644 --- a/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift +++ b/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift @@ -146,7 +146,7 @@ public final class SwiftModuleBuildDescription { // If we're an executable and we're not allowing test targets to link against us, we hide the module. let triple = buildParameters.triple let allowLinkingAgainstExecutables = [.coff, .macho, .elf].contains(triple.objectFormat) && self.toolsVersion >= .v5_5 - let dirPath = ((target.type == .executable) && !allowLinkingAgainstExecutables) ? self.tempsPath : self.modulesPath + let dirPath = (target.type == .executable && !allowLinkingAgainstExecutables) ? self.tempsPath : self.modulesPath return dirPath.appending(component: "\(self.target.c99name).swiftmodule") } @@ -201,7 +201,7 @@ public final class SwiftModuleBuildDescription { switch self.target.type { case .library, .test: return true - case .executable, .snippet, .macro: //john-to-revisit + case .executable, .snippet, .macro: // This deactivates heuristics in the Swift compiler that treats single-file modules and source files // named "main.swift" specially w.r.t. whether they can have an entry point. // diff --git a/Sources/Build/BuildPlan/BuildPlan.swift b/Sources/Build/BuildPlan/BuildPlan.swift index c8be70ceff3..ccf06d1167d 100644 --- a/Sources/Build/BuildPlan/BuildPlan.swift +++ b/Sources/Build/BuildPlan/BuildPlan.swift @@ -228,7 +228,6 @@ public class BuildPlan: SPMBuildCore.BuildPlan { /// as targets, but they are not directly included in the build graph. public let pluginDescriptions: [PluginBuildDescription] - /// The build targets. public var targets: AnySequence { AnySequence(self.targetMap.values) diff --git a/Sources/Commands/PackageCommands/PluginCommand.swift b/Sources/Commands/PackageCommands/PluginCommand.swift index f0c497ce1c0..d81bf6cea61 100644 --- a/Sources/Commands/PackageCommands/PluginCommand.swift +++ b/Sources/Commands/PackageCommands/PluginCommand.swift @@ -37,7 +37,7 @@ struct PluginCommand: AsyncSwiftCommand { ) var listCommands: Bool = false - public struct PluginOptions: ParsableArguments { + struct PluginOptions: ParsableArguments { @Flag( name: .customLong("allow-writing-to-package-directory"), help: "Allow the plugin to write to the package directory." @@ -363,7 +363,7 @@ struct PluginCommand: AsyncSwiftCommand { let allowNetworkConnectionsCopy = allowNetworkConnections let buildEnvironment = buildParameters.buildEnvironment - let pluginOutput = try await pluginTarget.invoke( + _ = try await pluginTarget.invoke( action: .performCommand(package: package, arguments: arguments), buildEnvironment: buildEnvironment, scriptRunner: pluginScriptRunner, diff --git a/Sources/PackageDescription/Product.swift b/Sources/PackageDescription/Product.swift index 1f7efbeae3b..34239fe0c6b 100644 --- a/Sources/PackageDescription/Product.swift +++ b/Sources/PackageDescription/Product.swift @@ -170,7 +170,7 @@ public class Product { } /// Defines a product that vends a package plugin target for use by clients of the package. - /// + /// /// It is not necessary to define a product for a plugin that /// is only used within the same package where you define it. All the targets /// listed must be plugin targets in the same package as the product. Swift Package Manager diff --git a/Sources/PackageGraph/ModulesGraph.swift b/Sources/PackageGraph/ModulesGraph.swift index 73c4458efeb..7c7e9800be5 100644 --- a/Sources/PackageGraph/ModulesGraph.swift +++ b/Sources/PackageGraph/ModulesGraph.swift @@ -267,7 +267,7 @@ public struct ModulesGraph { }) return try Dictionary(throwingUniqueKeysWithValues: testModuleDeps) }() - + for module in rootModules where module.type == .executable { // Find all dependencies of this module within its package. Note that we do not traverse plugin usages. let dependencies = try topologicalSortIdentifiable(module.dependencies, successors: { diff --git a/Sources/PackageLoading/Diagnostics.swift b/Sources/PackageLoading/Diagnostics.swift index 5ec694a4403..2873ac5444e 100644 --- a/Sources/PackageLoading/Diagnostics.swift +++ b/Sources/PackageLoading/Diagnostics.swift @@ -38,7 +38,7 @@ extension Basics.Diagnostic { case .library(.automatic): typeString = "" case .executable, .snippet, .plugin, .test, .macro, - .library(.dynamic), .library(.static): + .library(.dynamic), .library(.static): typeString = " (\(product.type))" } diff --git a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift index e5962a3026a..a0e0c7c3049 100644 --- a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift +++ b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift @@ -96,7 +96,6 @@ extension PackagePIFProjectBuilder { ) log(.debug, indent: 1, "Added dependency on target '\(dependencyGUID)'") } - case .product(let productDependency, let packageConditions): // Do not add a dependency for binary-only executable products since they are not part of the build. diff --git a/Tests/FunctionalTests/PluginTests.swift b/Tests/FunctionalTests/PluginTests.swift index c03f6bf3e18..89dd646b6a0 100644 --- a/Tests/FunctionalTests/PluginTests.swift +++ b/Tests/FunctionalTests/PluginTests.swift @@ -311,7 +311,6 @@ final class PluginTests: XCTestCase { public func Foo() { } """ ) - let printingPluginSourceFile = packageDir.appending(components: "Plugins", "PluginPrintingInfo", "plugin.swift") try localFileSystem.createDirectory(printingPluginSourceFile.parentDirectory, recursive: true) try localFileSystem.writeFileContents( From 237bd95c77e42a1aebcd22a44b9022e57d05484a Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 10 Jun 2025 11:29:56 -0400 Subject: [PATCH 042/225] formatting + starting to add git capabilities --- Sources/Commands/PackageCommands/Init.swift | 442 ++++++++++++-------- 1 file changed, 277 insertions(+), 165 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index ca2dfc82ba8..1fcb55473e3 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -26,6 +26,7 @@ import PackageGraph import SPMBuildCore import XCBuildSupport import TSCBasic +import SourceControl import ArgumentParserToolInfo @@ -76,6 +77,9 @@ extension SwiftPackageCommand { @Option(name: .customLong("template-path"), help: "Path to the local template.", completion: .directory) var templateDirectory: Basics.AbsolutePath? + @Option(name: .customLong("template-url"), help: "The git URL of the template.") + var templateURL: String? + // Git-specific options @Option(help: "The exact package version to depend on.") var exact: Version? @@ -115,8 +119,62 @@ extension SwiftPackageCommand { return path case .git: - // TODO: Cache logic and smarter hashing - throw StringError("git-based templates not yet implemented") + + var requirements : [PackageDependency.SourceControl.Requirement] = [] + + if let exact { + requirements.append(.exact(exact)) + } + + if let branch { + requirements.append(.branch(branch)) + } + + if let revision { + requirements.append(.revision(revision)) + } + + if let from { + requirements.append(.range(.upToNextMajor(from: from))) + } + + if let upToNextMinorFrom { + requirements.append(.range(.upToNextMinor(from: upToNextMinorFrom))) + } + + if requirements.count > 1 { + throw StringError( + "must specify at most one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" + ) + } + + guard let firstRequirement = requirements.first else { + throw StringError( + "must specify one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" + ) + } + + let requirement: PackageDependency.SourceControl.Requirement + if case .range(let range) = firstRequirement { + if let to { + requirement = .range(range.lowerBound ..< to) + } else { + requirement = .range(range) + } + } else { + requirement = firstRequirement + + if self.to != nil { + throw StringError("--to can only be specified with --from or --up-to-next-minor-from") + } + } + + if let templateURL = templateURL{ + print(try await getPackageFromGit(destination: templateURL, requirement: requirement)) + throw StringError("did not specify template URL") + } else { + throw StringError("did not specify template URL") + } case .registry: // TODO: Lookup and download from registry @@ -199,7 +257,7 @@ extension SwiftPackageCommand { let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) - + let output = try await Self.run( plugin: matchingPlugins[0], package: packageGraph.rootPackages.first!, @@ -253,175 +311,229 @@ extension SwiftPackageCommand { } } + func getPackageFromGit(destination: String, requirement: PackageDependency.SourceControl.Requirement) async throws -> Basics.AbsolutePath { + let repositoryProvider = GitRepositoryProvider() - static func run( - plugin: ResolvedModule, - package: ResolvedPackage, - packageGraph: ModulesGraph, - allowNetworkConnections: [SandboxNetworkPermission] = [], - arguments: [String], - swiftCommandState: SwiftCommandState - ) async throws -> Data { - let pluginTarget = plugin.underlying as! PluginModule + let fetchStandalonePackageByURL = {() async throws -> Basics.AbsolutePath in + let url = SourceControlURL(destination) + return try withTemporaryDirectory(removeTreeOnDeinit: true) { (tempDir: Basics.AbsolutePath) in + let tempPath = tempDir.appending(component: url.lastPathComponent) + let repositorySpecifier = RepositorySpecifier(url: url) - // The `plugins` directory is inside the workspace's main data directory, and contains all temporary files related to this plugin in the workspace. - let pluginsDir = try swiftCommandState.getActiveWorkspace().location.pluginWorkingDirectory - .appending(component: plugin.name) + try repositoryProvider.fetch( + repository: repositorySpecifier, + to: tempPath, + progressHandler: nil + ) - // The `cache` directory is in the plugin’s directory and is where the plugin script runner caches compiled plugin binaries and any other derived information for this plugin. - let pluginScriptRunner = try swiftCommandState.getPluginScriptRunner( - customPluginsDir: pluginsDir - ) + guard try repositoryProvider.isValidDirectory(tempPath), + let repository = repositoryProvider.open( + repository: repositorySpecifier, + at: tempPath + ) as? GitRepository else { + throw InternalError("Invalid directory at \(tempPath)") + } - // The `outputs` directory contains subdirectories for each combination of package and command plugin. Each usage of a plugin has an output directory that is writable by the plugin, where it can write additional files, and to which it can configure tools to write their outputs, etc. - let outputDir = pluginsDir.appending("outputs") - - // Determine the set of directories under which plugins are allowed to write. We always include the output directory. - var writableDirectories = [outputDir] - - // FIXME: decide whether this permission needs to be explicitly requested by the plugin target, or not - writableDirectories.append(package.path) - - var allowNetworkConnections = allowNetworkConnections - - // If the plugin requires permissions, we ask the user for approval. - if case .command(_, let permissions) = pluginTarget.capability { - try permissions.forEach { - let permissionString: String - let reasonString: String - let remedyOption: String - - switch $0 { - case .writeToPackageDirectory(let reason): - //guard !options.allowWritingToPackageDirectory else { return } // permission already granted - permissionString = "write to the package directory" - reasonString = reason - remedyOption = "--allow-writing-to-package-directory" - case .allowNetworkConnections(let scope, let reason): - guard scope != .none else { return } // no need to prompt - //guard options.allowNetworkConnections != .init(scope) else { return } // permission already granted - - switch scope { - case .all, .local: - let portsString = scope.ports - .isEmpty ? "on all ports" : - "on ports: \(scope.ports.map { "\($0)" }.joined(separator: ", "))" - permissionString = "allow \(scope.label) network connections \(portsString)" - case .docker, .unixDomainSocket: - permissionString = "allow \(scope.label) connections" - case .none: - permissionString = "" // should not be reached + // If requirement is a range, find the latest tag that matches + switch requirement { + case .range(let versionRange): + let tags = try repository.getTags() + let versions = tags.compactMap { Version($0) } + // Filter versions within range + let filteredVersions = versions.filter { versionRange.contains($0) } + guard let latestVersion = filteredVersions.max() else { + throw InternalError("No tags found within the specified version range \(versionRange)") + } + // Checkout the latest version tag + try repository.checkout(tag: latestVersion.description) + + case .exact(let exactVersion): + try repository.checkout(tag: exactVersion.description) + + case .branch(let branchName): + + throw InternalError("No branch option available for fetching a single commit") + case .revision(let revision): + try repository.checkout(revision: .init(identifier: revision)) } - reasonString = reason - // FIXME compute the correct reason for the type of network connection - remedyOption = - "--allow-network-connections 'Network connection is needed'" + + // Return the absolute path of the fetched git repository + return tempPath } + } + + return try await fetchStandalonePackageByURL() + } + + static func run( + plugin: ResolvedModule, + package: ResolvedPackage, + packageGraph: ModulesGraph, + allowNetworkConnections: [SandboxNetworkPermission] = [], + arguments: [String], + swiftCommandState: SwiftCommandState + ) async throws -> Data { + let pluginTarget = plugin.underlying as! PluginModule + + // The `plugins` directory is inside the workspace's main data directory, and contains all temporary files related to this plugin in the workspace. + let pluginsDir = try swiftCommandState.getActiveWorkspace().location.pluginWorkingDirectory + .appending(component: plugin.name) + + // The `cache` directory is in the plugin’s directory and is where the plugin script runner caches compiled plugin binaries and any other derived information for this plugin. + let pluginScriptRunner = try swiftCommandState.getPluginScriptRunner( + customPluginsDir: pluginsDir + ) + + // The `outputs` directory contains subdirectories for each combination of package and command plugin. Each usage of a plugin has an output directory that is writable by the plugin, where it can write additional files, and to which it can configure tools to write their outputs, etc. + let outputDir = pluginsDir.appending("outputs") + + // Determine the set of directories under which plugins are allowed to write. We always include the output directory. + var writableDirectories = [outputDir] + + // FIXME: decide whether this permission needs to be explicitly requested by the plugin target, or not + writableDirectories.append(package.path) + + var allowNetworkConnections = allowNetworkConnections + + // If the plugin requires permissions, we ask the user for approval. + if case .command(_, let permissions) = pluginTarget.capability { + try permissions.forEach { + let permissionString: String + let reasonString: String + let remedyOption: String + + switch $0 { + case .writeToPackageDirectory(let reason): + //guard !options.allowWritingToPackageDirectory else { return } // permission already granted + permissionString = "write to the package directory" + reasonString = reason + remedyOption = "--allow-writing-to-package-directory" + case .allowNetworkConnections(let scope, let reason): + guard scope != .none else { return } // no need to prompt + //guard options.allowNetworkConnections != .init(scope) else { return } // permission already granted + + switch scope { + case .all, .local: + let portsString = scope.ports + .isEmpty ? "on all ports" : + "on ports: \(scope.ports.map { "\($0)" }.joined(separator: ", "))" + permissionString = "allow \(scope.label) network connections \(portsString)" + case .docker, .unixDomainSocket: + permissionString = "allow \(scope.label) connections" + case .none: + permissionString = "" // should not be reached + } - let problem = "Plugin ‘\(plugin.name)’ wants permission to \(permissionString)." - let reason = "Stated reason: “\(reasonString)”." - if swiftCommandState.outputStream.isTTY { - // We can ask the user directly, so we do so. - let query = "Allow this plugin to \(permissionString)?" - swiftCommandState.outputStream.write("\(problem)\n\(reason)\n\(query) (yes/no) ".utf8) - swiftCommandState.outputStream.flush() - let answer = readLine(strippingNewline: true) - // Throw an error if we didn't get permission. - if answer?.lowercased() != "yes" { - throw StringError("Plugin was denied permission to \(permissionString).") + reasonString = reason + // FIXME compute the correct reason for the type of network connection + remedyOption = + "--allow-network-connections 'Network connection is needed'" } - } else { - // We can't ask the user, so emit an error suggesting passing the flag. - let remedy = "Use `\(remedyOption)` to allow this." - throw StringError([problem, reason, remedy].joined(separator: "\n")) - } - switch $0 { - case .writeToPackageDirectory: - // Otherwise append the directory to the list of allowed ones. - writableDirectories.append(package.path) - case .allowNetworkConnections(let scope, _): - allowNetworkConnections.append(.init(scope)) + let problem = "Plugin ‘\(plugin.name)’ wants permission to \(permissionString)." + let reason = "Stated reason: “\(reasonString)”." + if swiftCommandState.outputStream.isTTY { + // We can ask the user directly, so we do so. + let query = "Allow this plugin to \(permissionString)?" + swiftCommandState.outputStream.write("\(problem)\n\(reason)\n\(query) (yes/no) ".utf8) + swiftCommandState.outputStream.flush() + let answer = readLine(strippingNewline: true) + // Throw an error if we didn't get permission. + if answer?.lowercased() != "yes" { + throw StringError("Plugin was denied permission to \(permissionString).") + } + } else { + // We can't ask the user, so emit an error suggesting passing the flag. + let remedy = "Use `\(remedyOption)` to allow this." + throw StringError([problem, reason, remedy].joined(separator: "\n")) + } + + switch $0 { + case .writeToPackageDirectory: + // Otherwise append the directory to the list of allowed ones. + writableDirectories.append(package.path) + case .allowNetworkConnections(let scope, _): + allowNetworkConnections.append(.init(scope)) + } } } - } - // Make sure that the package path is read-only unless it's covered by any of the explicitly writable directories. - let readOnlyDirectories = writableDirectories - .contains { package.path.isDescendantOfOrEqual(to: $0) } ? [] : [package.path] + // Make sure that the package path is read-only unless it's covered by any of the explicitly writable directories. + let readOnlyDirectories = writableDirectories + .contains { package.path.isDescendantOfOrEqual(to: $0) } ? [] : [package.path] - // Use the directory containing the compiler as an additional search directory, and add the $PATH. - let toolSearchDirs = [try swiftCommandState.getTargetToolchain().swiftCompilerPath.parentDirectory] + // Use the directory containing the compiler as an additional search directory, and add the $PATH. + let toolSearchDirs = [try swiftCommandState.getTargetToolchain().swiftCompilerPath.parentDirectory] + getEnvSearchPaths(pathString: Environment.current[.path], currentWorkingDirectory: .none) - let buildParameters = try swiftCommandState.toolsBuildParameters - // Build or bring up-to-date any executable host-side tools on which this plugin depends. Add them and any binary dependencies to the tool-names-to-path map. - let buildSystem = try await swiftCommandState.createBuildSystem( - explicitBuildSystem: .native, - traitConfiguration: .init(), - cacheBuildManifest: false, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: buildParameters, - packageGraphLoader: { packageGraph } - ) + let buildParameters = try swiftCommandState.toolsBuildParameters + // Build or bring up-to-date any executable host-side tools on which this plugin depends. Add them and any binary dependencies to the tool-names-to-path map. + let buildSystem = try await swiftCommandState.createBuildSystem( + explicitBuildSystem: .native, + traitConfiguration: .init(), + cacheBuildManifest: false, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: buildParameters, + packageGraphLoader: { packageGraph } + ) - let accessibleTools = try await plugin.preparePluginTools( - fileSystem: swiftCommandState.fileSystem, - environment: buildParameters.buildEnvironment, - for: try pluginScriptRunner.hostTriple - ) { name, _ in - // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies are not supported within a package, so if the tool happens to be from the same package, we instead find the executable that corresponds to the product. There is always one, because of autogeneration of implicit executables with the same name as the target if there isn't an explicit one. - try await buildSystem.build(subset: .product(name, for: .host)) - if let builtTool = try buildSystem.buildPlan.buildProducts.first(where: { - $0.product.name == name && $0.buildParameters.destination == .host - }) { - return try builtTool.binaryPath - } else { - return nil + let accessibleTools = try await plugin.preparePluginTools( + fileSystem: swiftCommandState.fileSystem, + environment: buildParameters.buildEnvironment, + for: try pluginScriptRunner.hostTriple + ) { name, _ in + // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies are not supported within a package, so if the tool happens to be from the same package, we instead find the executable that corresponds to the product. There is always one, because of autogeneration of implicit executables with the same name as the target if there isn't an explicit one. + try await buildSystem.build(subset: .product(name, for: .host)) + if let builtTool = try buildSystem.buildPlan.buildProducts.first(where: { + $0.product.name == name && $0.buildParameters.destination == .host + }) { + return try builtTool.binaryPath + } else { + return nil + } } - } - // Set up a delegate to handle callbacks from the command plugin. - let pluginDelegate = PluginDelegate(swiftCommandState: swiftCommandState, plugin: pluginTarget, echoOutput: false) - let delegateQueue = DispatchQueue(label: "plugin-invocation") + // Set up a delegate to handle callbacks from the command plugin. + let pluginDelegate = PluginDelegate(swiftCommandState: swiftCommandState, plugin: pluginTarget, echoOutput: false) + let delegateQueue = DispatchQueue(label: "plugin-invocation") - // Run the command plugin. + // Run the command plugin. - // TODO: use region based isolation when swift 6 is available - let writableDirectoriesCopy = writableDirectories - let allowNetworkConnectionsCopy = allowNetworkConnections + // TODO: use region based isolation when swift 6 is available + let writableDirectoriesCopy = writableDirectories + let allowNetworkConnectionsCopy = allowNetworkConnections - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") - } + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") + } - guard let workingDirectory = swiftCommandState.options.locations.packageDirectory ?? swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find current working directory") - } - let buildEnvironment = buildParameters.buildEnvironment - try await pluginTarget.invoke( - action: .performCommand(package: package, arguments: arguments), - buildEnvironment: buildEnvironment, - scriptRunner: pluginScriptRunner, - workingDirectory: workingDirectory, - outputDirectory: outputDir, - toolSearchDirectories: toolSearchDirs, - accessibleTools: accessibleTools, - writableDirectories: writableDirectoriesCopy, - readOnlyDirectories: readOnlyDirectories, - allowNetworkConnections: allowNetworkConnectionsCopy, - pkgConfigDirectories: swiftCommandState.options.locations.pkgConfigDirectories, - sdkRootPath: buildParameters.toolchain.sdkRootPath, - fileSystem: swiftCommandState.fileSystem, - modulesGraph: packageGraph, - observabilityScope: swiftCommandState.observabilityScope, - callbackQueue: delegateQueue, - delegate: pluginDelegate - ) + guard let workingDirectory = swiftCommandState.options.locations.packageDirectory ?? swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find current working directory") + } + let buildEnvironment = buildParameters.buildEnvironment + try await pluginTarget.invoke( + action: .performCommand(package: package, arguments: arguments), + buildEnvironment: buildEnvironment, + scriptRunner: pluginScriptRunner, + workingDirectory: workingDirectory, + outputDirectory: outputDir, + toolSearchDirectories: toolSearchDirs, + accessibleTools: accessibleTools, + writableDirectories: writableDirectoriesCopy, + readOnlyDirectories: readOnlyDirectories, + allowNetworkConnections: allowNetworkConnectionsCopy, + pkgConfigDirectories: swiftCommandState.options.locations.pkgConfigDirectories, + sdkRootPath: buildParameters.toolchain.sdkRootPath, + fileSystem: swiftCommandState.fileSystem, + modulesGraph: packageGraph, + observabilityScope: swiftCommandState.observabilityScope, + callbackQueue: delegateQueue, + delegate: pluginDelegate + ) - return pluginDelegate.lineBufferedOutput - } + return pluginDelegate.lineBufferedOutput + } // first save current activeWorkspace //second switch activeWorkspace to the template Path @@ -462,23 +574,23 @@ extension SwiftPackageCommand { } extension InitPackage.PackageType: ExpressibleByArgument { - init(from templateType: TargetDescription.TemplateType) throws { - switch templateType { - case .executable: - self = .executable - case .library: - self = .library - case .tool: - self = .tool - case .macro: - self = .macro - case .buildToolPlugin: - self = .buildToolPlugin - case .commandPlugin: - self = .commandPlugin - case .empty: - self = .empty - } + init(from templateType: TargetDescription.TemplateType) throws { + switch templateType { + case .executable: + self = .executable + case .library: + self = .library + case .tool: + self = .tool + case .macro: + self = .macro + case .buildToolPlugin: + self = .buildToolPlugin + case .commandPlugin: + self = .commandPlugin + case .empty: + self = .empty + } } } From 7a42dfd304608e77ddc533002e22c09dd96491f2 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 11 Jun 2025 09:04:28 -0400 Subject: [PATCH 043/225] git support + dependency mapping based on type --- Sources/Commands/PackageCommands/Init.swift | 212 +++++++++++--------- Sources/SourceControl/GitRepository.swift | 51 +++++ Sources/Workspace/InitTemplatePackage.swift | 10 +- 3 files changed, 177 insertions(+), 96 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 1fcb55473e3..236a6e8fea8 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -120,58 +120,9 @@ extension SwiftPackageCommand { case .git: - var requirements : [PackageDependency.SourceControl.Requirement] = [] - - if let exact { - requirements.append(.exact(exact)) - } - - if let branch { - requirements.append(.branch(branch)) - } - - if let revision { - requirements.append(.revision(revision)) - } - - if let from { - requirements.append(.range(.upToNextMajor(from: from))) - } - - if let upToNextMinorFrom { - requirements.append(.range(.upToNextMinor(from: upToNextMinorFrom))) - } - - if requirements.count > 1 { - throw StringError( - "must specify at most one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" - ) - } - - guard let firstRequirement = requirements.first else { - throw StringError( - "must specify one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" - ) - } - - let requirement: PackageDependency.SourceControl.Requirement - if case .range(let range) = firstRequirement { - if let to { - requirement = .range(range.lowerBound ..< to) - } else { - requirement = .range(range) - } - } else { - requirement = firstRequirement - - if self.to != nil { - throw StringError("--to can only be specified with --from or --up-to-next-minor-from") - } - } - + let requirement = try checkRequirements() if let templateURL = templateURL{ - print(try await getPackageFromGit(destination: templateURL, requirement: requirement)) - throw StringError("did not specify template URL") + return try await getPackageFromGit(destination: templateURL, requirement: requirement) } else { throw StringError("did not specify template URL") } @@ -205,6 +156,10 @@ extension SwiftPackageCommand { return try await checkConditions(swiftCommandState) } + if templateType == .git { + try FileManager.default.removeItem(at: resolvedTemplatePath.asURL) + } + var supportedTemplateTestingLibraries = Set() if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || (templateInitType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { @@ -215,10 +170,25 @@ extension SwiftPackageCommand { supportedTemplateTestingLibraries.insert(.swiftTesting) } + func packageDependency() throws -> MappablePackageDependency.Kind { + switch templateType { + case .local: + return .fileSystem(name: packageName, path: resolvedTemplatePath.asURL.path) + + case .git: + guard let url = templateURL else { + throw StringError("Missing Git url") + } + return try .sourceControl(name: packageName, location: url, requirement: checkRequirements()) + + default: + throw StringError("Not implemented yet") + } + } let initTemplatePackage = try InitTemplatePackage( name: packageName, templateName: template, - initMode: templateType ?? .local, + initMode: packageDependency(), templatePath: resolvedTemplatePath, fileSystem: swiftCommandState.fileSystem, packageType: templateInitType, @@ -311,61 +281,121 @@ extension SwiftPackageCommand { } } - func getPackageFromGit(destination: String, requirement: PackageDependency.SourceControl.Requirement) async throws -> Basics.AbsolutePath { + func checkRequirements() throws -> PackageDependency.SourceControl.Requirement { + var requirements : [PackageDependency.SourceControl.Requirement] = [] + + if let exact { + requirements.append(.exact(exact)) + } + + if let branch { + requirements.append(.branch(branch)) + } + + if let revision { + requirements.append(.revision(revision)) + } + + if let from { + requirements.append(.range(.upToNextMajor(from: from))) + } + + if let upToNextMinorFrom { + requirements.append(.range(.upToNextMinor(from: upToNextMinorFrom))) + } + + if requirements.count > 1 { + throw StringError( + "must specify at most one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" + ) + } + + guard let firstRequirement = requirements.first else { + throw StringError( + "must specify one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" + ) + } + + let requirement: PackageDependency.SourceControl.Requirement + if case .range(let range) = firstRequirement { + if let to { + requirement = .range(range.lowerBound ..< to) + } else { + requirement = .range(range) + } + } else { + requirement = firstRequirement + + if self.to != nil { + throw StringError("--to can only be specified with --from or --up-to-next-minor-from") + } + } + return requirement + + } + func getPackageFromGit( + destination: String, + requirement: PackageDependency.SourceControl.Requirement + ) async throws -> Basics.AbsolutePath { let repositoryProvider = GitRepositoryProvider() - let fetchStandalonePackageByURL = {() async throws -> Basics.AbsolutePath in - let url = SourceControlURL(destination) - return try withTemporaryDirectory(removeTreeOnDeinit: true) { (tempDir: Basics.AbsolutePath) in - let tempPath = tempDir.appending(component: url.lastPathComponent) + let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in + try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in + let url = SourceControlURL(destination) let repositorySpecifier = RepositorySpecifier(url: url) - try repositoryProvider.fetch( - repository: repositorySpecifier, - to: tempPath, - progressHandler: nil - ) + // This is the working clone destination + let bareCopyPath = tempDir.appending(component: "bare-copy") - guard try repositoryProvider.isValidDirectory(tempPath), - let repository = repositoryProvider.open( - repository: repositorySpecifier, - at: tempPath - ) as? GitRepository else { - throw InternalError("Invalid directory at \(tempPath)") - } + let workingCopyPath = tempDir.appending(component: "working-copy") - // If requirement is a range, find the latest tag that matches - switch requirement { - case .range(let versionRange): - let tags = try repository.getTags() - let versions = tags.compactMap { Version($0) } - // Filter versions within range - let filteredVersions = versions.filter { versionRange.contains($0) } - guard let latestVersion = filteredVersions.max() else { - throw InternalError("No tags found within the specified version range \(versionRange)") - } - // Checkout the latest version tag - try repository.checkout(tag: latestVersion.description) + try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) - case .exact(let exactVersion): - try repository.checkout(tag: exactVersion.description) + try FileManager.default.createDirectory(atPath: workingCopyPath.pathString, withIntermediateDirectories: true) - case .branch(let branchName): + // Validate directory (now should exist) + guard try repositoryProvider.isValidDirectory(bareCopyPath) else { + throw InternalError("Invalid directory at \(workingCopyPath)") + } - throw InternalError("No branch option available for fetching a single commit") - case .revision(let revision): - try repository.checkout(revision: .init(identifier: revision)) - } - // Return the absolute path of the fetched git repository - return tempPath - } + try repositoryProvider.createWorkingCopyFromBare(repository: repositorySpecifier, sourcePath: bareCopyPath, at: workingCopyPath, editable: true) + + + try FileManager.default.removeItem(at: bareCopyPath.asURL) + + return workingCopyPath + + // checkout according to requirement + /*switch requirement { + case .range(let versionRange): + let tags = try repository.getTags() + let versions = tags.compactMap { Version($0) } + let filteredVersions = versions.filter { versionRange.contains($0) } + guard let latestVersion = filteredVersions.max() else { + throw InternalError("No tags found within the specified version range \(versionRange)") + } + try repository.checkout(tag: latestVersion.description) + + case .exact(let exactVersion): + try repository.checkout(tag: exactVersion.description) + + case .branch(let branchName): + throw InternalError("No branch option available for fetching a single commit") + + case .revision(let revision): + try repository.checkout(revision: .init(identifier: revision)) + } + + */ + } } return try await fetchStandalonePackageByURL() } + static func run( plugin: ResolvedModule, package: ResolvedPackage, diff --git a/Sources/SourceControl/GitRepository.swift b/Sources/SourceControl/GitRepository.swift index 2b6bebb9955..0fa07e97459 100644 --- a/Sources/SourceControl/GitRepository.swift +++ b/Sources/SourceControl/GitRepository.swift @@ -154,6 +154,8 @@ public struct GitRepositoryProvider: RepositoryProvider, Cancellable { ) } + + private func clone( _ repository: RepositorySpecifier, _ origin: String, @@ -224,6 +226,55 @@ public struct GitRepositoryProvider: RepositoryProvider, Cancellable { } } + public func createWorkingCopyFromBare( + repository: RepositorySpecifier, + sourcePath: Basics.AbsolutePath, + at destinationPath: Basics.AbsolutePath, + editable: Bool + ) throws -> WorkingCheckout { + + if editable { + // For editable clones, i.e. the user is expected to directly work on them, first we create + // a clone from our cache of repositories and then we replace the remote to the one originally + // present in the bare repository. + + try self.clone( + repository, + sourcePath.pathString, + destinationPath.pathString, + [] + ) + + // The default name of the remote. + let origin = "origin" + // In destination repo remove the remote which will be pointing to the source repo. + let clone = GitRepository(git: self.git, path: destinationPath) + // Set the original remote to the new clone. + try clone.setURL(remote: origin, url: repository.location.gitURL) + // FIXME: This is unfortunate that we have to fetch to update remote's data. + try clone.fetch() + } else { + // Clone using a shared object store with the canonical copy. + // + // We currently expect using shared storage here to be safe because we + // only ever expect to attempt to use the working copy to materialize a + // revision we selected in response to dependency resolution, and if we + // re-resolve such that the objects in this repository changed, we would + // only ever expect to get back a revision that remains present in the + // object storage. + + try self.clone( + repository, + sourcePath.pathString, + destinationPath.pathString, + ["--shared"] + ) + } + return try self.openWorkingCopy(at: destinationPath) + + } + + public func createWorkingCopy( repository: RepositorySpecifier, sourcePath: Basics.AbsolutePath, diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 2a8a4a50ba1..71675eef6cc 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -22,7 +22,7 @@ import ArgumentParserToolInfo public final class InitTemplatePackage { - var initMode: TemplateType + let packageDependency: MappablePackageDependency.Kind public var supportedTestingLibraries: Set @@ -85,7 +85,7 @@ public final class InitTemplatePackage { public init( name: String, templateName: String, - initMode: TemplateType, + initMode: MappablePackageDependency.Kind, templatePath: Basics.AbsolutePath, fileSystem: FileSystem, packageType: InitPackage.PackageType, @@ -94,7 +94,7 @@ public final class InitTemplatePackage { installedSwiftPMConfiguration: InstalledSwiftPMConfiguration, ) { self.packageName = name - self.initMode = initMode + self.packageDependency = initMode self.templatePath = templatePath self.packageType = packageType self.supportedTestingLibraries = supportedTestingLibraries @@ -130,7 +130,7 @@ public final class InitTemplatePackage { do { manifestContents = try fileSystem.readFileContents(manifestPath) } catch { - throw StringError("Cannot fin package manifest in \(manifestPath)") + throw StringError("Cannot find package manifest in \(manifestPath)") } let manifestSyntax = manifestContents.withData { data in @@ -142,7 +142,7 @@ public final class InitTemplatePackage { } let editResult = try AddPackageDependency.addPackageDependency( - .fileSystem(name: nil, path: self.templatePath.pathString), to: manifestSyntax) + packageDependency, to: manifestSyntax) try editResult.applyEdits(to: fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: false) } From 3b2c146d51fb4e77d7e53625eb0fc1191af5a6cd Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 11 Jun 2025 10:00:42 -0400 Subject: [PATCH 044/225] added requirements + ability to checkout a branch without creating new one --- Sources/Commands/PackageCommands/Init.swift | 49 +++++++++---------- Sources/SourceControl/GitRepository.swift | 14 ++++++ Sources/SourceControl/Repository.swift | 3 ++ .../InMemoryGitRepository.swift | 6 +++ 4 files changed, 46 insertions(+), 26 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 302214f754d..393a0f5677c 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -360,36 +360,33 @@ extension SwiftPackageCommand { - try repositoryProvider.createWorkingCopyFromBare(repository: repositorySpecifier, sourcePath: bareCopyPath, at: workingCopyPath, editable: true) + let repository = try repositoryProvider.createWorkingCopyFromBare(repository: repositorySpecifier, sourcePath: bareCopyPath, at: workingCopyPath, editable: true) try FileManager.default.removeItem(at: bareCopyPath.asURL) - return workingCopyPath + switch requirement { + case .range(let versionRange): + let tags = try repository.getTags() + let versions = tags.compactMap { Version($0) } + let filteredVersions = versions.filter { versionRange.contains($0) } + guard let latestVersion = filteredVersions.max() else { + throw InternalError("No tags found within the specified version range \(versionRange)") + } + try repository.checkout(tag: latestVersion.description) + + case .exact(let exactVersion): + try repository.checkout(tag: exactVersion.description) + + case .branch(let branchName): + try repository.checkout(branch: branchName) - // checkout according to requirement - /*switch requirement { - case .range(let versionRange): - let tags = try repository.getTags() - let versions = tags.compactMap { Version($0) } - let filteredVersions = versions.filter { versionRange.contains($0) } - guard let latestVersion = filteredVersions.max() else { - throw InternalError("No tags found within the specified version range \(versionRange)") - } - try repository.checkout(tag: latestVersion.description) - - case .exact(let exactVersion): - try repository.checkout(tag: exactVersion.description) - - case .branch(let branchName): - throw InternalError("No branch option available for fetching a single commit") - - case .revision(let revision): - try repository.checkout(revision: .init(identifier: revision)) - } - - */ - } + case .revision(let revision): + try repository.checkout(revision: .init(identifier: revision)) + } + + return workingCopyPath + } } return try await fetchStandalonePackageByURL() @@ -586,7 +583,7 @@ extension SwiftPackageCommand { for product in products { for targetName in product.targets { - if let target = targets.first(where: { _ in template == targetName }) { + if let target = targets.first(where: { $0.name == template }) { if let options = target.templateInitializationOptions { if case let .packageInit(templateType, _, _) = options { diff --git a/Sources/SourceControl/GitRepository.swift b/Sources/SourceControl/GitRepository.swift index 0fa07e97459..a210001aee3 100644 --- a/Sources/SourceControl/GitRepository.swift +++ b/Sources/SourceControl/GitRepository.swift @@ -772,6 +772,20 @@ public final class GitRepository: Repository, WorkingCheckout { } } + public func checkout(branch: String) throws { + guard self.isWorkingRepo else { + throw InternalError("This operation is only valid in a working repository") + } + // use barrier for write operations + try self.lock.withLock { + try callGit( + "checkout", + branch, + failureMessage: "Couldn't check out branch '\(branch)'" + ) + } + } + public func archive(to path: AbsolutePath) throws { guard self.isWorkingRepo else { throw InternalError("This operation is only valid in a working repository") diff --git a/Sources/SourceControl/Repository.swift b/Sources/SourceControl/Repository.swift index a86227b7271..f8e899ba929 100644 --- a/Sources/SourceControl/Repository.swift +++ b/Sources/SourceControl/Repository.swift @@ -268,6 +268,9 @@ public protocol WorkingCheckout { /// Note: It is an error to provide a branch name which already exists. func checkout(newBranch: String) throws + /// Checkout out the given branch + func checkout(branch: String) throws + /// Returns true if there is an alternative store in the checkout and it is valid. func isAlternateObjectStoreValid(expected: AbsolutePath) -> Bool diff --git a/Sources/_InternalTestSupport/InMemoryGitRepository.swift b/Sources/_InternalTestSupport/InMemoryGitRepository.swift index 6de8a0eec0a..3ae11a680b8 100644 --- a/Sources/_InternalTestSupport/InMemoryGitRepository.swift +++ b/Sources/_InternalTestSupport/InMemoryGitRepository.swift @@ -383,6 +383,12 @@ extension InMemoryGitRepository: WorkingCheckout { } } + public func checkout(branch: String) throws { + self.lock.withLock { + self.history[branch] = head + } + } + public func isAlternateObjectStoreValid(expected: AbsolutePath) -> Bool { return true } From 0c6ab10c32e1a007a480f06c0b70cbc139d7579e Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 11 Jun 2025 10:32:01 -0400 Subject: [PATCH 045/225] added git functionality to show-templates --- .../PackageCommands/ShowTemplates.swift | 168 +++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index b304db93d8a..97c2d62649a 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -17,6 +17,9 @@ import Foundation import PackageModel import PackageGraph import Workspace +import TSCUtility +import TSCBasic +import SourceControl struct ShowTemplates: AsyncSwiftCommand { static let configuration = CommandConfiguration( @@ -25,11 +28,60 @@ struct ShowTemplates: AsyncSwiftCommand { @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions + @Option(name: .customLong("template-url"), help: "The git URL of the template.") + var templateURL: String? + + // Git-specific options + @Option(help: "The exact package version to depend on.") + var exact: Version? + + @Option(help: "The specific package revision to depend on.") + var revision: String? + + @Option(help: "The branch of the package to depend on.") + var branch: String? + + @Option(help: "The package version to depend on (up to the next major version).") + var from: Version? + + @Option(help: "The package version to depend on (up to the next minor version).") + var upToNextMinorFrom: Version? + + @Option(help: "Specify upper bound on the package version range (exclusive).") + var to: Version? + @Option(help: "Set the output format.") var format: ShowTemplatesMode = .flatlist func run(_ swiftCommandState: SwiftCommandState) async throws { - let packageGraph = try await swiftCommandState.loadPackageGraph() + + let packagePath: Basics.AbsolutePath + var deleteAfter = false + + // Use local current directory or fetch Git package + if let templateURL = self.templateURL { + let requirement = try checkRequirements() + packagePath = try await getPackageFromGit(destination: templateURL, requirement: requirement) + deleteAfter = true + } else { + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("No template URL provided and no current directory") + } + packagePath = cwd + } + + defer { + if deleteAfter { + try? FileManager.default.removeItem(atPath: packagePath.pathString) + } + } + + + let packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: packagePath) { workspace, root in + return try await swiftCommandState.loadPackageGraph() + + } + let rootPackages = packageGraph.rootPackages.map { $0.identity } let templates = packageGraph.allModules.filter({ @@ -87,4 +139,118 @@ struct ShowTemplates: AsyncSwiftCommand { } } } + + func getPackageFromGit( + destination: String, + requirement: PackageDependency.SourceControl.Requirement + ) async throws -> Basics.AbsolutePath { + let repositoryProvider = GitRepositoryProvider() + + let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in + try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in + let url = SourceControlURL(destination) + let repositorySpecifier = RepositorySpecifier(url: url) + + // This is the working clone destination + let bareCopyPath = tempDir.appending(component: "bare-copy") + + let workingCopyPath = tempDir.appending(component: "working-copy") + + try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) + + try FileManager.default.createDirectory(atPath: workingCopyPath.pathString, withIntermediateDirectories: true) + + // Validate directory (now should exist) + guard try repositoryProvider.isValidDirectory(bareCopyPath) else { + throw InternalError("Invalid directory at \(workingCopyPath)") + } + + + + let repository = try repositoryProvider.createWorkingCopyFromBare(repository: repositorySpecifier, sourcePath: bareCopyPath, at: workingCopyPath, editable: true) + + + try FileManager.default.removeItem(at: bareCopyPath.asURL) + + switch requirement { + case .range(let versionRange): + let tags = try repository.getTags() + let versions = tags.compactMap { Version($0) } + let filteredVersions = versions.filter { versionRange.contains($0) } + guard let latestVersion = filteredVersions.max() else { + throw InternalError("No tags found within the specified version range \(versionRange)") + } + try repository.checkout(tag: latestVersion.description) + + case .exact(let exactVersion): + try repository.checkout(tag: exactVersion.description) + + case .branch(let branchName): + try repository.checkout(branch: branchName) + + case .revision(let revision): + try repository.checkout(revision: .init(identifier: revision)) + } + + return workingCopyPath + } + } + + return try await fetchStandalonePackageByURL() + } + + + func checkRequirements() throws -> PackageDependency.SourceControl.Requirement { + var requirements : [PackageDependency.SourceControl.Requirement] = [] + + if let exact { + requirements.append(.exact(exact)) + } + + if let branch { + requirements.append(.branch(branch)) + } + + if let revision { + requirements.append(.revision(revision)) + } + + if let from { + requirements.append(.range(.upToNextMajor(from: from))) + } + + if let upToNextMinorFrom { + requirements.append(.range(.upToNextMinor(from: upToNextMinorFrom))) + } + + if requirements.count > 1 { + throw StringError( + "must specify at most one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" + ) + } + + guard let firstRequirement = requirements.first else { + throw StringError( + "must specify one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" + ) + } + + let requirement: PackageDependency.SourceControl.Requirement + if case .range(let range) = firstRequirement { + if let to { + requirement = .range(range.lowerBound ..< to) + } else { + requirement = .range(range) + } + } else { + requirement = firstRequirement + + if self.to != nil { + throw StringError("--to can only be specified with --from or --up-to-next-minor-from") + } + } + return requirement + + } + } From 7698033e9c3114dcf508d291d25aca5121dbcf31 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 11 Jun 2025 13:55:32 -0400 Subject: [PATCH 046/225] refactoring and organizing code --- Sources/Commands/PackageCommands/Init.swift | 590 ++++-------------- .../RequirementResolver.swift | 46 ++ .../_InternalInitSupport/TemplateBuild.swift | 42 ++ .../TemplatePathResolver.swift | 137 ++++ .../TemplatePluginRunner.swift | 173 +++++ .../TestingLibrarySupport.swift | 39 ++ 6 files changed, 566 insertions(+), 461 deletions(-) create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 393a0f5677c..b0c82e97e8f 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -35,13 +35,13 @@ extension SwiftPackageCommand { public static let configuration = CommandConfiguration( abstract: "Initialize a new package.", ) - + @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions - + @OptionGroup(visibility: .hidden) var buildOptions: BuildCommandOptions - + @Option( name: .customLong("type"), help: ArgumentHelp("Package type:", discussion: """ @@ -56,214 +56,61 @@ extension SwiftPackageCommand { empty - An empty package with a Package.swift manifest. """)) var initMode: InitPackage.PackageType = .library - + /// Which testing libraries to use (and any related options.) @OptionGroup() var testLibraryOptions: TestLibraryOptions - + @Option(name: .customLong("name"), help: "Provide custom package name.") var packageName: String? - + // This command should support creating the supplied --package-path if it isn't created. var createPackagePath = true - + @Option(name: .customLong("template"), help: "Name of a template to initialize the package.") var template: String = "" var useTemplates: Bool { !template.isEmpty } - + @Option(name: .customLong("template-type"), help: "Template type: registry, git, local.") var templateType: InitTemplatePackage.TemplateType? - + @Option(name: .customLong("template-path"), help: "Path to the local template.", completion: .directory) var templateDirectory: Basics.AbsolutePath? - + @Option(name: .customLong("template-url"), help: "The git URL of the template.") var templateURL: String? - + // Git-specific options @Option(help: "The exact package version to depend on.") var exact: Version? - + @Option(help: "The specific package revision to depend on.") var revision: String? - + @Option(help: "The branch of the package to depend on.") var branch: String? - + @Option(help: "The package version to depend on (up to the next major version).") var from: Version? - + @Option(help: "The package version to depend on (up to the next minor version).") var upToNextMinorFrom: Version? - + @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? - - //swift package init --template woof --template-type local --template-path path here - - //first check the path and see if the template woof is actually there - //if yes, build and get the templateInitializationOptions from it - // read templateInitializationOptions and parse permissions + type of package to initialize - // once read, initialize barebones package with what is needed, and add dependency to local template product - // swift build, then call --experimental-dump-help on the product - // prompt user - // run the executable with the command line stuff - - /// Returns the resolved template path for a given template source. - func resolveTemplatePath() async throws -> Basics.AbsolutePath { - switch templateType { - case .local: - guard let path = templateDirectory else { - throw InternalError("Template path must be specified for local templates.") - } - return path - - case .git: - - let requirement = try checkRequirements() - if let templateURL = templateURL{ - return try await getPackageFromGit(destination: templateURL, requirement: requirement) - } else { - throw StringError("did not specify template URL") - } - - case .registry: - // TODO: Lookup and download from registry - throw StringError("Registry-based templates not yet implemented") - - case .none: - throw InternalError("Missing --template-type for --template") - } - } - - - - //first, + func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") } - + let packageName = self.packageName ?? cwd.basename - - // Testing is on by default, with XCTest only enabled explicitly. - // For macros this is reversed, since we don't support testing - // macros with Swift Testing yet. + if useTemplates { - let resolvedTemplatePath = try await resolveTemplatePath() - - let templateInitType = try await swiftCommandState.withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { workspace, root in - return try await checkConditions(swiftCommandState) - } - - if templateType == .git { - try FileManager.default.removeItem(at: resolvedTemplatePath.asURL) - } - - var supportedTemplateTestingLibraries = Set() - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (templateInitType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { - supportedTemplateTestingLibraries.insert(.xctest) - } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (templateInitType != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { - supportedTemplateTestingLibraries.insert(.swiftTesting) - } - - func packageDependency() throws -> MappablePackageDependency.Kind { - switch templateType { - case .local: - return .fileSystem(name: packageName, path: resolvedTemplatePath.asURL.path) - - case .git: - guard let url = templateURL else { - throw StringError("Missing Git url") - } - return try .sourceControl(name: packageName, location: url, requirement: checkRequirements()) - - default: - throw StringError("Not implemented yet") - } - } - let initTemplatePackage = try InitTemplatePackage( - name: packageName, - templateName: template, - initMode: packageDependency(), - templatePath: resolvedTemplatePath, - fileSystem: swiftCommandState.fileSystem, - packageType: templateInitType, - supportedTestingLibraries: supportedTemplateTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration - ) - - try initTemplatePackage.setupTemplateManifest() - - // Build system setup - let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in - try await swiftCommandState.createBuildSystem( - explicitProduct: buildOptions.product, - traitConfiguration: .init(traitOptions: buildOptions.traits), - shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: swiftCommandState.toolsBuildParameters, - outputStream: TSCBasic.stdoutStream - ) - } - - guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { - throw ExitCode.failure - } - - try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in - do { - try await buildSystem.build(subset: subset) - } catch _ as Diagnostics { - throw ExitCode.failure - } - } - - let packageGraph = try await swiftCommandState.loadPackageGraph() - let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) - - - - let output = try await Self.run( - plugin: matchingPlugins[0], - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: ["--", "--experimental-dump-help"], - swiftCommandState: swiftCommandState - ) - - let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let response = try initTemplatePackage.promptUser(tool: toolInfo) - do { - - let _ = try await Self.run( - plugin: matchingPlugins[0], - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: response, - swiftCommandState: swiftCommandState - ) - - - } - + try await runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) } else { - - var supportedTestingLibraries = Set() - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.xctest) - } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.swiftTesting) - } - - + let supportedTestingLibraries = computeSupportedTestingLibraries(for: testLibraryOptions, initMode: initMode, swiftCommandState: swiftCommandState) + let initPackage = try InitPackage( name: packageName, packageType: initMode, @@ -272,304 +119,105 @@ extension SwiftPackageCommand { installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, fileSystem: swiftCommandState.fileSystem ) - + initPackage.progressReporter = { message in print(message) } - + try initPackage.writePackageStructure() } } - - func checkRequirements() throws -> PackageDependency.SourceControl.Requirement { - var requirements : [PackageDependency.SourceControl.Requirement] = [] - - if let exact { - requirements.append(.exact(exact)) - } - - if let branch { - requirements.append(.branch(branch)) - } - - if let revision { - requirements.append(.revision(revision)) - } - - if let from { - requirements.append(.range(.upToNextMajor(from: from))) - } - - if let upToNextMinorFrom { - requirements.append(.range(.upToNextMinor(from: upToNextMinorFrom))) - } - - if requirements.count > 1 { - throw StringError( - "must specify at most one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" - ) - } - - guard let firstRequirement = requirements.first else { - throw StringError( - "must specify one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" - ) + + private func runTemplateInit(swiftCommandState: SwiftCommandState, packageName: String, cwd: Basics.AbsolutePath) async throws { + + let resolvedTemplatePath: Basics.AbsolutePath + var requirement: PackageDependency.SourceControl.Requirement? + + switch self.templateType { + case .git: + requirement = try DependencyRequirementResolver( + exact: self.exact, + revision: self.revision, + branch: self.branch, + from: self.from, + upToNextMinorFrom: self.upToNextMinorFrom, + to: self.to + ).resolve() + + resolvedTemplatePath = try await TemplatePathResolver( + templateType: self.templateType, + templateDirectory: self.templateDirectory, + templateURL: self.templateURL, + requirement: requirement + ).resolve() + + case .local, .registry: + resolvedTemplatePath = try await TemplatePathResolver( + templateType: self.templateType, + templateDirectory: self.templateDirectory, + templateURL: self.templateURL, + requirement: nil + ).resolve() + + case .none: + throw StringError("Missing template type") } - - let requirement: PackageDependency.SourceControl.Requirement - if case .range(let range) = firstRequirement { - if let to { - requirement = .range(range.lowerBound ..< to) - } else { - requirement = .range(range) - } - } else { - requirement = firstRequirement - - if self.to != nil { - throw StringError("--to can only be specified with --from or --up-to-next-minor-from") - } + + let templateInitType = try await swiftCommandState.withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { workspace, root in + return try await checkConditions(swiftCommandState) } - return requirement - - } - func getPackageFromGit( - destination: String, - requirement: PackageDependency.SourceControl.Requirement - ) async throws -> Basics.AbsolutePath { - let repositoryProvider = GitRepositoryProvider() - - let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in - try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in - let url = SourceControlURL(destination) - let repositorySpecifier = RepositorySpecifier(url: url) - - // This is the working clone destination - let bareCopyPath = tempDir.appending(component: "bare-copy") - - let workingCopyPath = tempDir.appending(component: "working-copy") - - try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) - - try FileManager.default.createDirectory(atPath: workingCopyPath.pathString, withIntermediateDirectories: true) - - // Validate directory (now should exist) - guard try repositoryProvider.isValidDirectory(bareCopyPath) else { - throw InternalError("Invalid directory at \(workingCopyPath)") - } - - - - let repository = try repositoryProvider.createWorkingCopyFromBare(repository: repositorySpecifier, sourcePath: bareCopyPath, at: workingCopyPath, editable: true) - - - try FileManager.default.removeItem(at: bareCopyPath.asURL) - - switch requirement { - case .range(let versionRange): - let tags = try repository.getTags() - let versions = tags.compactMap { Version($0) } - let filteredVersions = versions.filter { versionRange.contains($0) } - guard let latestVersion = filteredVersions.max() else { - throw InternalError("No tags found within the specified version range \(versionRange)") - } - try repository.checkout(tag: latestVersion.description) - - case .exact(let exactVersion): - try repository.checkout(tag: exactVersion.description) - - case .branch(let branchName): - try repository.checkout(branch: branchName) - - case .revision(let revision): - try repository.checkout(revision: .init(identifier: revision)) - } - - return workingCopyPath - } + + if templateType == .git { + try FileManager.default.removeItem(at: resolvedTemplatePath.asURL) } - - return try await fetchStandalonePackageByURL() - } - - - static func run( - plugin: ResolvedModule, - package: ResolvedPackage, - packageGraph: ModulesGraph, - allowNetworkConnections: [SandboxNetworkPermission] = [], - arguments: [String], - swiftCommandState: SwiftCommandState - ) async throws -> Data { - let pluginTarget = plugin.underlying as! PluginModule - - // The `plugins` directory is inside the workspace's main data directory, and contains all temporary files related to this plugin in the workspace. - let pluginsDir = try swiftCommandState.getActiveWorkspace().location.pluginWorkingDirectory - .appending(component: plugin.name) - - // The `cache` directory is in the plugin’s directory and is where the plugin script runner caches compiled plugin binaries and any other derived information for this plugin. - let pluginScriptRunner = try swiftCommandState.getPluginScriptRunner( - customPluginsDir: pluginsDir + + let supportedTemplateTestingLibraries = computeSupportedTestingLibraries(for: testLibraryOptions, initMode: templateInitType, swiftCommandState: swiftCommandState) + + let initTemplatePackage = try InitTemplatePackage( + name: packageName, + templateName: template, + initMode: packageDependency(requirement: requirement, resolvedTemplatePath: resolvedTemplatePath), + templatePath: resolvedTemplatePath, + fileSystem: swiftCommandState.fileSystem, + packageType: templateInitType, + supportedTestingLibraries: supportedTemplateTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration ) - - // The `outputs` directory contains subdirectories for each combination of package and command plugin. Each usage of a plugin has an output directory that is writable by the plugin, where it can write additional files, and to which it can configure tools to write their outputs, etc. - let outputDir = pluginsDir.appending("outputs") - - // Determine the set of directories under which plugins are allowed to write. We always include the output directory. - var writableDirectories = [outputDir] - - // FIXME: decide whether this permission needs to be explicitly requested by the plugin target, or not - writableDirectories.append(package.path) - - var allowNetworkConnections = allowNetworkConnections - - // If the plugin requires permissions, we ask the user for approval. - if case .command(_, let permissions) = pluginTarget.capability { - try permissions.forEach { - let permissionString: String - let reasonString: String - let remedyOption: String - - switch $0 { - case .writeToPackageDirectory(let reason): - //guard !options.allowWritingToPackageDirectory else { return } // permission already granted - permissionString = "write to the package directory" - reasonString = reason - remedyOption = "--allow-writing-to-package-directory" - case .allowNetworkConnections(let scope, let reason): - guard scope != .none else { return } // no need to prompt - //guard options.allowNetworkConnections != .init(scope) else { return } // permission already granted - - switch scope { - case .all, .local: - let portsString = scope.ports - .isEmpty ? "on all ports" : - "on ports: \(scope.ports.map { "\($0)" }.joined(separator: ", "))" - permissionString = "allow \(scope.label) network connections \(portsString)" - case .docker, .unixDomainSocket: - permissionString = "allow \(scope.label) connections" - case .none: - permissionString = "" // should not be reached - } - - reasonString = reason - // FIXME compute the correct reason for the type of network connection - remedyOption = - "--allow-network-connections 'Network connection is needed'" - } - - let problem = "Plugin ‘\(plugin.name)’ wants permission to \(permissionString)." - let reason = "Stated reason: “\(reasonString)”." - if swiftCommandState.outputStream.isTTY { - // We can ask the user directly, so we do so. - let query = "Allow this plugin to \(permissionString)?" - swiftCommandState.outputStream.write("\(problem)\n\(reason)\n\(query) (yes/no) ".utf8) - swiftCommandState.outputStream.flush() - let answer = readLine(strippingNewline: true) - // Throw an error if we didn't get permission. - if answer?.lowercased() != "yes" { - throw StringError("Plugin was denied permission to \(permissionString).") - } - } else { - // We can't ask the user, so emit an error suggesting passing the flag. - let remedy = "Use `\(remedyOption)` to allow this." - throw StringError([problem, reason, remedy].joined(separator: "\n")) - } - - switch $0 { - case .writeToPackageDirectory: - // Otherwise append the directory to the list of allowed ones. - writableDirectories.append(package.path) - case .allowNetworkConnections(let scope, _): - allowNetworkConnections.append(.init(scope)) - } - } - } - - // Make sure that the package path is read-only unless it's covered by any of the explicitly writable directories. - let readOnlyDirectories = writableDirectories - .contains { package.path.isDescendantOfOrEqual(to: $0) } ? [] : [package.path] - - // Use the directory containing the compiler as an additional search directory, and add the $PATH. - let toolSearchDirs = [try swiftCommandState.getTargetToolchain().swiftCompilerPath.parentDirectory] - + getEnvSearchPaths(pathString: Environment.current[.path], currentWorkingDirectory: .none) - - let buildParameters = try swiftCommandState.toolsBuildParameters - // Build or bring up-to-date any executable host-side tools on which this plugin depends. Add them and any binary dependencies to the tool-names-to-path map. - let buildSystem = try await swiftCommandState.createBuildSystem( - explicitBuildSystem: .native, - traitConfiguration: .init(), - cacheBuildManifest: false, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: buildParameters, - packageGraphLoader: { packageGraph } + + try initTemplatePackage.setupTemplateManifest() + + try await TemplateBuildSupport.build(swiftCommandState: swiftCommandState, buildOptions: buildOptions, globalOptions: globalOptions, cwd: cwd) + + let packageGraph = try await swiftCommandState.loadPackageGraph() + let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) + + let output = try await TemplatePluginRunner.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: ["--", "--experimental-dump-help"], + swiftCommandState: swiftCommandState ) - - let accessibleTools = try await plugin.preparePluginTools( - fileSystem: swiftCommandState.fileSystem, - environment: buildParameters.buildEnvironment, - for: try pluginScriptRunner.hostTriple - ) { name, _ in - // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies are not supported within a package, so if the tool happens to be from the same package, we instead find the executable that corresponds to the product. There is always one, because of autogeneration of implicit executables with the same name as the target if there isn't an explicit one. - try await buildSystem.build(subset: .product(name, for: .host)) - if let builtTool = try buildSystem.buildPlan.buildProducts.first(where: { - $0.product.name == name && $0.buildParameters.destination == .host - }) { - return try builtTool.binaryPath - } else { - return nil - } - } - - // Set up a delegate to handle callbacks from the command plugin. - let pluginDelegate = PluginDelegate(swiftCommandState: swiftCommandState, plugin: pluginTarget, echoOutput: false) - let delegateQueue = DispatchQueue(label: "plugin-invocation") - - // Run the command plugin. - - // TODO: use region based isolation when swift 6 is available - let writableDirectoriesCopy = writableDirectories - let allowNetworkConnectionsCopy = allowNetworkConnections - - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") - } - - guard let workingDirectory = swiftCommandState.options.locations.packageDirectory ?? swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find current working directory") + + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + let response = try initTemplatePackage.promptUser(tool: toolInfo) + do { + let _ = try await TemplatePluginRunner.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: response, + swiftCommandState: swiftCommandState + ) } - let buildEnvironment = buildParameters.buildEnvironment - try await pluginTarget.invoke( - action: .performCommand(package: package, arguments: arguments), - buildEnvironment: buildEnvironment, - scriptRunner: pluginScriptRunner, - workingDirectory: workingDirectory, - outputDirectory: outputDir, - toolSearchDirectories: toolSearchDirs, - accessibleTools: accessibleTools, - writableDirectories: writableDirectoriesCopy, - readOnlyDirectories: readOnlyDirectories, - allowNetworkConnections: allowNetworkConnectionsCopy, - pkgConfigDirectories: swiftCommandState.options.locations.pkgConfigDirectories, - sdkRootPath: buildParameters.toolchain.sdkRootPath, - fileSystem: swiftCommandState.fileSystem, - modulesGraph: packageGraph, - observabilityScope: swiftCommandState.observabilityScope, - callbackQueue: delegateQueue, - delegate: pluginDelegate - ) - - return pluginDelegate.lineBufferedOutput } - - // first save current activeWorkspace - //second switch activeWorkspace to the template Path - //third revert after conditions have been checked, (we will also get stuff needed for dpeende + private func checkConditions(_ swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType{ - + let workspace = try swiftCommandState.getActiveWorkspace() let root = try swiftCommandState.getWorkspaceRoot() - + let rootManifests = try await workspace.loadRootManifests( packages: root.packages, observabilityScope: swiftCommandState.observabilityScope @@ -577,15 +225,15 @@ extension SwiftPackageCommand { guard let rootManifest = rootManifests.values.first else { throw InternalError("invalid manifests at \(root.packages)") } - + let products = rootManifest.products let targets = rootManifest.targets - + for product in products { for targetName in product.targets { if let target = targets.first(where: { $0.name == template }) { if let options = target.templateInitializationOptions { - + if case let .packageInit(templateType, _, _) = options { return try .init(from: templateType) } @@ -595,6 +243,26 @@ extension SwiftPackageCommand { } throw InternalError("Could not find template \(template)") } + + private func packageDependency(requirement: PackageDependency.SourceControl.Requirement?, resolvedTemplatePath: Basics.AbsolutePath) throws -> MappablePackageDependency.Kind { + switch templateType { + case .local: + return .fileSystem(name: packageName, path: resolvedTemplatePath.asURL.path) + + case .git: + guard let url = templateURL else { + throw StringError("Missing Git url") + } + + guard let gitRequirement = requirement else { + throw StringError("Missing Git requirement") + } + return .sourceControl(name: packageName, location: url, requirement: gitRequirement) + + default: + throw StringError("Not implemented yet") + } + } } } @@ -617,7 +285,7 @@ extension InitPackage.PackageType: ExpressibleByArgument { self = .empty } } - + } extension InitTemplatePackage.TemplateType: ExpressibleByArgument {} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift new file mode 100644 index 00000000000..f5f0f6ccdec --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// 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 PackageModel +import TSCBasic +import TSCUtility + +struct DependencyRequirementResolver { + let exact: Version? + let revision: String? + let branch: String? + let from: Version? + let upToNextMinorFrom: Version? + let to: Version? + + func resolve() throws -> PackageDependency.SourceControl.Requirement { + var all: [PackageDependency.SourceControl.Requirement] = [] + + if let v = exact { all.append(.exact(v)) } + if let b = branch { all.append(.branch(b)) } + if let r = revision { all.append(.revision(r)) } + if let f = from { all.append(.range(.upToNextMajor(from: f))) } + if let u = upToNextMinorFrom { all.append(.range(.upToNextMinor(from: u))) } + + guard all.count == 1, let requirement = all.first else { + throw StringError("Specify exactly one version requirement.") + } + + if case .range(let range) = requirement, let upper = to { + return .range(range.lowerBound ..< upper) + } else if to != nil { + throw StringError("--to requires --from or --up-to-next-minor-from") + } + + return requirement + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift new file mode 100644 index 00000000000..ad8814ee52d --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -0,0 +1,42 @@ +// +// TemplateBuild.swift +// SwiftPM +// +// Created by John Bute on 2025-06-11. +// + + +import CoreCommands +import Basics +import TSCBasic +import ArgumentParser +import TSCUtility +import SPMBuildCore + +struct TemplateBuildSupport { + static func build(swiftCommandState: SwiftCommandState, buildOptions: BuildCommandOptions, globalOptions: GlobalOptions, cwd: Basics.AbsolutePath) async throws { + let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + try await swiftCommandState.createBuildSystem( + explicitProduct: buildOptions.product, + traitConfiguration: .init(traitOptions: buildOptions.traits), + shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: swiftCommandState.toolsBuildParameters, + outputStream: TSCBasic.stdoutStream + ) + } + + guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { + throw ExitCode.failure + } + + try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + do { + try await buildSystem.build(subset: subset) + } catch _ as Diagnostics { + throw ExitCode.failure + } + } + + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift new file mode 100644 index 00000000000..f0c2506e214 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -0,0 +1,137 @@ +//===----------------------------------------------------------------------===// +// +// 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 Workspace +import Basics +import PackageModel +import TSCBasic +import SourceControl +import Foundation +import TSCUtility + + +struct TemplatePathResolver { + let templateType: InitTemplatePackage.TemplateType? + let templateDirectory: Basics.AbsolutePath? + let templateURL: String? + let requirement: PackageDependency.SourceControl.Requirement? + + func resolve() async throws -> Basics.AbsolutePath { + switch templateType { + case .local: + guard let path = templateDirectory else { + throw StringError("Template path must be specified for local templates.") + } + return path + + case .git: + guard let url = templateURL else { + throw StringError("Missing Git URL for git template.") + } + + guard let gitRequirement = requirement else { + throw StringError("Missing version requirement for git template.") + } + + return try await GitTemplateFetcher(destination: url, requirement: gitRequirement).fetch() + + case .registry: + throw StringError("Registry templates not supported yet.") + + case .none: + throw StringError("Missing --template-type.") + } + } + + struct GitTemplateFetcher { + let destination: String + let requirement: PackageDependency.SourceControl.Requirement + + func fetch() async throws -> Basics.AbsolutePath { + + let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in + try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in + + let url = SourceControlURL(destination) + let repositorySpecifier = RepositorySpecifier(url: url) + let repositoryProvider = GitRepositoryProvider() + + + let bareCopyPath = tempDir.appending(component: "bare-copy") + + let workingCopyPath = tempDir.appending(component: "working-copy") + + try fetchBareRepository(provider: repositoryProvider, specifier: repositorySpecifier, to: bareCopyPath) + try validateDirectory(provider: repositoryProvider, at: bareCopyPath) + + + try FileManager.default.createDirectory( + atPath: workingCopyPath.pathString, + withIntermediateDirectories: true + ) + + let repository = try repositoryProvider.createWorkingCopyFromBare( + repository: repositorySpecifier, + sourcePath: bareCopyPath, + at: workingCopyPath, + editable: true + ) + + try FileManager.default.removeItem(at: bareCopyPath.asURL) + + try checkout(repository: repository) + + return workingCopyPath + } + } + + return try await fetchStandalonePackageByURL() + } + + private func fetchBareRepository( + provider: GitRepositoryProvider, + specifier: RepositorySpecifier, + to path: Basics.AbsolutePath + ) throws { + try provider.fetch(repository: specifier, to: path) + } + + private func validateDirectory(provider: GitRepositoryProvider, at path: Basics.AbsolutePath) throws { + guard try provider.isValidDirectory(path) else { + throw InternalError("Invalid directory at \(path)") + } + } + + private func checkout(repository: WorkingCheckout) throws { + switch requirement { + case .exact(let version): + try repository.checkout(tag: version.description) + + case .branch(let name): + try repository.checkout(branch: name) + + case .revision(let revision): + try repository.checkout(revision: .init(identifier: revision)) + + case .range(let range): + let tags = try repository.getTags() + let versions = tags.compactMap { Version($0) } + let filteredVersions = versions.filter { range.contains($0) } + guard let latestVersion = filteredVersions.max() else { + throw InternalError("No tags found within the specified version range \(range)") + } + try repository.checkout(tag: latestVersion.description) + } + } + + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift new file mode 100644 index 00000000000..09ad73a51d6 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift @@ -0,0 +1,173 @@ +// +// TemplatePluginRunner.swift +// SwiftPM +// +// Created by John Bute on 2025-06-11. +// + +import ArgumentParser +import Basics + +@_spi(SwiftPMInternal) +import CoreCommands + +import PackageModel +import Workspace +import SPMBuildCore +import TSCUtility + +import Foundation +import PackageGraph +import SPMBuildCore +import XCBuildSupport +import TSCBasic +import SourceControl + + +struct TemplatePluginRunner { + + static func run( + plugin: ResolvedModule, + package: ResolvedPackage, + packageGraph: ModulesGraph, + arguments: [String], + swiftCommandState: SwiftCommandState, + allowNetworkConnections: [SandboxNetworkPermission] = [] + ) async throws -> Data { + let pluginTarget = try castToPlugin(plugin) + let pluginsDir = try pluginDirectory(for: plugin.name, in: swiftCommandState) + let outputDir = pluginsDir.appending("outputs") + let pluginScriptRunner = try swiftCommandState.getPluginScriptRunner(customPluginsDir: pluginsDir) + + var writableDirs = [outputDir, package.path] + var allowedNetworkConnections = allowNetworkConnections + + try requestPluginPermissions( + from: pluginTarget, + pluginName: plugin.name, + packagePath: package.path, + writableDirectories: &writableDirs, + allowNetworkConnections: &allowedNetworkConnections, + state: swiftCommandState + ) + + let readOnlyDirs = writableDirs.contains(where: { package.path.isDescendantOfOrEqual(to: $0) }) ? [] : [package.path] + let toolSearchDirs = try defaultToolSearchDirectories(using: swiftCommandState) + + let buildParams = try swiftCommandState.toolsBuildParameters + let buildSystem = try await swiftCommandState.createBuildSystem( + explicitBuildSystem: .native, + traitConfiguration: .init(), + cacheBuildManifest: false, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: buildParams, + packageGraphLoader: { packageGraph } + ) + + let accessibleTools = try await plugin.preparePluginTools( + fileSystem: swiftCommandState.fileSystem, + environment: try swiftCommandState.toolsBuildParameters.buildEnvironment, + for: try pluginScriptRunner.hostTriple + ) { name, _ in + // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies are not supported within a package, so if the tool happens to be from the same package, we instead find the executable that corresponds to the product. There is always one, because of autogeneraxtion of implicit executables with the same name as the target if there isn't an explicit one. + try await buildSystem.build(subset: .product(name, for: .host)) + if let builtTool = try buildSystem.buildPlan.buildProducts.first(where: { + $0.product.name == name && $0.buildParameters.destination == .host + }) { + return try builtTool.binaryPath + } else { + return nil + } + } + + let delegate = PluginDelegate(swiftCommandState: swiftCommandState, plugin: pluginTarget, echoOutput: false) + + let workingDir = try swiftCommandState.options.locations.packageDirectory + ?? swiftCommandState.fileSystem.currentWorkingDirectory + ?? { throw InternalError("Could not determine working directory") }() + + try await pluginTarget.invoke( + action: .performCommand(package: package, arguments: arguments), + buildEnvironment: buildParams.buildEnvironment, + scriptRunner: pluginScriptRunner, + workingDirectory: workingDir, + outputDirectory: outputDir, + toolSearchDirectories: toolSearchDirs, + accessibleTools: accessibleTools, + writableDirectories: writableDirs, + readOnlyDirectories: readOnlyDirs, + allowNetworkConnections: allowedNetworkConnections, + pkgConfigDirectories: swiftCommandState.options.locations.pkgConfigDirectories, + sdkRootPath: buildParams.toolchain.sdkRootPath, + fileSystem: swiftCommandState.fileSystem, + modulesGraph: packageGraph, + observabilityScope: swiftCommandState.observabilityScope, + callbackQueue: DispatchQueue(label: "plugin-invocation"), + delegate: delegate + ) + + return delegate.lineBufferedOutput + } + + private static func castToPlugin(_ plugin: ResolvedModule) throws -> PluginModule { + guard let pluginTarget = plugin.underlying as? PluginModule else { + throw InternalError("Expected PluginModule") + } + return pluginTarget + } + + private static func pluginDirectory(for name: String, in state: SwiftCommandState) throws -> Basics.AbsolutePath { + try state.getActiveWorkspace().location.pluginWorkingDirectory.appending(component: name) + } + + private static func defaultToolSearchDirectories(using state: SwiftCommandState) throws -> [Basics.AbsolutePath] { + let toolchainPath = try state.getTargetToolchain().swiftCompilerPath.parentDirectory + let envPaths = Basics.getEnvSearchPaths(pathString: Environment.current[.path], currentWorkingDirectory: nil) + return [toolchainPath] + envPaths + } + + private static func requestPluginPermissions( + from plugin: PluginModule, + pluginName: String, + packagePath: Basics.AbsolutePath, + writableDirectories: inout [Basics.AbsolutePath], + allowNetworkConnections: inout [SandboxNetworkPermission], + state: SwiftCommandState + ) throws { + guard case .command(_, let permissions) = plugin.capability else { return } + + for permission in permissions { + let (desc, reason, remedy) = describe(permission) + + if state.outputStream.isTTY { + state.outputStream.write("Plugin '\(pluginName)' wants permission to \(desc).\nStated reason: “\(reason)”.\nAllow? (yes/no) ".utf8) + state.outputStream.flush() + + guard readLine()?.lowercased() == "yes" else { + throw StringError("Permission denied: \(desc)") + } + } else { + throw StringError("Plugin '\(pluginName)' requires: \(desc).\nReason: “\(reason)”.\nUse \(remedy) to allow.") + } + + switch permission { + case .writeToPackageDirectory: + writableDirectories.append(packagePath) + case .allowNetworkConnections(let scope, _): + allowNetworkConnections.append(SandboxNetworkPermission(scope)) + } + } + } + + private static func describe(_ permission: PluginPermission) -> (String, String, String) { + switch permission { + case .writeToPackageDirectory(let reason): + return ("write to the package directory", reason, "--allow-writing-to-package-directory") + case .allowNetworkConnections(let scope, let reason): + let ports = scope.ports.map(String.init).joined(separator: ", ") + let desc = scope.ports.isEmpty ? "allow \(scope.label) connections" : "allow \(scope.label) on ports: \(ports)" + return (desc, reason, "--allow-network-connections") + } + } +} + diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift b/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift new file mode 100644 index 00000000000..24588c894c4 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// +// 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 +@_spi(SwiftPMInternal) +import CoreCommands +import Workspace + +func computeSupportedTestingLibraries( + for testLibraryOptions: TestLibraryOptions, + initMode: InitPackage.PackageType, + swiftCommandState: SwiftCommandState +) -> Set { + + var supportedTemplateTestingLibraries: Set = .init() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTemplateTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTemplateTestingLibraries.insert(.swiftTesting) + } + + return supportedTemplateTestingLibraries + +} + From c9f5001727670e8d45184ac4d586052a10bd1b12 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 11 Jun 2025 13:58:36 -0400 Subject: [PATCH 047/225] organizing code to differentiate between initializing regular package and initializing template package --- Sources/Commands/PackageCommands/Init.swift | 39 +++++++++++-------- .../TemplatePluginRunner.swift | 2 +- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index b0c82e97e8f..3e6315d397b 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -109,25 +109,30 @@ extension SwiftPackageCommand { if useTemplates { try await runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) } else { - let supportedTestingLibraries = computeSupportedTestingLibraries(for: testLibraryOptions, initMode: initMode, swiftCommandState: swiftCommandState) - - let initPackage = try InitPackage( - name: packageName, - packageType: initMode, - supportedTestingLibraries: supportedTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, - fileSystem: swiftCommandState.fileSystem - ) - - initPackage.progressReporter = { message in - print(message) - } - - try initPackage.writePackageStructure() + try runPackageInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) } } - + + private func runPackageInit(swiftCommandState: SwiftCommandState, packageName: String, cwd: Basics.AbsolutePath) throws { + let supportedTestingLibraries = computeSupportedTestingLibraries(for: testLibraryOptions, initMode: initMode, swiftCommandState: swiftCommandState) + + let initPackage = try InitPackage( + name: packageName, + packageType: initMode, + supportedTestingLibraries: supportedTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, + fileSystem: swiftCommandState.fileSystem + ) + + initPackage.progressReporter = { message in + print(message) + } + + try initPackage.writePackageStructure() + + } + private func runTemplateInit(swiftCommandState: SwiftCommandState, packageName: String, cwd: Basics.AbsolutePath) async throws { let resolvedTemplatePath: Basics.AbsolutePath diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift index 09ad73a51d6..daf459b922c 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift @@ -86,7 +86,7 @@ struct TemplatePluginRunner { ?? swiftCommandState.fileSystem.currentWorkingDirectory ?? { throw InternalError("Could not determine working directory") }() - try await pluginTarget.invoke( + let _ = try await pluginTarget.invoke( action: .performCommand(package: package, arguments: arguments), buildEnvironment: buildParams.buildEnvironment, scriptRunner: pluginScriptRunner, From 824c8c6203c38db236b8ecf307cc4e08a54a70f4 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 11 Jun 2025 15:07:20 -0400 Subject: [PATCH 048/225] added documentation + reorganized show-templates code --- Sources/Commands/PackageCommands/Init.swift | 190 +++++++------ .../PackageCommands/ShowTemplates.swift | 231 ++++++---------- .../RequirementResolver.swift | 38 ++- .../_InternalInitSupport/TemplateBuild.swift | 86 ++++-- .../TemplatePathResolver.swift | 61 ++++- .../TemplatePluginRunner.swift | 88 +++++-- .../TestingLibrarySupport.swift | 28 +- Sources/SourceControl/GitRepository.swift | 1 - Sources/Workspace/InitTemplatePackage.swift | 249 ++++++++++++------ 9 files changed, 602 insertions(+), 370 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 3e6315d397b..6ea942ec214 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -17,16 +17,16 @@ import Basics import CoreCommands import PackageModel -import Workspace import SPMBuildCore import TSCUtility +import Workspace import Foundation import PackageGraph +import SourceControl import SPMBuildCore -import XCBuildSupport import TSCBasic -import SourceControl +import XCBuildSupport import ArgumentParserToolInfo @@ -35,86 +35,110 @@ extension SwiftPackageCommand { public static let configuration = CommandConfiguration( abstract: "Initialize a new package.", ) - + @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions - + @OptionGroup(visibility: .hidden) var buildOptions: BuildCommandOptions - + @Option( name: .customLong("type"), help: ArgumentHelp("Package type:", discussion: """ - library - A package with a library. - executable - A package with an executable. - tool - A package with an executable that uses - Swift Argument Parser. Use this template if you - plan to have a rich set of command-line arguments. - build-tool-plugin - A package that vends a build tool plugin. - command-plugin - A package that vends a command plugin. - macro - A package that vends a macro. - empty - An empty package with a Package.swift manifest. - """)) + library - A package with a library. + executable - A package with an executable. + tool - A package with an executable that uses + Swift Argument Parser. Use this template if you + plan to have a rich set of command-line arguments. + build-tool-plugin - A package that vends a build tool plugin. + command-plugin - A package that vends a command plugin. + macro - A package that vends a macro. + empty - An empty package with a Package.swift manifest. + """) + ) var initMode: InitPackage.PackageType = .library - + /// Which testing libraries to use (and any related options.) @OptionGroup() var testLibraryOptions: TestLibraryOptions - + + /// A custom name for the package. Defaults to the current directory name. @Option(name: .customLong("name"), help: "Provide custom package name.") var packageName: String? - + // This command should support creating the supplied --package-path if it isn't created. var createPackagePath = true - + + /// Name of a template to use for package initialization. @Option(name: .customLong("template"), help: "Name of a template to initialize the package.") var template: String = "" - var useTemplates: Bool { !template.isEmpty } - + + /// Returns true if a template is specified. + var useTemplates: Bool { !self.template.isEmpty } + + /// The type of template to use: `registry`, `git`, or `local`. @Option(name: .customLong("template-type"), help: "Template type: registry, git, local.") var templateType: InitTemplatePackage.TemplateType? - + + /// Path to a local template. @Option(name: .customLong("template-path"), help: "Path to the local template.", completion: .directory) var templateDirectory: Basics.AbsolutePath? - + + /// Git URL of the template. @Option(name: .customLong("template-url"), help: "The git URL of the template.") var templateURL: String? - - // Git-specific options + + // MARK: - Versioning Options for Remote Git Templates + + /// The exact version of the remote package to use. @Option(help: "The exact package version to depend on.") var exact: Version? - + + /// Specific revision to use (for Git templates). @Option(help: "The specific package revision to depend on.") var revision: String? - + + /// Branch name to use (for Git templates). @Option(help: "The branch of the package to depend on.") var branch: String? - + + /// Version to depend on, up to the next major version. @Option(help: "The package version to depend on (up to the next major version).") var from: Version? - + + /// Version to depend on, up to the next minor version. @Option(help: "The package version to depend on (up to the next minor version).") var upToNextMinorFrom: Version? - + + /// Upper bound on the version range (exclusive). @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? - + func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") } - + let packageName = self.packageName ?? cwd.basename - - if useTemplates { - try await runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) + + if self.useTemplates { + try await self.runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) } else { - try runPackageInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) + try self.runPackageInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) } } - private func runPackageInit(swiftCommandState: SwiftCommandState, packageName: String, cwd: Basics.AbsolutePath) throws { - let supportedTestingLibraries = computeSupportedTestingLibraries(for: testLibraryOptions, initMode: initMode, swiftCommandState: swiftCommandState) + /// Runs the standard package initialization (non-template). + private func runPackageInit( + swiftCommandState: SwiftCommandState, + packageName: String, + cwd: Basics.AbsolutePath + ) throws { + let supportedTestingLibraries = computeSupportedTestingLibraries( + for: testLibraryOptions, + initMode: initMode, + swiftCommandState: swiftCommandState + ) let initPackage = try InitPackage( name: packageName, @@ -130,14 +154,17 @@ extension SwiftPackageCommand { } try initPackage.writePackageStructure() - } - private func runTemplateInit(swiftCommandState: SwiftCommandState, packageName: String, cwd: Basics.AbsolutePath) async throws { - + /// Runs the package initialization using an author-defined template. + private func runTemplateInit( + swiftCommandState: SwiftCommandState, + packageName: String, + cwd: Basics.AbsolutePath + ) async throws { let resolvedTemplatePath: Basics.AbsolutePath var requirement: PackageDependency.SourceControl.Requirement? - + switch self.templateType { case .git: requirement = try DependencyRequirementResolver( @@ -148,14 +175,14 @@ extension SwiftPackageCommand { upToNextMinorFrom: self.upToNextMinorFrom, to: self.to ).resolve() - + resolvedTemplatePath = try await TemplatePathResolver( templateType: self.templateType, templateDirectory: self.templateDirectory, templateURL: self.templateURL, requirement: requirement ).resolve() - + case .local, .registry: resolvedTemplatePath = try await TemplatePathResolver( templateType: self.templateType, @@ -163,20 +190,28 @@ extension SwiftPackageCommand { templateURL: self.templateURL, requirement: nil ).resolve() - + case .none: throw StringError("Missing template type") } - - let templateInitType = try await swiftCommandState.withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { workspace, root in - return try await checkConditions(swiftCommandState) - } - - if templateType == .git { - try FileManager.default.removeItem(at: resolvedTemplatePath.asURL) + + let templateInitType = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in + try await self.checkConditions(swiftCommandState) + } + + // Clean up downloaded package after execution. + defer { + if templateType == .git { + try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) + } } - - let supportedTemplateTestingLibraries = computeSupportedTestingLibraries(for: testLibraryOptions, initMode: templateInitType, swiftCommandState: swiftCommandState) + + let supportedTemplateTestingLibraries = computeSupportedTestingLibraries( + for: testLibraryOptions, + initMode: templateInitType, + swiftCommandState: swiftCommandState + ) let initTemplatePackage = try InitTemplatePackage( name: packageName, @@ -189,14 +224,19 @@ extension SwiftPackageCommand { destinationPath: cwd, installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration ) - + try initTemplatePackage.setupTemplateManifest() - try await TemplateBuildSupport.build(swiftCommandState: swiftCommandState, buildOptions: buildOptions, globalOptions: globalOptions, cwd: cwd) + try await TemplateBuildSupport.build( + swiftCommandState: swiftCommandState, + buildOptions: self.buildOptions, + globalOptions: self.globalOptions, + cwd: cwd + ) let packageGraph = try await swiftCommandState.loadPackageGraph() - let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) - + let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: packageGraph, limitedTo: nil) + let output = try await TemplatePluginRunner.run( plugin: matchingPlugins[0], package: packageGraph.rootPackages.first!, @@ -204,7 +244,7 @@ extension SwiftPackageCommand { arguments: ["--", "--experimental-dump-help"], swiftCommandState: swiftCommandState ) - + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) let response = try initTemplatePackage.promptUser(tool: toolInfo) do { @@ -217,12 +257,12 @@ extension SwiftPackageCommand { ) } } - - private func checkConditions(_ swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType{ - + + /// Validates the loaded manifest to determine package type. + private func checkConditions(_ swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType { let workspace = try swiftCommandState.getActiveWorkspace() let root = try swiftCommandState.getWorkspaceRoot() - + let rootManifests = try await workspace.loadRootManifests( packages: root.packages, observabilityScope: swiftCommandState.observabilityScope @@ -230,29 +270,32 @@ extension SwiftPackageCommand { guard let rootManifest = rootManifests.values.first else { throw InternalError("invalid manifests at \(root.packages)") } - + let products = rootManifest.products let targets = rootManifest.targets - + for product in products { for targetName in product.targets { if let target = targets.first(where: { $0.name == template }) { if let options = target.templateInitializationOptions { - - if case let .packageInit(templateType, _, _) = options { + if case .packageInit(let templateType, _, _) = options { return try .init(from: templateType) } } } } } - throw InternalError("Could not find template \(template)") + throw InternalError("Could not find template \(self.template)") } - private func packageDependency(requirement: PackageDependency.SourceControl.Requirement?, resolvedTemplatePath: Basics.AbsolutePath) throws -> MappablePackageDependency.Kind { - switch templateType { + /// Transforms the author's package into the required dependency + private func packageDependency( + requirement: PackageDependency.SourceControl.Requirement?, + resolvedTemplatePath: Basics.AbsolutePath + ) throws -> MappablePackageDependency.Kind { + switch self.templateType { case .local: - return .fileSystem(name: packageName, path: resolvedTemplatePath.asURL.path) + return .fileSystem(name: self.packageName, path: resolvedTemplatePath.asURL.path) case .git: guard let url = templateURL else { @@ -262,7 +305,7 @@ extension SwiftPackageCommand { guard let gitRequirement = requirement else { throw StringError("Missing Git requirement") } - return .sourceControl(name: packageName, location: url, requirement: gitRequirement) + return .sourceControl(name: self.packageName, location: url, requirement: gitRequirement) default: throw StringError("Not implemented yet") @@ -290,7 +333,6 @@ extension InitPackage.PackageType: ExpressibleByArgument { self = .empty } } - } extension InitTemplatePackage.TemplateType: ExpressibleByArgument {} diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 97c2d62649a..65c3e25689d 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift open source project // -// Copyright (c) 2014-2025 Apple Inc. and the Swift project authors +// 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 @@ -14,89 +14,121 @@ import ArgumentParser import Basics import CoreCommands import Foundation -import PackageModel import PackageGraph -import Workspace +import PackageModel import TSCUtility -import TSCBasic -import SourceControl +import Workspace +/// A Swift command that lists the available executable templates from a package. +/// +/// The command can work with either a local package or a remote Git-based package template. +/// It supports version specification and configurable output formats (flat list or JSON). struct ShowTemplates: AsyncSwiftCommand { static let configuration = CommandConfiguration( - abstract: "List the available executables from this package.") + abstract: "List the available executables from this package." + ) @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions + /// The Git URL of the template to list executables from. + /// + /// If not provided, the command uses the current working directory. @Option(name: .customLong("template-url"), help: "The git URL of the template.") var templateURL: String? - // Git-specific options + /// Output format for the templates list. + /// + /// Can be either `.flatlist` (default) or `.json`. + @Option(help: "Set the output format.") + var format: ShowTemplatesMode = .flatlist + + // MARK: - Versioning Options for Remote Git Templates + + /// The exact version of the remote package to use. @Option(help: "The exact package version to depend on.") var exact: Version? + /// Specific revision to use (for Git templates). @Option(help: "The specific package revision to depend on.") var revision: String? + /// Branch name to use (for Git templates). @Option(help: "The branch of the package to depend on.") var branch: String? + /// Version to depend on, up to the next major version. @Option(help: "The package version to depend on (up to the next major version).") var from: Version? + /// Version to depend on, up to the next minor version. @Option(help: "The package version to depend on (up to the next minor version).") var upToNextMinorFrom: Version? + /// Upper bound on the version range (exclusive). @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? - @Option(help: "Set the output format.") - var format: ShowTemplatesMode = .flatlist - func run(_ swiftCommandState: SwiftCommandState) async throws { - let packagePath: Basics.AbsolutePath - var deleteAfter = false + var shouldDeleteAfter = false + + if let templateURL = self.templateURL { + // Resolve dependency requirement based on provided options. + let requirement = try DependencyRequirementResolver( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ).resolve() + + // Download and resolve the Git-based template. + let resolver = TemplatePathResolver( + templateType: .git, + templateDirectory: nil, + templateURL: templateURL, + requirement: requirement + ) + packagePath = try await resolver.resolve() + shouldDeleteAfter = true - // Use local current directory or fetch Git package - if let templateURL = self.templateURL { - let requirement = try checkRequirements() - packagePath = try await getPackageFromGit(destination: templateURL, requirement: requirement) - deleteAfter = true - } else { - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("No template URL provided and no current directory") - } - packagePath = cwd + } else { + // Use the current working directory. + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("No template URL provided and no current directory") } + packagePath = cwd + } - defer { - if deleteAfter { - try? FileManager.default.removeItem(atPath: packagePath.pathString) - } + // Clean up downloaded package after execution. + defer { + if shouldDeleteAfter { + try? FileManager.default.removeItem(atPath: packagePath.pathString) } + } - - let packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: packagePath) { workspace, root in - return try await swiftCommandState.loadPackageGraph() - + // Load the package graph. + let packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: packagePath) { _, _ in + try await swiftCommandState.loadPackageGraph() } - let rootPackages = packageGraph.rootPackages.map { $0.identity } + let rootPackages = packageGraph.rootPackages.map(\.identity) - let templates = packageGraph.allModules.filter({ - $0.underlying.template - }).map { module -> Template in + // Extract executable modules marked as templates. + let templates = packageGraph.allModules.filter(\.underlying.template).map { module -> Template in if !rootPackages.contains(module.packageIdentity) { return Template(package: module.packageIdentity.description, name: module.name) } else { - return Template(package: Optional.none, name: module.name) + return Template(package: String?.none, name: module.name) } } + // Display templates in the requested format. switch self.format { case .flatlist: - for template in templates.sorted(by: {$0.name < $1.name }) { + for template in templates.sorted(by: { $0.name < $1.name }) { if let package = template.package { print("\(template.name) (\(package))") } else { @@ -113,13 +145,20 @@ struct ShowTemplates: AsyncSwiftCommand { } } + /// Represents a discovered template. struct Template: Codable { + /// Optional name of the external package, if the template comes from one. var package: String? + /// The name of the executable template. var name: String } + /// Output format modes for the `ShowTemplates` command. enum ShowTemplatesMode: String, RawRepresentable, CustomStringConvertible, ExpressibleByArgument, CaseIterable { - case flatlist, json + /// Output as a simple list of template names. + case flatlist + /// Output as a JSON array of template objects. + case json public init?(rawValue: String) { switch rawValue.lowercased() { @@ -134,123 +173,9 @@ struct ShowTemplates: AsyncSwiftCommand { public var description: String { switch self { - case .flatlist: return "flatlist" - case .json: return "json" + case .flatlist: "flatlist" + case .json: "json" } } } - - func getPackageFromGit( - destination: String, - requirement: PackageDependency.SourceControl.Requirement - ) async throws -> Basics.AbsolutePath { - let repositoryProvider = GitRepositoryProvider() - - let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in - try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in - let url = SourceControlURL(destination) - let repositorySpecifier = RepositorySpecifier(url: url) - - // This is the working clone destination - let bareCopyPath = tempDir.appending(component: "bare-copy") - - let workingCopyPath = tempDir.appending(component: "working-copy") - - try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) - - try FileManager.default.createDirectory(atPath: workingCopyPath.pathString, withIntermediateDirectories: true) - - // Validate directory (now should exist) - guard try repositoryProvider.isValidDirectory(bareCopyPath) else { - throw InternalError("Invalid directory at \(workingCopyPath)") - } - - - - let repository = try repositoryProvider.createWorkingCopyFromBare(repository: repositorySpecifier, sourcePath: bareCopyPath, at: workingCopyPath, editable: true) - - - try FileManager.default.removeItem(at: bareCopyPath.asURL) - - switch requirement { - case .range(let versionRange): - let tags = try repository.getTags() - let versions = tags.compactMap { Version($0) } - let filteredVersions = versions.filter { versionRange.contains($0) } - guard let latestVersion = filteredVersions.max() else { - throw InternalError("No tags found within the specified version range \(versionRange)") - } - try repository.checkout(tag: latestVersion.description) - - case .exact(let exactVersion): - try repository.checkout(tag: exactVersion.description) - - case .branch(let branchName): - try repository.checkout(branch: branchName) - - case .revision(let revision): - try repository.checkout(revision: .init(identifier: revision)) - } - - return workingCopyPath - } - } - - return try await fetchStandalonePackageByURL() - } - - - func checkRequirements() throws -> PackageDependency.SourceControl.Requirement { - var requirements : [PackageDependency.SourceControl.Requirement] = [] - - if let exact { - requirements.append(.exact(exact)) - } - - if let branch { - requirements.append(.branch(branch)) - } - - if let revision { - requirements.append(.revision(revision)) - } - - if let from { - requirements.append(.range(.upToNextMajor(from: from))) - } - - if let upToNextMinorFrom { - requirements.append(.range(.upToNextMinor(from: upToNextMinorFrom))) - } - - if requirements.count > 1 { - throw StringError( - "must specify at most one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" - ) - } - - guard let firstRequirement = requirements.first else { - throw StringError( - "must specify one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" - ) - } - - let requirement: PackageDependency.SourceControl.Requirement - if case .range(let range) = firstRequirement { - if let to { - requirement = .range(range.lowerBound ..< to) - } else { - requirement = .range(range) - } - } else { - requirement = firstRequirement - - if self.to != nil { - throw StringError("--to can only be specified with --from or --up-to-next-minor-from") - } - } - return requirement - - } - } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index f5f0f6ccdec..48150041557 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -14,14 +14,50 @@ import PackageModel import TSCBasic import TSCUtility +/// A utility for resolving a single, well-formed source control dependency requirement +/// based on mutually exclusive versioning inputs such as `exact`, `branch`, `revision`, +/// or version ranges (`from`, `upToNextMinorFrom`, `to`). +/// +/// This is typically used to translate user-specified version inputs (e.g., from the command line) +/// into a concrete `PackageDependency.SourceControl.Requirement` that SwiftPM can understand. +/// +/// Only one of the following fields should be non-nil: +/// - `exact`: A specific version (e.g., 1.2.3). +/// - `revision`: A specific VCS revision (e.g., commit hash). +/// - `branch`: A named branch (e.g., "main"). +/// - `from`: Lower bound of a version range with an upper bound inferred as the next major version. +/// - `upToNextMinorFrom`: Lower bound of a version range with an upper bound inferred as the next minor version. +/// +/// Optionally, a `to` value can be specified to manually cap the upper bound of a version range, +/// but it must be combined with `from` or `upToNextMinorFrom`. + struct DependencyRequirementResolver { + /// An exact version to use. let exact: Version? + + /// A specific source control revision (e.g., a commit SHA). let revision: String? + + /// A branch name to track. let branch: String? + + /// The lower bound for a version range with an implicit upper bound to the next major version. let from: Version? + + /// The lower bound for a version range with an implicit upper bound to the next minor version. let upToNextMinorFrom: Version? + + /// An optional manual upper bound for the version range. Must be used with `from` or `upToNextMinorFrom`. let to: Version? + /// Resolves the provided requirement fields into a concrete `PackageDependency.SourceControl.Requirement`. + /// + /// - Returns: A valid, single requirement representing a source control constraint. + /// - Throws: A `StringError` if: + /// - More than one requirement type is provided. + /// - None of the requirement fields are set. + /// - A `to` value is provided without a corresponding `from` or `upToNextMinorFrom`. + func resolve() throws -> PackageDependency.SourceControl.Requirement { var all: [PackageDependency.SourceControl.Requirement] = [] @@ -37,7 +73,7 @@ struct DependencyRequirementResolver { if case .range(let range) = requirement, let upper = to { return .range(range.lowerBound ..< upper) - } else if to != nil { + } else if self.to != nil { throw StringError("--to requires --from or --up-to-next-minor-from") } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift index ad8814ee52d..e8b8c4212d9 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -1,42 +1,76 @@ +//===----------------------------------------------------------------------===// // -// TemplateBuild.swift -// SwiftPM +// This source file is part of the Swift open source project // -// Created by John Bute on 2025-06-11. +// 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 CoreCommands +import ArgumentParser import Basics +import CoreCommands +import SPMBuildCore import TSCBasic -import ArgumentParser import TSCUtility -import SPMBuildCore -struct TemplateBuildSupport { - static func build(swiftCommandState: SwiftCommandState, buildOptions: BuildCommandOptions, globalOptions: GlobalOptions, cwd: Basics.AbsolutePath) async throws { - let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in - try await swiftCommandState.createBuildSystem( - explicitProduct: buildOptions.product, - traitConfiguration: .init(traitOptions: buildOptions.traits), - shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: swiftCommandState.toolsBuildParameters, - outputStream: TSCBasic.stdoutStream - ) - } +/// A utility for building Swift packages using the SwiftPM build system. +/// +/// `TemplateBuildSupport` encapsulates the logic needed to initialize the +/// SwiftPM build system and perform a build operation based on a specific +/// command configuration and workspace context. + +enum TemplateBuildSupport { + /// Builds a Swift package using the given command state, options, and working directory. + /// + /// This method performs the following steps: + /// 1. Initializes a temporary workspace, optionally switching to a user-specified package directory. + /// 2. Creates a build system with the specified configuration, including product, traits, and build parameters. + /// 3. Resolves the build subset (e.g., targets or products to build). + /// 4. Executes the build within the workspace. + /// + /// - Parameters: + /// - swiftCommandState: The current Swift command state, containing context such as the workspace and + /// diagnostics. + /// - buildOptions: Options used to configure what and how to build, including the product and traits. + /// - globalOptions: Global configuration such as the package directory and logging verbosity. + /// - cwd: The current working directory to use if no package directory is explicitly provided. + /// + /// - Throws: + /// - `ExitCode.failure` if no valid build subset can be resolved or if the build fails due to diagnostics. + /// - Any other errors thrown during workspace setup or build system creation. + static func build( + swiftCommandState: SwiftCommandState, + buildOptions: BuildCommandOptions, + globalOptions: GlobalOptions, + cwd: Basics.AbsolutePath + ) async throws { + let buildSystem = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + try await swiftCommandState.createBuildSystem( + explicitProduct: buildOptions.product, + traitConfiguration: .init(traitOptions: buildOptions.traits), + shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: swiftCommandState.toolsBuildParameters, + outputStream: TSCBasic.stdoutStream + ) + } guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { throw ExitCode.failure } - try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in - do { - try await buildSystem.build(subset: subset) - } catch _ as Diagnostics { - throw ExitCode.failure + try await swiftCommandState + .withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + do { + try await buildSystem.build(subset: subset) + } catch _ as Diagnostics { + throw ExitCode.failure + } } - } - } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index f0c2506e214..0fb1cf6fad5 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -10,23 +10,45 @@ // //===----------------------------------------------------------------------===// -import Workspace import Basics +import Foundation import PackageModel -import TSCBasic import SourceControl -import Foundation +import TSCBasic import TSCUtility +import Workspace +/// A utility responsible for resolving the path to a package template, +/// based on the provided template type and associated configuration. +/// +/// Supported template types include: +/// - `.local`: A local file system path to a template directory. +/// - `.git`: A remote Git repository containing the template. +/// - `.registry`: (Currently unsupported) +/// +/// Used during package initialization (e.g., via `swift package init --template`). struct TemplatePathResolver { - let templateType: InitTemplatePackage.TemplateType? + /// The type of template to resolve (e.g., local, git, registry). + let templateType: InitTemplatePackage.TemplateSource? + + /// The local path to a template directory, used for `.local` templates. let templateDirectory: Basics.AbsolutePath? + + /// The URL of the Git repository containing the template, used for `.git` templates. let templateURL: String? + + /// The versioning requirement for the Git repository (e.g., exact version, branch, revision, or version range). let requirement: PackageDependency.SourceControl.Requirement? + /// Resolves the template path by downloading or validating it based on the template type. + /// + /// - Returns: The resolved path to the template directory. + /// - Throws: + /// - `StringError` if required values (e.g., path, URL, requirement) are missing, + /// or if the template type is unsupported or unspecified. func resolve() async throws -> Basics.AbsolutePath { - switch templateType { + switch self.templateType { case .local: guard let path = templateDirectory else { throw StringError("Template path must be specified for local templates.") @@ -52,12 +74,19 @@ struct TemplatePathResolver { } } + /// A helper that fetches a Git-based template repository and checks out the specified version or revision. struct GitTemplateFetcher { + /// The Git URL of the remote repository. let destination: String + + /// The source control requirement used to determine which version/branch/revision to check out. let requirement: PackageDependency.SourceControl.Requirement + /// Fetches the repository and returns the path to the checked-out working copy. + /// + /// - Returns: A path to the directory containing the fetched template. + /// - Throws: Any error encountered during repository fetch, checkout, or validation. func fetch() async throws -> Basics.AbsolutePath { - let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in @@ -65,14 +94,16 @@ struct TemplatePathResolver { let repositorySpecifier = RepositorySpecifier(url: url) let repositoryProvider = GitRepositoryProvider() - let bareCopyPath = tempDir.appending(component: "bare-copy") let workingCopyPath = tempDir.appending(component: "working-copy") - try fetchBareRepository(provider: repositoryProvider, specifier: repositorySpecifier, to: bareCopyPath) - try validateDirectory(provider: repositoryProvider, at: bareCopyPath) - + try self.fetchBareRepository( + provider: repositoryProvider, + specifier: repositorySpecifier, + to: bareCopyPath + ) + try self.validateDirectory(provider: repositoryProvider, at: bareCopyPath) try FileManager.default.createDirectory( atPath: workingCopyPath.pathString, @@ -88,7 +119,7 @@ struct TemplatePathResolver { try FileManager.default.removeItem(at: bareCopyPath.asURL) - try checkout(repository: repository) + try self.checkout(repository: repository) return workingCopyPath } @@ -97,6 +128,7 @@ struct TemplatePathResolver { return try await fetchStandalonePackageByURL() } + /// Fetches a bare clone of the Git repository to the specified path. private func fetchBareRepository( provider: GitRepositoryProvider, specifier: RepositorySpecifier, @@ -105,14 +137,18 @@ struct TemplatePathResolver { try provider.fetch(repository: specifier, to: path) } + /// Validates that the directory contains a valid Git repository. private func validateDirectory(provider: GitRepositoryProvider, at path: Basics.AbsolutePath) throws { guard try provider.isValidDirectory(path) else { throw InternalError("Invalid directory at \(path)") } } + /// Checks out the desired state (branch, tag, revision) in the working copy based on the requirement. + /// + /// - Throws: An error if no matching version is found in a version range, or if checkout fails. private func checkout(repository: WorkingCheckout) throws { - switch requirement { + switch self.requirement { case .exact(let version): try repository.checkout(tag: version.description) @@ -132,6 +168,5 @@ struct TemplatePathResolver { try repository.checkout(tag: latestVersion.description) } } - } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift index daf459b922c..891418903c9 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift @@ -1,9 +1,14 @@ +//===----------------------------------------------------------------------===// // -// TemplatePluginRunner.swift -// SwiftPM +// This source file is part of the Swift open source project // -// Created by John Bute on 2025-06-11. +// 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 ArgumentParser import Basics @@ -12,20 +17,48 @@ import Basics import CoreCommands import PackageModel -import Workspace import SPMBuildCore import TSCUtility +import Workspace import Foundation import PackageGraph +import SourceControl import SPMBuildCore -import XCBuildSupport import TSCBasic -import SourceControl - - -struct TemplatePluginRunner { +import XCBuildSupport +/// A utility that runs a plugin target within the context of a resolved Swift package. +/// +/// This is used to perform plugin invocations involved in template initialization scripts— +/// with proper sandboxing, permissions, and build system support. +/// +/// The plugin must be part of a resolved package graph, and the invocation is handled +/// asynchronously through SwiftPM’s plugin infrastructure. + +enum TemplatePluginRunner { + /// Runs the given plugin target with the specified arguments and environment context. + /// + /// This function performs the following steps: + /// 1. Validates and prepares plugin metadata and permissions. + /// 2. Prepares the plugin working directory and toolchain. + /// 3. Resolves required plugin tools, building any products referenced by the plugin. + /// 4. Invokes the plugin via the configured script runner with sandboxing. + /// + /// - Parameters: + /// - plugin: The resolved plugin module to run. + /// - package: The resolved package to which the plugin belongs. + /// - packageGraph: The complete graph of modules used by the build. + /// - arguments: Arguments to pass to the plugin at invocation time. + /// - swiftCommandState: The current Swift command state including environment, toolchain, and workspace. + /// - allowNetworkConnections: A list of pre-authorized network permissions for the plugin sandbox. + /// + /// - Returns: A `Data` value representing the plugin’s buffered stdout output. + /// + /// - Throws: + /// - `InternalError` if expected components (e.g., plugin module or working directory) are missing. + /// - `StringError` if permission is denied by the user or plugin configuration is invalid. + /// - Any other error thrown during tool resolution, plugin script execution, or build system creation. static func run( plugin: ResolvedModule, package: ResolvedPackage, @@ -51,12 +84,13 @@ struct TemplatePluginRunner { state: swiftCommandState ) - let readOnlyDirs = writableDirs.contains(where: { package.path.isDescendantOfOrEqual(to: $0) }) ? [] : [package.path] + let readOnlyDirs = writableDirs + .contains(where: { package.path.isDescendantOfOrEqual(to: $0) }) ? [] : [package.path] let toolSearchDirs = try defaultToolSearchDirectories(using: swiftCommandState) let buildParams = try swiftCommandState.toolsBuildParameters let buildSystem = try await swiftCommandState.createBuildSystem( - explicitBuildSystem: .native, + explicitBuildSystem: .native, // FIXME: This should be based on BuildSystemProvider. traitConfiguration: .init(), cacheBuildManifest: false, productsBuildParameters: swiftCommandState.productsBuildParameters, @@ -66,10 +100,13 @@ struct TemplatePluginRunner { let accessibleTools = try await plugin.preparePluginTools( fileSystem: swiftCommandState.fileSystem, - environment: try swiftCommandState.toolsBuildParameters.buildEnvironment, - for: try pluginScriptRunner.hostTriple + environment: swiftCommandState.toolsBuildParameters.buildEnvironment, + for: pluginScriptRunner.hostTriple ) { name, _ in - // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies are not supported within a package, so if the tool happens to be from the same package, we instead find the executable that corresponds to the product. There is always one, because of autogeneraxtion of implicit executables with the same name as the target if there isn't an explicit one. + // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies + // are not supported within a package, so if the tool happens to be from the same package, we instead find + // the executable that corresponds to the product. There is always one, because of autogeneraxtion of + // implicit executables with the same name as the target if there isn't an explicit one. try await buildSystem.build(subset: .product(name, for: .host)) if let builtTool = try buildSystem.buildPlan.buildProducts.first(where: { $0.product.name == name && $0.buildParameters.destination == .host @@ -109,6 +146,7 @@ struct TemplatePluginRunner { return delegate.lineBufferedOutput } + /// Safely casts a `ResolvedModule` to a `PluginModule`, or throws if invalid. private static func castToPlugin(_ plugin: ResolvedModule) throws -> PluginModule { guard let pluginTarget = plugin.underlying as? PluginModule else { throw InternalError("Expected PluginModule") @@ -116,16 +154,21 @@ struct TemplatePluginRunner { return pluginTarget } + /// Returns the plugin working directory for the specified plugin name. private static func pluginDirectory(for name: String, in state: SwiftCommandState) throws -> Basics.AbsolutePath { try state.getActiveWorkspace().location.pluginWorkingDirectory.appending(component: name) } + /// Resolves default tool search directories including the toolchain path and user $PATH. private static func defaultToolSearchDirectories(using state: SwiftCommandState) throws -> [Basics.AbsolutePath] { let toolchainPath = try state.getTargetToolchain().swiftCompilerPath.parentDirectory let envPaths = Basics.getEnvSearchPaths(pathString: Environment.current[.path], currentWorkingDirectory: nil) return [toolchainPath] + envPaths } + /// Prompts for and grants plugin permissions as specified in the plugin manifest. + /// + /// This supports terminal-based interactive prompts and non-interactive failure modes. private static func requestPluginPermissions( from plugin: PluginModule, pluginName: String, @@ -137,17 +180,23 @@ struct TemplatePluginRunner { guard case .command(_, let permissions) = plugin.capability else { return } for permission in permissions { - let (desc, reason, remedy) = describe(permission) + let (desc, reason, remedy) = self.describe(permission) if state.outputStream.isTTY { - state.outputStream.write("Plugin '\(pluginName)' wants permission to \(desc).\nStated reason: “\(reason)”.\nAllow? (yes/no) ".utf8) + state.outputStream + .write( + "Plugin '\(pluginName)' wants permission to \(desc).\nStated reason: “\(reason)”.\nAllow? (yes/no) " + .utf8 + ) state.outputStream.flush() guard readLine()?.lowercased() == "yes" else { throw StringError("Permission denied: \(desc)") } } else { - throw StringError("Plugin '\(pluginName)' requires: \(desc).\nReason: “\(reason)”.\nUse \(remedy) to allow.") + throw StringError( + "Plugin '\(pluginName)' requires: \(desc).\nReason: “\(reason)”.\nUse \(remedy) to allow." + ) } switch permission { @@ -159,15 +208,16 @@ struct TemplatePluginRunner { } } + /// Describes a plugin permission request with a description, reason, and CLI remedy flag. private static func describe(_ permission: PluginPermission) -> (String, String, String) { switch permission { case .writeToPackageDirectory(let reason): return ("write to the package directory", reason, "--allow-writing-to-package-directory") case .allowNetworkConnections(let scope, let reason): let ports = scope.ports.map(String.init).joined(separator: ", ") - let desc = scope.ports.isEmpty ? "allow \(scope.label) connections" : "allow \(scope.label) on ports: \(ports)" + let desc = scope.ports + .isEmpty ? "allow \(scope.label) connections" : "allow \(scope.label) on ports: \(ports)" return (desc, reason, "--allow-network-connections") } } } - diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift b/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift index 24588c894c4..c802e4ac71b 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift @@ -10,30 +10,44 @@ // //===----------------------------------------------------------------------===// - - import Basics @_spi(SwiftPMInternal) import CoreCommands import Workspace +/// Computes the set of supported testing libraries to be included in a package template +/// based on the user's specified testing options, the type of package being initialized, +/// and the Swift command state. +/// +/// This function takes into account whether the testing libraries were explicitly requested +/// (via command-line flags or configuration) or implicitly enabled based on package type. +/// +/// - Parameters: +/// - testLibraryOptions: The testing library preferences specified by the user. +/// - initMode: The type of package being initialized (e.g., executable, library, macro). +/// - swiftCommandState: The command state which includes environment and context information. +/// +/// - Returns: A set of `TestingLibrary` values that should be included in the generated template. func computeSupportedTestingLibraries( for testLibraryOptions: TestLibraryOptions, initMode: InitPackage.PackageType, swiftCommandState: SwiftCommandState ) -> Set { - var supportedTemplateTestingLibraries: Set = .init() + + // XCTest is enabled either explicitly, or implicitly for macro packages. if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) + { supportedTemplateTestingLibraries.insert(.xctest) } + + // Swift Testing is enabled either explicitly, or implicitly for non-macro packages. if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) + { supportedTemplateTestingLibraries.insert(.swiftTesting) } return supportedTemplateTestingLibraries - } - diff --git a/Sources/SourceControl/GitRepository.swift b/Sources/SourceControl/GitRepository.swift index a210001aee3..c436067ceb4 100644 --- a/Sources/SourceControl/GitRepository.swift +++ b/Sources/SourceControl/GitRepository.swift @@ -271,7 +271,6 @@ public struct GitRepositoryProvider: RepositoryProvider, Cancellable { ) } return try self.openWorkingCopy(at: destinationPath) - } diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 71675eef6cc..683a088cfec 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -5,44 +5,58 @@ // Created by John Bute on 2025-05-13. // +import ArgumentParserToolInfo import Basics -import PackageModel -import SPMBuildCore -import TSCUtility import Foundation -import Basics import PackageModel +import PackageModelSyntax import SPMBuildCore -import TSCUtility +import SwiftParser import System -import PackageModelSyntax import TSCBasic -import SwiftParser -import ArgumentParserToolInfo +import TSCUtility -public final class InitTemplatePackage { +/// A class responsible for initializing a Swift package from a specified template. +/// +/// This class handles creating the package structure, applying a template dependency +/// to the package manifest, and optionally prompting the user for input to customize +/// the generated package. +/// +/// It supports different types of templates (local, git, registry) and multiple +/// testing libraries. +/// +/// Usage: +/// - Initialize an instance with the package name, template details, file system, destination path, etc. +/// - Call `setupTemplateManifest()` to create the package and add the template dependency. +/// - Use `promptUser(tool:)` to interactively prompt the user for command line argument values. +public final class InitTemplatePackage { + /// The kind of package dependency to add for the template. let packageDependency: MappablePackageDependency.Kind - + /// The set of testing libraries supported by the generated package. public var supportedTestingLibraries: Set - + /// The name of the template to use. let templateName: String - /// The file system to use + /// The file system abstraction to use for file operations. let fileSystem: FileSystem - /// Where to create the new package + /// The absolute path where the package will be created. let destinationPath: Basics.AbsolutePath - /// Configuration from the used toolchain. + /// Configuration information from the installed Swift Package Manager toolchain. let installedSwiftPMConfiguration: InstalledSwiftPMConfiguration + /// The name of the package to create. var packageName: String + /// The path to the template files. var templatePath: Basics.AbsolutePath + /// The type of package to create (e.g., library, executable). let packageType: InitPackage.PackageType + /// Options used to configure package initialization. public struct InitPackageOptions { /// The type of package to create. @@ -51,11 +65,17 @@ public final class InitTemplatePackage { /// The set of supported testing libraries to include in the package. public var supportedTestingLibraries: Set - /// The list of platforms in the manifest. + /// The list of supported platforms to target in the manifest. /// - /// Note: This should only contain Apple platforms right now. + /// Note: Currently only Apple platforms are supported. public var platforms: [SupportedPlatform] + /// Creates a new `InitPackageOptions` instance. + /// - Parameters: + /// - packageType: The type of package to create. + /// - supportedTestingLibraries: The set of testing libraries to support. + /// - platforms: The list of supported platforms (default is empty). + public init( packageType: InitPackage.PackageType, supportedTestingLibraries: Set, @@ -67,21 +87,29 @@ public final class InitTemplatePackage { } } - - - public enum TemplateType: String, CustomStringConvertible { - case local = "local" - case git = "git" - case registry = "registry" + /// The type of template source. + public enum TemplateSource: String, CustomStringConvertible { + case local + case git + case registry public var description: String { - return rawValue + rawValue } } - - - + /// Creates a new `InitTemplatePackage` instance. + /// + /// - Parameters: + /// - name: The name of the package to create. + /// - templateName: The name of the template to use. + /// - initMode: The kind of package dependency to add for the template. + /// - templatePath: The file system path to the template files. + /// - fileSystem: The file system to use for operations. + /// - packageType: The type of package to create (e.g., library, executable). + /// - supportedTestingLibraries: The set of testing libraries to support. + /// - destinationPath: The directory where the new package should be created. + /// - installedSwiftPMConfiguration: Configuration from the SwiftPM toolchain. public init( name: String, templateName: String, @@ -104,31 +132,47 @@ public final class InitTemplatePackage { self.templateName = templateName } - + /// Sets up the package manifest by creating the package structure and + /// adding the template dependency to the manifest. + /// + /// This method initializes an empty package using `InitPackage`, writes the + /// package structure, and then applies the template dependency to the manifest file. + /// + /// - Throws: An error if package initialization or manifest modification fails. public func setupTemplateManifest() throws { // initialize empty swift package - let initializedPackage = try InitPackage(name: self.packageName, options: .init(packageType: self.packageType, supportedTestingLibraries: self.supportedTestingLibraries), destinationPath: self.destinationPath, installedSwiftPMConfiguration: self.installedSwiftPMConfiguration, fileSystem: self.fileSystem) + let initializedPackage = try InitPackage( + name: self.packageName, + options: .init(packageType: self.packageType, supportedTestingLibraries: self.supportedTestingLibraries), + destinationPath: self.destinationPath, + installedSwiftPMConfiguration: self.installedSwiftPMConfiguration, + fileSystem: self.fileSystem + ) try initializedPackage.writePackageStructure() - try initializePackageFromTemplate() - - //try build - // try --experimental-help-dump - //prompt - //run the executable. + try self.initializePackageFromTemplate() } + /// Initializes the package by adding the template dependency to the manifest. + /// + /// - Throws: An error if adding the dependency or modifying the manifest fails. private func initializePackageFromTemplate() throws { - try addTemplateDepenency() + try self.addTemplateDepenency() } - private func addTemplateDepenency() throws { - + /// Adds the template dependency to the package manifest. + /// + /// This reads the manifest file, parses it into a syntax tree, modifies it + /// to include the template dependency, and then writes the updated manifest + /// back to disk. + /// + /// - Throws: An error if the manifest file cannot be read, parsed, or modified. - let manifestPath = destinationPath.appending(component: Manifest.filename) + private func addTemplateDepenency() throws { + let manifestPath = self.destinationPath.appending(component: Manifest.filename) let manifestContents: ByteString do { - manifestContents = try fileSystem.readFileContents(manifestPath) + manifestContents = try self.fileSystem.readFileContents(manifestPath) } catch { throw StringError("Cannot find package manifest in \(manifestPath)") } @@ -142,22 +186,42 @@ public final class InitTemplatePackage { } let editResult = try AddPackageDependency.addPackageDependency( - packageDependency, to: manifestSyntax) - - try editResult.applyEdits(to: fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: false) + self.packageDependency, to: manifestSyntax + ) + + try editResult.applyEdits( + to: self.fileSystem, + manifest: manifestSyntax, + manifestPath: manifestPath, + verbose: false + ) } - + /// Prompts the user for input based on the given tool information. + /// + /// This method converts the command arguments of the tool into prompt questions, + /// collects user input, and builds a command line argument array from the responses. + /// + /// - Parameter tool: The tool information containing command and argument metadata. + /// - Returns: An array of strings representing the command line arguments built from user input. + /// - Throws: `TemplateError.noArguments` if the tool command has no arguments. + public func promptUser(tool: ToolInfoV0) throws -> [String] { let arguments = try convertArguments(from: tool.command) let responses = UserPrompter.prompt(for: arguments) - let commandLine = buildCommandLine(from: responses) + let commandLine = self.buildCommandLine(from: responses) return commandLine } + /// Converts the command information into an array of argument metadata. + /// + /// - Parameter command: The command info object. + /// - Returns: An array of argument info objects. + /// - Throws: `TemplateError.noArguments` if the command has no arguments. + private func convertArguments(from command: CommandInfoV0) throws -> [ArgumentInfoV0] { guard let rawArgs = command.arguments else { throw TemplateError.noArguments @@ -165,23 +229,31 @@ public final class InitTemplatePackage { return rawArgs } + /// A helper struct to prompt the user for input values for command arguments. - private struct UserPrompter { + private enum UserPrompter { + /// Prompts the user for input for each argument, handling flags, options, and positional arguments. + /// + /// - Parameter arguments: The list of argument metadata to prompt for. + /// - Returns: An array of `ArgumentResponse` representing the user's input. static func prompt(for arguments: [ArgumentInfoV0]) -> [ArgumentResponse] { - return arguments + arguments .filter { $0.valueName != "help" && $0.shouldDisplay != false } .map { arg in let defaultText = arg.defaultValue.map { " (default: \($0))" } ?? "" - let allValuesText = (arg.allValues?.isEmpty == false) ? " [\(arg.allValues!.joined(separator: ", "))]" : "" + let allValuesText = (arg.allValues?.isEmpty == false) ? + " [\(arg.allValues!.joined(separator: ", "))]" : "" let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(defaultText):" var values: [String] = [] switch arg.kind { case .flag: - let confirmed = promptForConfirmation(prompt: promptMessage, - defaultBehavior: arg.defaultValue?.lowercased() == "true") + let confirmed = promptForConfirmation( + prompt: promptMessage, + defaultBehavior: arg.defaultValue?.lowercased() == "true" + ) values = [confirmed ? "true" : "false"] case .option, .positional: @@ -190,7 +262,9 @@ public final class InitTemplatePackage { if arg.isRepeating { while let input = readLine(), !input.isEmpty { if let allowed = arg.allValues, !allowed.contains(input) { - print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + print( + "Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))" + ) continue } values.append(input) @@ -200,9 +274,11 @@ public final class InitTemplatePackage { } } else { let input = readLine() - if let input = input, !input.isEmpty { + if let input, !input.isEmpty { if let allowed = arg.allValues, !allowed.contains(input) { - print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + print( + "Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))" + ) exit(1) } values = [input] @@ -218,66 +294,87 @@ public final class InitTemplatePackage { } } } + + /// Builds an array of command line argument strings from the given argument responses. + /// + /// - Parameter responses: The array of argument responses containing user inputs. + /// - Returns: An array of strings representing the command line arguments. + func buildCommandLine(from responses: [ArgumentResponse]) -> [String] { - return responses.flatMap(\.commandLineFragments) + responses.flatMap(\.commandLineFragments) } - + /// Prompts the user for a yes/no confirmation. + /// + /// - Parameters: + /// - prompt: The prompt message to display. + /// - defaultBehavior: The default value if the user provides no input. + /// - Returns: `true` if the user confirmed, otherwise `false`. private static func promptForConfirmation(prompt: String, defaultBehavior: Bool?) -> Bool { - let suffix = defaultBehavior == true ? " [Y/n]" : defaultBehavior == false ? " [y/N]" : " [y/n]" - print(prompt + suffix, terminator: " ") - guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { - return defaultBehavior ?? false - } + let suffix = defaultBehavior == true ? " [Y/n]" : defaultBehavior == false ? " [y/N]" : " [y/n]" + print(prompt + suffix, terminator: " ") + guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + return defaultBehavior ?? false + } - switch input { - case "y", "yes": return true - case "n", "no": return false - default: return defaultBehavior ?? false - } + switch input { + case "y", "yes": return true + case "n", "no": return false + default: return defaultBehavior ?? false } + } + + /// Represents a user's response to an argument prompt. struct ArgumentResponse { + /// The argument metadata. + let argument: ArgumentInfoV0 + /// The values provided by the user. + let values: [String] + /// Returns the command line fragments representing this argument and its values. var commandLineFragments: [String] { guard let name = argument.valueName else { - return values + return self.values } - switch argument.kind { + switch self.argument.kind { case .flag: - return values.first == "true" ? ["--\(name)"] : [] + return self.values.first == "true" ? ["--\(name)"] : [] case .option: - return values.flatMap { ["--\(name)", $0] } + return self.values.flatMap { ["--\(name)", $0] } case .positional: - return values + return self.values } } } } - +/// An error enum representing various template-related errors. private enum TemplateError: Swift.Error { + /// The provided path is invalid or does not exist. case invalidPath + + /// A manifest file already exists in the target directory. case manifestAlreadyExists + + /// The template has no arguments to prompt for. case noArguments } - extension TemplateError: CustomStringConvertible { + /// A readable description of the error var description: String { switch self { case .manifestAlreadyExists: - return "a manifest file already exists in this directory" + "a manifest file already exists in this directory" case .invalidPath: - return "Path does not exist, or is invalid." + "Path does not exist, or is invalid." case .noArguments: - return "Template has no arguments" + "Template has no arguments" } } } - - From c037f6890ad4819dd4bb875a01db1f8d59d7d7da Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 11 Jun 2025 15:26:47 -0400 Subject: [PATCH 049/225] changed naming from template types (git, local, registry) to template source --- Sources/Commands/PackageCommands/Init.swift | 14 +++++++------- .../Commands/PackageCommands/ShowTemplates.swift | 2 +- .../TemplatePathResolver.swift | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 6ea942ec214..b6d0234a401 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -78,7 +78,7 @@ extension SwiftPackageCommand { /// The type of template to use: `registry`, `git`, or `local`. @Option(name: .customLong("template-type"), help: "Template type: registry, git, local.") - var templateType: InitTemplatePackage.TemplateType? + var templateSource: InitTemplatePackage.TemplateSource? /// Path to a local template. @Option(name: .customLong("template-path"), help: "Path to the local template.", completion: .directory) @@ -165,7 +165,7 @@ extension SwiftPackageCommand { let resolvedTemplatePath: Basics.AbsolutePath var requirement: PackageDependency.SourceControl.Requirement? - switch self.templateType { + switch self.templateSource { case .git: requirement = try DependencyRequirementResolver( exact: self.exact, @@ -177,7 +177,7 @@ extension SwiftPackageCommand { ).resolve() resolvedTemplatePath = try await TemplatePathResolver( - templateType: self.templateType, + templateSource: self.templateSource, templateDirectory: self.templateDirectory, templateURL: self.templateURL, requirement: requirement @@ -185,7 +185,7 @@ extension SwiftPackageCommand { case .local, .registry: resolvedTemplatePath = try await TemplatePathResolver( - templateType: self.templateType, + templateSource: self.templateSource, templateDirectory: self.templateDirectory, templateURL: self.templateURL, requirement: nil @@ -202,7 +202,7 @@ extension SwiftPackageCommand { // Clean up downloaded package after execution. defer { - if templateType == .git { + if templateSource == .git { try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) } } @@ -293,7 +293,7 @@ extension SwiftPackageCommand { requirement: PackageDependency.SourceControl.Requirement?, resolvedTemplatePath: Basics.AbsolutePath ) throws -> MappablePackageDependency.Kind { - switch self.templateType { + switch self.templateSource { case .local: return .fileSystem(name: self.packageName, path: resolvedTemplatePath.asURL.path) @@ -335,4 +335,4 @@ extension InitPackage.PackageType: ExpressibleByArgument { } } -extension InitTemplatePackage.TemplateType: ExpressibleByArgument {} +extension InitTemplatePackage.TemplateSource: ExpressibleByArgument {} diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 65c3e25689d..00cc83b2d7a 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -86,7 +86,7 @@ struct ShowTemplates: AsyncSwiftCommand { // Download and resolve the Git-based template. let resolver = TemplatePathResolver( - templateType: .git, + templateSource: .git, templateDirectory: nil, templateURL: templateURL, requirement: requirement diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index 0fb1cf6fad5..82039efcd5e 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -29,8 +29,8 @@ import Workspace /// Used during package initialization (e.g., via `swift package init --template`). struct TemplatePathResolver { - /// The type of template to resolve (e.g., local, git, registry). - let templateType: InitTemplatePackage.TemplateSource? + /// The source of template to resolve (e.g., local, git, registry). + let templateSource: InitTemplatePackage.TemplateSource? /// The local path to a template directory, used for `.local` templates. let templateDirectory: Basics.AbsolutePath? @@ -48,7 +48,7 @@ struct TemplatePathResolver { /// - `StringError` if required values (e.g., path, URL, requirement) are missing, /// or if the template type is unsupported or unspecified. func resolve() async throws -> Basics.AbsolutePath { - switch self.templateType { + switch self.templateSource { case .local: guard let path = templateDirectory else { throw StringError("Template path must be specified for local templates.") From 9fcef0b3a2a949a78f504bf6ae12f19e45a84588 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 12 Jun 2025 13:37:28 -0400 Subject: [PATCH 050/225] generating templates from package registry --- Package.swift | 1 + Sources/Commands/PackageCommands/Init.swift | 72 ++++++++-- .../PackageCommands/ShowTemplates.swift | 8 +- .../RequirementResolver.swift | 50 ++++--- .../TemplatePathResolver.swift | 125 +++++++++++++++++- 5 files changed, 216 insertions(+), 40 deletions(-) diff --git a/Package.swift b/Package.swift index 43186f0aded..ca107d6428b 100644 --- a/Package.swift +++ b/Package.swift @@ -591,6 +591,7 @@ let package = Package( "XCBuildSupport", "SwiftBuildSupport", "SwiftFixIt", + "PackageRegistry", ] + swiftSyntaxDependencies(["SwiftIDEUtils"]), exclude: ["CMakeLists.txt", "README.md"], swiftSettings: swift6CompatibleExperimentalFeatures + [ diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index b6d0234a401..7d96a7080d8 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -88,7 +88,10 @@ extension SwiftPackageCommand { @Option(name: .customLong("template-url"), help: "The git URL of the template.") var templateURL: String? - // MARK: - Versioning Options for Remote Git Templates + @Option(name: .customLong("package-id"), help: "The package identifier of the template") + var templatePackageID: String? + + // MARK: - Versioning Options for Remote Git Templates and Registry templates /// The exact version of the remote package to use. @Option(help: "The exact package version to depend on.") @@ -114,6 +117,8 @@ extension SwiftPackageCommand { @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? + + func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") @@ -163,34 +168,58 @@ extension SwiftPackageCommand { cwd: Basics.AbsolutePath ) async throws { let resolvedTemplatePath: Basics.AbsolutePath - var requirement: PackageDependency.SourceControl.Requirement? + var registryRequirement: PackageDependency.Registry.Requirement? + var sourceControlRequirement: PackageDependency.SourceControl.Requirement? switch self.templateSource { case .git: - requirement = try DependencyRequirementResolver( + sourceControlRequirement = try DependencyRequirementResolver( exact: self.exact, revision: self.revision, branch: self.branch, from: self.from, upToNextMinorFrom: self.upToNextMinorFrom, to: self.to - ).resolve() + ).resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + resolvedTemplatePath = try await TemplatePathResolver( templateSource: self.templateSource, templateDirectory: self.templateDirectory, templateURL: self.templateURL, - requirement: requirement - ).resolve() + sourceControlRequirement: sourceControlRequirement, + registryRequirement: nil, + packageIdentity: nil + ).resolve(swiftCommandState: swiftCommandState) - case .local, .registry: + case .local: resolvedTemplatePath = try await TemplatePathResolver( templateSource: self.templateSource, templateDirectory: self.templateDirectory, templateURL: self.templateURL, - requirement: nil - ).resolve() + sourceControlRequirement: nil, + registryRequirement: nil, + packageIdentity: nil + ).resolve(swiftCommandState: swiftCommandState) + case .registry: + + registryRequirement = try DependencyRequirementResolver( + exact: self.exact, + revision: self.revision, + branch: self.branch, + from: self.from, + upToNextMinorFrom: self.upToNextMinorFrom, + to: self.to + ).resolve(for: .registry) as? PackageDependency.Registry.Requirement + resolvedTemplatePath = try await TemplatePathResolver( + templateSource: self.templateSource, + templateDirectory: self.templateDirectory, + templateURL: self.templateURL, + sourceControlRequirement: nil, + registryRequirement: registryRequirement, + packageIdentity: templatePackageID + ).resolve(swiftCommandState: swiftCommandState) case .none: throw StringError("Missing template type") } @@ -202,7 +231,7 @@ extension SwiftPackageCommand { // Clean up downloaded package after execution. defer { - if templateSource == .git { + if templateSource == .git || templateSource == .registry { try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) } } @@ -216,7 +245,7 @@ extension SwiftPackageCommand { let initTemplatePackage = try InitTemplatePackage( name: packageName, templateName: template, - initMode: packageDependency(requirement: requirement, resolvedTemplatePath: resolvedTemplatePath), + initMode: packageDependency(sourceControlRequirement: sourceControlRequirement, registryRequirement: registryRequirement, resolvedTemplatePath: resolvedTemplatePath), templatePath: resolvedTemplatePath, fileSystem: swiftCommandState.fileSystem, packageType: templateInitType, @@ -290,7 +319,8 @@ extension SwiftPackageCommand { /// Transforms the author's package into the required dependency private func packageDependency( - requirement: PackageDependency.SourceControl.Requirement?, + sourceControlRequirement: PackageDependency.SourceControl.Requirement? = nil, + registryRequirement: PackageDependency.Registry.Requirement? = nil, resolvedTemplatePath: Basics.AbsolutePath ) throws -> MappablePackageDependency.Kind { switch self.templateSource { @@ -302,14 +332,28 @@ extension SwiftPackageCommand { throw StringError("Missing Git url") } - guard let gitRequirement = requirement else { + guard let gitRequirement = sourceControlRequirement else { throw StringError("Missing Git requirement") } return .sourceControl(name: self.packageName, location: url, requirement: gitRequirement) + case .registry: + + guard let packageID = templatePackageID else { + throw StringError("Missing Package ID") + } + + + guard let packageRegistryRequirement = registryRequirement else { + throw StringError("Missing Registry requirement") + } + + return .registry(id: packageID, requirement: packageRegistryRequirement) + default: - throw StringError("Not implemented yet") + throw StringError("Missing template source type") } + } } } diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 00cc83b2d7a..2507d6ea5be 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -82,16 +82,18 @@ struct ShowTemplates: AsyncSwiftCommand { from: from, upToNextMinorFrom: upToNextMinorFrom, to: to - ).resolve() + ).resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement // Download and resolve the Git-based template. let resolver = TemplatePathResolver( templateSource: .git, templateDirectory: nil, templateURL: templateURL, - requirement: requirement + sourceControlRequirement: requirement, + registryRequirement: nil, + packageIdentity: nil ) - packagePath = try await resolver.resolve() + packagePath = try await resolver.resolve(swiftCommandState: swiftCommandState) shouldDeleteAfter = true } else { diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index 48150041557..f4aab1d8288 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -58,25 +58,43 @@ struct DependencyRequirementResolver { /// - None of the requirement fields are set. /// - A `to` value is provided without a corresponding `from` or `upToNextMinorFrom`. - func resolve() throws -> PackageDependency.SourceControl.Requirement { - var all: [PackageDependency.SourceControl.Requirement] = [] + func resolve(for type: DependencyType) throws -> Any { + // Resolve all possibilities first + var allGitRequirements: [PackageDependency.SourceControl.Requirement] = [] + if let v = exact { allGitRequirements.append(.exact(v)) } + if let b = branch { allGitRequirements.append(.branch(b)) } + if let r = revision { allGitRequirements.append(.revision(r)) } + if let f = from { allGitRequirements.append(.range(.upToNextMajor(from: f))) } + if let u = upToNextMinorFrom { allGitRequirements.append(.range(.upToNextMinor(from: u))) } - if let v = exact { all.append(.exact(v)) } - if let b = branch { all.append(.branch(b)) } - if let r = revision { all.append(.revision(r)) } - if let f = from { all.append(.range(.upToNextMajor(from: f))) } - if let u = upToNextMinorFrom { all.append(.range(.upToNextMinor(from: u))) } + // For Registry, only exact or range allowed: + var allRegistryRequirements: [PackageDependency.Registry.Requirement] = [] + if let v = exact { allRegistryRequirements.append(.exact(v)) } - guard all.count == 1, let requirement = all.first else { - throw StringError("Specify exactly one version requirement.") - } + switch type { + case .sourceControl: + guard allGitRequirements.count == 1, let requirement = allGitRequirements.first else { + throw StringError("Specify exactly one source control version requirement.") + } + if case .range(let range) = requirement, let upper = to { + return PackageDependency.SourceControl.Requirement.range(range.lowerBound ..< upper) + } else if self.to != nil { + throw StringError("--to requires --from or --up-to-next-minor-from") + } + return requirement - if case .range(let range) = requirement, let upper = to { - return .range(range.lowerBound ..< upper) - } else if self.to != nil { - throw StringError("--to requires --from or --up-to-next-minor-from") + case .registry: + guard allRegistryRequirements.count == 1, let requirement = allRegistryRequirements.first else { + throw StringError("Specify exactly one registry version requirement.") + } + // Registry does not support `to` separately, so range should already consider upper bound + return requirement } - - return requirement } } + + +enum DependencyType { + case sourceControl + case registry +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index 82039efcd5e..31d336a8b44 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -17,6 +17,11 @@ import SourceControl import TSCBasic import TSCUtility import Workspace +import CoreCommands +import PackageRegistry +import ArgumentParser +import PackageFingerprint +import PackageSigning /// A utility responsible for resolving the path to a package template, /// based on the provided template type and associated configuration. @@ -39,7 +44,13 @@ struct TemplatePathResolver { let templateURL: String? /// The versioning requirement for the Git repository (e.g., exact version, branch, revision, or version range). - let requirement: PackageDependency.SourceControl.Requirement? + let sourceControlRequirement: PackageDependency.SourceControl.Requirement? + + /// The versioning requirement for the registry package (e.g., exact version). + let registryRequirement: PackageDependency.Registry.Requirement? + + /// The package identifier of the package in package-registry + let packageIdentity: String? /// Resolves the template path by downloading or validating it based on the template type. /// @@ -47,7 +58,7 @@ struct TemplatePathResolver { /// - Throws: /// - `StringError` if required values (e.g., path, URL, requirement) are missing, /// or if the template type is unsupported or unspecified. - func resolve() async throws -> Basics.AbsolutePath { + func resolve(swiftCommandState: SwiftCommandState) async throws -> Basics.AbsolutePath { switch self.templateSource { case .local: guard let path = templateDirectory else { @@ -60,24 +71,124 @@ struct TemplatePathResolver { throw StringError("Missing Git URL for git template.") } - guard let gitRequirement = requirement else { + guard let requirement = sourceControlRequirement else { throw StringError("Missing version requirement for git template.") } - return try await GitTemplateFetcher(destination: url, requirement: gitRequirement).fetch() + return try await GitTemplateFetcher(source: url, requirement: requirement).fetch() case .registry: - throw StringError("Registry templates not supported yet.") + + guard let packageID = packageIdentity else { + throw StringError("Missing package identity for registry template") + } + + guard let requirement = registryRequirement else { + throw StringError("Missing version requirement for registry template.") + } + + return try await RegistryTemplateFetcher().fetch(swiftCommandState: swiftCommandState, packageIdentity: packageID, requirement: requirement) case .none: throw StringError("Missing --template-type.") } } + struct RegistryTemplateFetcher { + + + func fetch(swiftCommandState: SwiftCommandState, packageIdentity: String, requirement: PackageDependency.Registry.Requirement) async throws -> Basics.AbsolutePath { + + return try await withTemporaryDirectory(removeTreeOnDeinit: false) { tempDir in + + let configuration = try TemplatePathResolver.RegistryTemplateFetcher.getRegistriesConfig(swiftCommandState, global: true) + let registryConfiguration = configuration.configuration + + let authorizationProvider: AuthorizationProvider? + authorizationProvider = try swiftCommandState.getRegistryAuthorizationProvider() + + + let registryClient = RegistryClient( + configuration: registryConfiguration, + fingerprintStorage: .none, + fingerprintCheckingMode: .strict, + skipSignatureValidation: false, + signingEntityStorage: .none, + signingEntityCheckingMode: .strict, + authorizationProvider: authorizationProvider, + delegate: .none, + checksumAlgorithm: SHA256() + ) + + + let package = PackageIdentity.plain(packageIdentity) + + switch requirement { + case .exact(let version): + try await registryClient.downloadSourceArchive( + package: package, + version: Version(0, 0, 0), + destinationPath: tempDir.appending(component: packageIdentity), + progressHandler: nil, + timeout: nil, + fileSystem: swiftCommandState.fileSystem, + observabilityScope: swiftCommandState.observabilityScope + ) + + default: fatalError("Unsupported requirement: \(requirement)") + + } + + // Unpack directory and bring it to temp directory level. + let contents = try swiftCommandState.fileSystem.getDirectoryContents(tempDir) + guard let extractedDir = contents.first else { + throw StringError("No directory found after extraction.") + } + let extractedPath = tempDir.appending(component: extractedDir) + + for item in try swiftCommandState.fileSystem.getDirectoryContents(extractedPath) { + let src = extractedPath.appending(component: item) + let dst = tempDir.appending(component: item) + try swiftCommandState.fileSystem.move(from: src, to: dst) + } + + // Optionally remove the now-empty subdirectory + try swiftCommandState.fileSystem.removeFileTree(extractedPath) + + return tempDir + } + } + + static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace.Configuration.Registries { + if global { + let sharedRegistriesFile = Workspace.DefaultLocations.registriesConfigurationFile( + at: swiftCommandState.sharedConfigurationDirectory + ) + // Workspace not needed when working with user-level registries config + return try .init( + fileSystem: swiftCommandState.fileSystem, + localRegistriesFile: .none, + sharedRegistriesFile: sharedRegistriesFile + ) + } else { + let workspace = try swiftCommandState.getActiveWorkspace() + return try .init( + fileSystem: swiftCommandState.fileSystem, + localRegistriesFile: workspace.location.localRegistriesConfigurationFile, + sharedRegistriesFile: workspace.location.sharedRegistriesConfigurationFile + ) + } + } + + + + } + + /// A helper that fetches a Git-based template repository and checks out the specified version or revision. struct GitTemplateFetcher { /// The Git URL of the remote repository. - let destination: String + let source: String /// The source control requirement used to determine which version/branch/revision to check out. let requirement: PackageDependency.SourceControl.Requirement @@ -90,7 +201,7 @@ struct TemplatePathResolver { let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in - let url = SourceControlURL(destination) + let url = SourceControlURL(source) let repositorySpecifier = RepositorySpecifier(url: url) let repositoryProvider = GitRepositoryProvider() From 954f732952cc2be903b17632bcd6a7af1e507b0b Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 12 Jun 2025 13:55:38 -0400 Subject: [PATCH 051/225] added registry support for show-templates --- .../PackageCommands/ShowTemplates.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 2507d6ea5be..e23789ffa32 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -37,6 +37,9 @@ struct ShowTemplates: AsyncSwiftCommand { @Option(name: .customLong("template-url"), help: "The git URL of the template.") var templateURL: String? + @Option(name: .customLong("package-id"), help: "The package identifier of the template") + var templatePackageID: String? + /// Output format for the templates list. /// /// Can be either `.flatlist` (default) or `.json`. @@ -96,6 +99,29 @@ struct ShowTemplates: AsyncSwiftCommand { packagePath = try await resolver.resolve(swiftCommandState: swiftCommandState) shouldDeleteAfter = true + } else if let packageID = self.templatePackageID { + + let requirement = try DependencyRequirementResolver( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ).resolve(for: .registry) as? PackageDependency.Registry.Requirement + + // Download and resolve the Git-based template. + let resolver = TemplatePathResolver( + templateSource: .registry, + templateDirectory: nil, + templateURL: nil, + sourceControlRequirement: nil, + registryRequirement: requirement, + packageIdentity: packageID + ) + + packagePath = try await resolver.resolve(swiftCommandState: swiftCommandState) + shouldDeleteAfter = true } else { // Use the current working directory. guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { From c57abff3103fe899d019caa434711e1caf9bc6e9 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 12 Jun 2025 15:43:45 -0400 Subject: [PATCH 052/225] formatting + documentation + quality --- Sources/Commands/PackageCommands/Init.swift | 143 ++---- .../PackageCommands/ShowTemplates.swift | 99 ++-- .../PackageDependencyBuilder.swift | 99 ++++ .../RequirementResolver.swift | 132 ++++-- .../TemplatePathResolver.swift | 434 +++++++++--------- 5 files changed, 505 insertions(+), 402 deletions(-) create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 7d96a7080d8..62c1d24c852 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -117,8 +117,6 @@ extension SwiftPackageCommand { @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? - - func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") @@ -167,63 +165,35 @@ extension SwiftPackageCommand { packageName: String, cwd: Basics.AbsolutePath ) async throws { - let resolvedTemplatePath: Basics.AbsolutePath - var registryRequirement: PackageDependency.Registry.Requirement? - var sourceControlRequirement: PackageDependency.SourceControl.Requirement? - - switch self.templateSource { - case .git: - sourceControlRequirement = try DependencyRequirementResolver( - exact: self.exact, - revision: self.revision, - branch: self.branch, - from: self.from, - upToNextMinorFrom: self.upToNextMinorFrom, - to: self.to - ).resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement - - - resolvedTemplatePath = try await TemplatePathResolver( - templateSource: self.templateSource, - templateDirectory: self.templateDirectory, - templateURL: self.templateURL, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: nil, - packageIdentity: nil - ).resolve(swiftCommandState: swiftCommandState) - - case .local: - resolvedTemplatePath = try await TemplatePathResolver( - templateSource: self.templateSource, - templateDirectory: self.templateDirectory, - templateURL: self.templateURL, - sourceControlRequirement: nil, - registryRequirement: nil, - packageIdentity: nil - ).resolve(swiftCommandState: swiftCommandState) - case .registry: - - registryRequirement = try DependencyRequirementResolver( - exact: self.exact, - revision: self.revision, - branch: self.branch, - from: self.from, - upToNextMinorFrom: self.upToNextMinorFrom, - to: self.to - ).resolve(for: .registry) as? PackageDependency.Registry.Requirement - - resolvedTemplatePath = try await TemplatePathResolver( - templateSource: self.templateSource, - templateDirectory: self.templateDirectory, - templateURL: self.templateURL, - sourceControlRequirement: nil, - registryRequirement: registryRequirement, - packageIdentity: templatePackageID - ).resolve(swiftCommandState: swiftCommandState) - case .none: - throw StringError("Missing template type") + guard let source = templateSource else { + throw ValidationError("No template source specified.") } + let requirementResolver = DependencyRequirementResolver( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ) + + let registryRequirement: PackageDependency.Registry.Requirement? = + try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement + + let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = + try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + + let resolvedTemplatePath = try await TemplatePathResolver( + source: templateSource, + templateDirectory: templateDirectory, + templateURL: templateURL, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() + let templateInitType = try await swiftCommandState .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in try await self.checkConditions(swiftCommandState) @@ -231,8 +201,11 @@ extension SwiftPackageCommand { // Clean up downloaded package after execution. defer { - if templateSource == .git || templateSource == .registry { + if templateSource == .git { try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) + } else if templateSource == .registry { + let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL + try? FileManager.default.removeItem(at: parentDirectoryURL) } } @@ -242,10 +215,23 @@ extension SwiftPackageCommand { swiftCommandState: swiftCommandState ) + let builder = DefaultPackageDependencyBuilder( + templateSource: source, + packageName: packageName, + templateURL: self.templateURL, + templatePackageID: self.templatePackageID + ) + + let dependencyKind = try builder.makePackageDependency( + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ) + let initTemplatePackage = try InitTemplatePackage( name: packageName, templateName: template, - initMode: packageDependency(sourceControlRequirement: sourceControlRequirement, registryRequirement: registryRequirement, resolvedTemplatePath: resolvedTemplatePath), + initMode: dependencyKind, templatePath: resolvedTemplatePath, fileSystem: swiftCommandState.fileSystem, packageType: templateInitType, @@ -316,45 +302,6 @@ extension SwiftPackageCommand { } throw InternalError("Could not find template \(self.template)") } - - /// Transforms the author's package into the required dependency - private func packageDependency( - sourceControlRequirement: PackageDependency.SourceControl.Requirement? = nil, - registryRequirement: PackageDependency.Registry.Requirement? = nil, - resolvedTemplatePath: Basics.AbsolutePath - ) throws -> MappablePackageDependency.Kind { - switch self.templateSource { - case .local: - return .fileSystem(name: self.packageName, path: resolvedTemplatePath.asURL.path) - - case .git: - guard let url = templateURL else { - throw StringError("Missing Git url") - } - - guard let gitRequirement = sourceControlRequirement else { - throw StringError("Missing Git requirement") - } - return .sourceControl(name: self.packageName, location: url, requirement: gitRequirement) - - case .registry: - - guard let packageID = templatePackageID else { - throw StringError("Missing Package ID") - } - - - guard let packageRegistryRequirement = registryRequirement else { - throw StringError("Missing Registry requirement") - } - - return .registry(id: packageID, requirement: packageRegistryRequirement) - - default: - throw StringError("Missing template source type") - } - - } } } diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index e23789ffa32..9a25f254470 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -76,71 +76,86 @@ struct ShowTemplates: AsyncSwiftCommand { let packagePath: Basics.AbsolutePath var shouldDeleteAfter = false - if let templateURL = self.templateURL { - // Resolve dependency requirement based on provided options. - let requirement = try DependencyRequirementResolver( - exact: exact, - revision: revision, - branch: branch, - from: from, - upToNextMinorFrom: upToNextMinorFrom, - to: to - ).resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + let requirementResolver = DependencyRequirementResolver( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ) + + let registryRequirement: PackageDependency.Registry.Requirement? = + try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement + + let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = + try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + + var resolvedTemplatePath: Basics.AbsolutePath + var templateSource: InitTemplatePackage.TemplateSource + if let templateURL = self.templateURL { // Download and resolve the Git-based template. - let resolver = TemplatePathResolver( - templateSource: .git, + resolvedTemplatePath = try await TemplatePathResolver( + source: .git, templateDirectory: nil, templateURL: templateURL, - sourceControlRequirement: requirement, - registryRequirement: nil, - packageIdentity: nil - ) - packagePath = try await resolver.resolve(swiftCommandState: swiftCommandState) - shouldDeleteAfter = true - - } else if let packageID = self.templatePackageID { + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: self.templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() - let requirement = try DependencyRequirementResolver( - exact: exact, - revision: revision, - branch: branch, - from: from, - upToNextMinorFrom: upToNextMinorFrom, - to: to - ).resolve(for: .registry) as? PackageDependency.Registry.Requirement + templateSource = .git + } else if let packageID = self.templatePackageID { // Download and resolve the Git-based template. - let resolver = TemplatePathResolver( - templateSource: .registry, + resolvedTemplatePath = try await TemplatePathResolver( + source: .registry, templateDirectory: nil, templateURL: nil, - sourceControlRequirement: nil, - registryRequirement: requirement, - packageIdentity: packageID - ) + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: self.templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() + + templateSource = .registry - packagePath = try await resolver.resolve(swiftCommandState: swiftCommandState) - shouldDeleteAfter = true } else { // Use the current working directory. guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("No template URL provided and no current directory") } - packagePath = cwd + + resolvedTemplatePath = try await TemplatePathResolver( + source: .local, + templateDirectory: cwd, + templateURL: nil, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: nil, + swiftCommandState: swiftCommandState + ).resolve() + + templateSource = .local } // Clean up downloaded package after execution. defer { - if shouldDeleteAfter { - try? FileManager.default.removeItem(atPath: packagePath.pathString) + if templateSource == .git { + try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) + } else if templateSource == .registry { + let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL + try? FileManager.default.removeItem(at: parentDirectoryURL) } } // Load the package graph. - let packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: packagePath) { _, _ in - try await swiftCommandState.loadPackageGraph() - } + let packageGraph = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in + try await swiftCommandState.loadPackageGraph() + } let rootPackages = packageGraph.rootPackages.map(\.identity) diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift new file mode 100644 index 00000000000..5c84727436c --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation +import PackageModel +import TSCBasic +import TSCUtility +import Workspace + +/// A protocol for building `MappablePackageDependency.Kind` instances from provided dependency information. +/// +/// Conforming types are responsible for converting high-level dependency configuration +/// (such as template source type and associated metadata) into a concrete dependency +/// that SwiftPM can work with. +protocol PackageDependencyBuilder { + /// Constructs a `MappablePackageDependency.Kind` based on the provided requirements and template path. + /// + /// - Parameters: + /// - sourceControlRequirement: The source control requirement (e.g., Git-based), if applicable. + /// - registryRequirement: The registry requirement, if applicable. + /// - resolvedTemplatePath: The resolved absolute path to a local package template, if applicable. + /// + /// - Returns: A concrete `MappablePackageDependency.Kind` value. + /// + /// - Throws: A `StringError` if required inputs (e.g., Git URL, Package ID) are missing or invalid for the selected + /// source type. + func makePackageDependency( + sourceControlRequirement: PackageDependency.SourceControl.Requirement?, + registryRequirement: PackageDependency.Registry.Requirement?, + resolvedTemplatePath: Basics.AbsolutePath + ) throws -> MappablePackageDependency.Kind +} + +/// Default implementation of `PackageDependencyBuilder` that builds a package dependency +/// from a given template source and metadata. +/// +/// This struct is typically used when initializing new packages from templates via SwiftPM. +struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { + /// The source type of the package template (e.g., local file system, Git repository, or registry). + let templateSource: InitTemplatePackage.TemplateSource + + /// The name to assign to the resulting package dependency. + let packageName: String + + /// The URL of the Git repository, if the template source is Git-based. + let templateURL: String? + + /// The registry package identifier, if the template source is registry-based. + let templatePackageID: String? + + /// Constructs a package dependency kind based on the selected template source. + /// + /// - Parameters: + /// - sourceControlRequirement: The requirement for Git-based dependencies. + /// - registryRequirement: The requirement for registry-based dependencies. + /// - resolvedTemplatePath: The local file path for filesystem-based dependencies. + /// + /// - Returns: A `MappablePackageDependency.Kind` representing the dependency. + /// + /// - Throws: A `StringError` if necessary information is missing or mismatched for the selected template source. + func makePackageDependency( + sourceControlRequirement: PackageDependency.SourceControl.Requirement? = nil, + registryRequirement: PackageDependency.Registry.Requirement? = nil, + resolvedTemplatePath: Basics.AbsolutePath + ) throws -> MappablePackageDependency.Kind { + switch self.templateSource { + case .local: + return .fileSystem(name: self.packageName, path: resolvedTemplatePath.asURL.path) + + case .git: + guard let url = templateURL else { + throw StringError("Missing Git url") + } + guard let requirement = sourceControlRequirement else { + throw StringError("Missing Git requirement") + } + return .sourceControl(name: self.packageName, location: url, requirement: requirement) + + case .registry: + guard let id = templatePackageID else { + throw StringError("Missing Package ID") + } + guard let requirement = registryRequirement else { + throw StringError("Missing Registry requirement") + } + return .registry(id: id, requirement: requirement) + } + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index f4aab1d8288..61b2981074a 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -14,24 +14,29 @@ import PackageModel import TSCBasic import TSCUtility -/// A utility for resolving a single, well-formed source control dependency requirement -/// based on mutually exclusive versioning inputs such as `exact`, `branch`, `revision`, -/// or version ranges (`from`, `upToNextMinorFrom`, `to`). -/// -/// This is typically used to translate user-specified version inputs (e.g., from the command line) -/// into a concrete `PackageDependency.SourceControl.Requirement` that SwiftPM can understand. -/// -/// Only one of the following fields should be non-nil: -/// - `exact`: A specific version (e.g., 1.2.3). -/// - `revision`: A specific VCS revision (e.g., commit hash). -/// - `branch`: A named branch (e.g., "main"). -/// - `from`: Lower bound of a version range with an upper bound inferred as the next major version. -/// - `upToNextMinorFrom`: Lower bound of a version range with an upper bound inferred as the next minor version. +/// A protocol defining an interface for resolving package dependency requirements +/// based on a user’s input (such as version, branch, or revision). +protocol DependencyRequirementResolving { + /// Resolves the requirement for the specified dependency type. + /// + /// - Parameter type: The type of dependency (`.sourceControl` or `.registry`) to resolve. + /// - Returns: A resolved requirement (`SourceControl.Requirement` or `Registry.Requirement`) as `Any`. + /// - Throws: `StringError` if resolution fails due to invalid or conflicting input. + func resolve(for type: DependencyType) throws -> Any +} + +/// A utility for resolving a single, well-formed package dependency requirement +/// from mutually exclusive versioning inputs, such as: +/// - `exact`: A specific version (e.g., 1.2.3) +/// - `branch`: A branch name (e.g., "main") +/// - `revision`: A commit hash or VCS revision +/// - `from` / `upToNextMinorFrom`: Lower bounds for version ranges +/// - `to`: An optional upper bound that refines a version range /// -/// Optionally, a `to` value can be specified to manually cap the upper bound of a version range, -/// but it must be combined with `from` or `upToNextMinorFrom`. +/// This resolver ensures only one form of versioning input is specified and validates combinations like `to` with +/// `from`. -struct DependencyRequirementResolver { +struct DependencyRequirementResolver: DependencyRequirementResolving { /// An exact version to use. let exact: Version? @@ -50,51 +55,78 @@ struct DependencyRequirementResolver { /// An optional manual upper bound for the version range. Must be used with `from` or `upToNextMinorFrom`. let to: Version? - /// Resolves the provided requirement fields into a concrete `PackageDependency.SourceControl.Requirement`. + /// Resolves a concrete requirement based on the provided fields and target dependency type. /// - /// - Returns: A valid, single requirement representing a source control constraint. - /// - Throws: A `StringError` if: - /// - More than one requirement type is provided. - /// - None of the requirement fields are set. - /// - A `to` value is provided without a corresponding `from` or `upToNextMinorFrom`. + /// - Parameter type: The dependency type to resolve (`.sourceControl` or `.registry`). + /// - Returns: A resolved requirement object (`PackageDependency.SourceControl.Requirement` or + /// `PackageDependency.Registry.Requirement`). + /// - Throws: `StringError` if the inputs are invalid, ambiguous, or incomplete. func resolve(for type: DependencyType) throws -> Any { - // Resolve all possibilities first - var allGitRequirements: [PackageDependency.SourceControl.Requirement] = [] - if let v = exact { allGitRequirements.append(.exact(v)) } - if let b = branch { allGitRequirements.append(.branch(b)) } - if let r = revision { allGitRequirements.append(.revision(r)) } - if let f = from { allGitRequirements.append(.range(.upToNextMajor(from: f))) } - if let u = upToNextMinorFrom { allGitRequirements.append(.range(.upToNextMinor(from: u))) } - - // For Registry, only exact or range allowed: - var allRegistryRequirements: [PackageDependency.Registry.Requirement] = [] - if let v = exact { allRegistryRequirements.append(.exact(v)) } - switch type { case .sourceControl: - guard allGitRequirements.count == 1, let requirement = allGitRequirements.first else { - throw StringError("Specify exactly one source control version requirement.") - } - if case .range(let range) = requirement, let upper = to { - return PackageDependency.SourceControl.Requirement.range(range.lowerBound ..< upper) - } else if self.to != nil { - throw StringError("--to requires --from or --up-to-next-minor-from") - } - return requirement - + try self.resolveSourceControlRequirement() case .registry: - guard allRegistryRequirements.count == 1, let requirement = allRegistryRequirements.first else { - throw StringError("Specify exactly one registry version requirement.") - } - // Registry does not support `to` separately, so range should already consider upper bound - return requirement + try self.resolveRegistryRequirement() } } -} + /// Internal helper for resolving a source control (Git) requirement. + /// + /// - Returns: A valid `PackageDependency.SourceControl.Requirement`. + /// - Throws: `StringError` if multiple or no input fields are set, or if `to` is used without `from` or + /// `upToNextMinorFrom`. + private func resolveSourceControlRequirement() throws -> PackageDependency.SourceControl.Requirement { + var requirements: [PackageDependency.SourceControl.Requirement] = [] + if let v = exact { requirements.append(.exact(v)) } + if let b = branch { requirements.append(.branch(b)) } + if let r = revision { requirements.append(.revision(r)) } + if let f = from { requirements.append(.range(.upToNextMajor(from: f))) } + if let u = upToNextMinorFrom { requirements.append(.range(.upToNextMinor(from: u))) } + + guard requirements.count == 1, let requirement = requirements.first else { + throw StringError("Specify exactly one source control version requirement.") + } + + if case .range(let range) = requirement, let upper = to { + return .range(range.lowerBound ..< upper) + } else if self.to != nil { + throw StringError("--to requires --from or --up-to-next-minor-from") + } + + return requirement + } + + /// Internal helper for resolving a registry-based requirement. + /// + /// - Returns: A valid `PackageDependency.Registry.Requirement`. + /// - Throws: `StringError` if more than one registry versioning input is provided or if `to` is used without a base + /// range. + private func resolveRegistryRequirement() throws -> PackageDependency.Registry.Requirement { + var requirements: [PackageDependency.Registry.Requirement] = [] + + if let v = exact { requirements.append(.exact(v)) } + if let f = from { requirements.append(.range(.upToNextMajor(from: f))) } + if let u = upToNextMinorFrom { requirements.append(.range(.upToNextMinor(from: u))) } + + guard requirements.count == 1, let requirement = requirements.first else { + throw StringError("Specify exactly one source control version requirement.") + } + + if case .range(let range) = requirement, let upper = to { + return .range(range.lowerBound ..< upper) + } else if self.to != nil { + throw StringError("--to requires --from or --up-to-next-minor-from") + } + + return requirement + } +} +/// Enum representing the type of dependency to resolve. enum DependencyType { + /// A source control dependency, such as a Git repository. case sourceControl + /// A registry dependency, typically resolved from a package registry. case registry } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index 31d336a8b44..be18d97df36 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -10,274 +10,284 @@ // //===----------------------------------------------------------------------===// +import ArgumentParser import Basics +import CoreCommands import Foundation +import PackageFingerprint import PackageModel +import PackageRegistry +import PackageSigning import SourceControl import TSCBasic import TSCUtility import Workspace -import CoreCommands -import PackageRegistry -import ArgumentParser -import PackageFingerprint -import PackageSigning -/// A utility responsible for resolving the path to a package template, -/// based on the provided template type and associated configuration. -/// -/// Supported template types include: -/// - `.local`: A local file system path to a template directory. -/// - `.git`: A remote Git repository containing the template. -/// - `.registry`: (Currently unsupported) +/// A protocol representing a generic package template fetcher. /// -/// Used during package initialization (e.g., via `swift package init --template`). +/// Conforming types encapsulate the logic to retrieve a template from a given source, +/// such as a local path, Git repository, or registry. The template is expected to be +/// returned as an absolute path to its location on the file system. +protocol TemplateFetcher { + func fetch() async throws -> Basics.AbsolutePath +} +/// Resolves the path to a Swift package template based on the specified template source. +/// +/// This struct determines how to obtain the template, whether from: +/// - A local directory (`.local`) +/// - A Git repository (`.git`) +/// - A Swift package registry (`.registry`) +/// +/// It abstracts the underlying fetch logic using a strategy pattern via the `TemplateFetcher` protocol. +/// +/// Usage: +/// ```swift +/// let resolver = try TemplatePathResolver(...) +/// let templatePath = try await resolver.resolve() +/// ``` struct TemplatePathResolver { - /// The source of template to resolve (e.g., local, git, registry). - let templateSource: InitTemplatePackage.TemplateSource? - - /// The local path to a template directory, used for `.local` templates. - let templateDirectory: Basics.AbsolutePath? - - /// The URL of the Git repository containing the template, used for `.git` templates. - let templateURL: String? - - /// The versioning requirement for the Git repository (e.g., exact version, branch, revision, or version range). - let sourceControlRequirement: PackageDependency.SourceControl.Requirement? + let fetcher: TemplateFetcher - /// The versioning requirement for the registry package (e.g., exact version). - let registryRequirement: PackageDependency.Registry.Requirement? - - /// The package identifier of the package in package-registry - let packageIdentity: String? - - /// Resolves the template path by downloading or validating it based on the template type. + /// Initializes a TemplatePathResolver with the given source and options. + /// + /// - Parameters: + /// - source: The type of template source (`local`, `git`, or `registry`). + /// - templateDirectory: Local path if using `.local` source. + /// - templateURL: Git URL if using `.git` source. + /// - sourceControlRequirement: Versioning or branch details for Git. + /// - registryRequirement: Versioning requirement for registry. + /// - packageIdentity: Package name/identity used with registry templates. + /// - swiftCommandState: Command state to access file system and config. /// - /// - Returns: The resolved path to the template directory. - /// - Throws: - /// - `StringError` if required values (e.g., path, URL, requirement) are missing, - /// or if the template type is unsupported or unspecified. - func resolve(swiftCommandState: SwiftCommandState) async throws -> Basics.AbsolutePath { - switch self.templateSource { + /// - Throws: `StringError` if any required parameter is missing. + init( + source: InitTemplatePackage.TemplateSource?, + templateDirectory: Basics.AbsolutePath?, + templateURL: String?, + sourceControlRequirement: PackageDependency.SourceControl.Requirement?, + registryRequirement: PackageDependency.Registry.Requirement?, + packageIdentity: String?, + swiftCommandState: SwiftCommandState + ) throws { + switch source { case .local: guard let path = templateDirectory else { throw StringError("Template path must be specified for local templates.") } - return path + self.fetcher = LocalTemplateFetcher(path: path) case .git: - guard let url = templateURL else { - throw StringError("Missing Git URL for git template.") - } - - guard let requirement = sourceControlRequirement else { - throw StringError("Missing version requirement for git template.") + guard let url = templateURL, let requirement = sourceControlRequirement else { + throw StringError("Missing Git URL or requirement for git template.") } - - return try await GitTemplateFetcher(source: url, requirement: requirement).fetch() + self.fetcher = GitTemplateFetcher(source: url, requirement: requirement) case .registry: - - guard let packageID = packageIdentity else { - throw StringError("Missing package identity for registry template") - } - - guard let requirement = registryRequirement else { - throw StringError("Missing version requirement for registry template.") + guard let identity = packageIdentity, let requirement = registryRequirement else { + throw StringError("Missing registry package identity or requirement.") } - - return try await RegistryTemplateFetcher().fetch(swiftCommandState: swiftCommandState, packageIdentity: packageID, requirement: requirement) + self.fetcher = RegistryTemplateFetcher( + swiftCommandState: swiftCommandState, + packageIdentity: identity, + requirement: requirement + ) case .none: throw StringError("Missing --template-type.") } } - struct RegistryTemplateFetcher { + /// Resolves the template path by executing the underlying fetcher. + /// + /// - Returns: Absolute path to the downloaded or located template directory. + /// - Throws: Any error encountered during fetch. + func resolve() async throws -> Basics.AbsolutePath { + try await self.fetcher.fetch() + } +} +/// Fetcher implementation for local file system templates. +/// +/// Simply returns the provided path as-is, assuming it exists and is valid. +struct LocalTemplateFetcher: TemplateFetcher { + let path: Basics.AbsolutePath - func fetch(swiftCommandState: SwiftCommandState, packageIdentity: String, requirement: PackageDependency.Registry.Requirement) async throws -> Basics.AbsolutePath { + func fetch() async throws -> Basics.AbsolutePath { + self.path + } +} + +/// Fetches a Swift package template from a Git repository based on a specified requirement. +/// +/// Supports: +/// - Checkout by tag (exact version) +/// - Checkout by branch +/// - Checkout by specific revision +/// - Checkout the highest version within a version range +/// +/// The template is cloned into a temporary directory, checked out, and returned. - return try await withTemporaryDirectory(removeTreeOnDeinit: false) { tempDir in +struct GitTemplateFetcher: TemplateFetcher { + /// The Git URL of the remote repository. + let source: String - let configuration = try TemplatePathResolver.RegistryTemplateFetcher.getRegistriesConfig(swiftCommandState, global: true) - let registryConfiguration = configuration.configuration + /// The source control requirement used to determine which version/branch/revision to check out. + let requirement: PackageDependency.SourceControl.Requirement - let authorizationProvider: AuthorizationProvider? - authorizationProvider = try swiftCommandState.getRegistryAuthorizationProvider() + /// Fetches the repository and returns the path to the checked-out working copy. + /// + /// - Returns: A path to the directory containing the fetched template. + /// - Throws: Any error encountered during repository fetch, checkout, or validation. + /// Fetches a bare clone of the Git repository to the specified path. + func fetch() async throws -> Basics.AbsolutePath { + let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in + try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in - let registryClient = RegistryClient( - configuration: registryConfiguration, - fingerprintStorage: .none, - fingerprintCheckingMode: .strict, - skipSignatureValidation: false, - signingEntityStorage: .none, - signingEntityCheckingMode: .strict, - authorizationProvider: authorizationProvider, - delegate: .none, - checksumAlgorithm: SHA256() - ) + let url = SourceControlURL(source) + let repositorySpecifier = RepositorySpecifier(url: url) + let repositoryProvider = GitRepositoryProvider() + let bareCopyPath = tempDir.appending(component: "bare-copy") - let package = PackageIdentity.plain(packageIdentity) + let workingCopyPath = tempDir.appending(component: "working-copy") - switch requirement { - case .exact(let version): - try await registryClient.downloadSourceArchive( - package: package, - version: Version(0, 0, 0), - destinationPath: tempDir.appending(component: packageIdentity), - progressHandler: nil, - timeout: nil, - fileSystem: swiftCommandState.fileSystem, - observabilityScope: swiftCommandState.observabilityScope - ) + try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) - default: fatalError("Unsupported requirement: \(requirement)") + try self.validateDirectory(provider: repositoryProvider, at: bareCopyPath) - } + try FileManager.default.createDirectory( + atPath: workingCopyPath.pathString, + withIntermediateDirectories: true + ) - // Unpack directory and bring it to temp directory level. - let contents = try swiftCommandState.fileSystem.getDirectoryContents(tempDir) - guard let extractedDir = contents.first else { - throw StringError("No directory found after extraction.") - } - let extractedPath = tempDir.appending(component: extractedDir) + let repository = try repositoryProvider.createWorkingCopyFromBare( + repository: repositorySpecifier, + sourcePath: bareCopyPath, + at: workingCopyPath, + editable: true + ) - for item in try swiftCommandState.fileSystem.getDirectoryContents(extractedPath) { - let src = extractedPath.appending(component: item) - let dst = tempDir.appending(component: item) - try swiftCommandState.fileSystem.move(from: src, to: dst) - } + try FileManager.default.removeItem(at: bareCopyPath.asURL) - // Optionally remove the now-empty subdirectory - try swiftCommandState.fileSystem.removeFileTree(extractedPath) + try self.checkout(repository: repository) - return tempDir + return workingCopyPath } } - static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace.Configuration.Registries { - if global { - let sharedRegistriesFile = Workspace.DefaultLocations.registriesConfigurationFile( - at: swiftCommandState.sharedConfigurationDirectory - ) - // Workspace not needed when working with user-level registries config - return try .init( - fileSystem: swiftCommandState.fileSystem, - localRegistriesFile: .none, - sharedRegistriesFile: sharedRegistriesFile - ) - } else { - let workspace = try swiftCommandState.getActiveWorkspace() - return try .init( - fileSystem: swiftCommandState.fileSystem, - localRegistriesFile: workspace.location.localRegistriesConfigurationFile, - sharedRegistriesFile: workspace.location.sharedRegistriesConfigurationFile - ) - } - } - - - + return try await fetchStandalonePackageByURL() } - - /// A helper that fetches a Git-based template repository and checks out the specified version or revision. - struct GitTemplateFetcher { - /// The Git URL of the remote repository. - let source: String - - /// The source control requirement used to determine which version/branch/revision to check out. - let requirement: PackageDependency.SourceControl.Requirement - - /// Fetches the repository and returns the path to the checked-out working copy. - /// - /// - Returns: A path to the directory containing the fetched template. - /// - Throws: Any error encountered during repository fetch, checkout, or validation. - func fetch() async throws -> Basics.AbsolutePath { - let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in - try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in - - let url = SourceControlURL(source) - let repositorySpecifier = RepositorySpecifier(url: url) - let repositoryProvider = GitRepositoryProvider() - - let bareCopyPath = tempDir.appending(component: "bare-copy") - - let workingCopyPath = tempDir.appending(component: "working-copy") - - try self.fetchBareRepository( - provider: repositoryProvider, - specifier: repositorySpecifier, - to: bareCopyPath - ) - try self.validateDirectory(provider: repositoryProvider, at: bareCopyPath) - - try FileManager.default.createDirectory( - atPath: workingCopyPath.pathString, - withIntermediateDirectories: true - ) - - let repository = try repositoryProvider.createWorkingCopyFromBare( - repository: repositorySpecifier, - sourcePath: bareCopyPath, - at: workingCopyPath, - editable: true - ) - - try FileManager.default.removeItem(at: bareCopyPath.asURL) - - try self.checkout(repository: repository) - - return workingCopyPath - } - } - - return try await fetchStandalonePackageByURL() - } - - /// Fetches a bare clone of the Git repository to the specified path. - private func fetchBareRepository( - provider: GitRepositoryProvider, - specifier: RepositorySpecifier, - to path: Basics.AbsolutePath - ) throws { - try provider.fetch(repository: specifier, to: path) + /// Validates that the directory contains a valid Git repository. + private func validateDirectory(provider: GitRepositoryProvider, at path: Basics.AbsolutePath) throws { + guard try provider.isValidDirectory(path) else { + throw InternalError("Invalid directory at \(path)") } + } - /// Validates that the directory contains a valid Git repository. - private func validateDirectory(provider: GitRepositoryProvider, at path: Basics.AbsolutePath) throws { - guard try provider.isValidDirectory(path) else { - throw InternalError("Invalid directory at \(path)") + /// Checks out the desired state (branch, tag, revision) in the working copy based on the requirement. + /// + /// - Throws: An error if no matching version is found in a version range, or if checkout fails. + private func checkout(repository: WorkingCheckout) throws { + switch self.requirement { + case .exact(let version): + try repository.checkout(tag: version.description) + + case .branch(let name): + try repository.checkout(branch: name) + + case .revision(let revision): + try repository.checkout(revision: .init(identifier: revision)) + + case .range(let range): + let tags = try repository.getTags() + let versions = tags.compactMap { Version($0) } + let filteredVersions = versions.filter { range.contains($0) } + guard let latestVersion = filteredVersions.max() else { + throw InternalError("No tags found within the specified version range \(range)") } + try repository.checkout(tag: latestVersion.description) } + } +} - /// Checks out the desired state (branch, tag, revision) in the working copy based on the requirement. - /// - /// - Throws: An error if no matching version is found in a version range, or if checkout fails. - private func checkout(repository: WorkingCheckout) throws { - switch self.requirement { - case .exact(let version): - try repository.checkout(tag: version.description) - - case .branch(let name): - try repository.checkout(branch: name) - - case .revision(let revision): - try repository.checkout(revision: .init(identifier: revision)) - - case .range(let range): - let tags = try repository.getTags() - let versions = tags.compactMap { Version($0) } - let filteredVersions = versions.filter { range.contains($0) } - guard let latestVersion = filteredVersions.max() else { - throw InternalError("No tags found within the specified version range \(range)") - } - try repository.checkout(tag: latestVersion.description) +/// Fetches a Swift package template from a package registry. +/// +/// Downloads the source archive for the specified package and version. +/// Extracts it to a temporary directory and returns the path. +/// +/// Supports: +/// - Exact version +/// - Upper bound of a version range (e.g., latest version within a range) +struct RegistryTemplateFetcher: TemplateFetcher { + /// The swiftCommandState of the current process. + /// Used to get configurations and authentication needed to get package from registry + let swiftCommandState: SwiftCommandState + + /// The package identifier of the package in registry + let packageIdentity: String + /// The registry requirement used to determine which version to fetch. + let requirement: PackageDependency.Registry.Requirement + + /// Performs the registry fetch by downloading and extracting a source archive. + /// + /// - Returns: Absolute path to the extracted template directory. + /// - Throws: If registry configuration is invalid or the download fails. + + func fetch() async throws -> Basics.AbsolutePath { + try await withTemporaryDirectory(removeTreeOnDeinit: false) { tempDir in + let config = try Self.getRegistriesConfig(self.swiftCommandState, global: true) + let auth = try swiftCommandState.getRegistryAuthorizationProvider() + + let registryClient = RegistryClient( + configuration: config.configuration, + fingerprintStorage: .none, + fingerprintCheckingMode: .strict, + skipSignatureValidation: false, + signingEntityStorage: .none, + signingEntityCheckingMode: .strict, + authorizationProvider: auth, + delegate: .none, + checksumAlgorithm: SHA256() + ) + + let identity = PackageIdentity.plain(self.packageIdentity) + + let version: Version = switch self.requirement { + case .exact(let ver): ver + case .range(let range): range.upperBound } + + let dest = tempDir.appending(component: self.packageIdentity) + try await registryClient.downloadSourceArchive( + package: identity, + version: version, + destinationPath: dest, + progressHandler: nil, + timeout: nil, + fileSystem: self.swiftCommandState.fileSystem, + observabilityScope: self.swiftCommandState.observabilityScope + ) + + return dest } } + + /// Resolves the registry configuration from shared SwiftPM configuration. + /// + /// - Returns: Registry configuration to use for fetching packages. + /// - Throws: If configuration files are missing or unreadable. + private static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace + .Configuration.Registries + { + let sharedFile = Workspace.DefaultLocations + .registriesConfigurationFile(at: swiftCommandState.sharedConfigurationDirectory) + return try .init( + fileSystem: swiftCommandState.fileSystem, + localRegistriesFile: .none, + sharedRegistriesFile: sharedFile + ) + } } From cf21512b664b49c46e674847888ff20bf583b73b Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Fri, 13 Jun 2025 10:36:58 -0400 Subject: [PATCH 053/225] Add instructions to the SwiftPM readme for trying out templates --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index f1f2320688c..dbb900d55a8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,35 @@ # Swift Package Manager Project +## Swift Package Manager Templates + +This branch has an experimental SwiftPM template feature that you can use to experiment. Here's how you can try it out. + +First, you need to build this package and produce SwiftPM binaries with the template support: + +``` +swift build +``` + +Now you can go to an empty directory and use an example template to make a package like this: + +``` +/.build/debug/swift-package init --template PartsService --template-type git --template-url git@github.pie.apple.com:jbute/simple-template-example.git +``` + +There's also a template maker that will help you to write your own template. Here's how you can generate your own template: + +``` +/.build/debug/swift-package init --template TemplateMaker --template-type git --template-url git@github.pie.apple.com:christie-mcgee/template.git +``` + +Once you've customized your template then you can test it from an empty directory: + +``` +/.build/debug/swift-package init --template MyTemplate --template-type local --template-path +``` + +## About SwiftPM + The Swift Package Manager is a tool for managing distribution of source code, aimed at making it easy to share your code and reuse others’ code. The tool directly addresses the challenges of compiling and linking Swift packages, managing dependencies, versioning, and supporting flexible distribution and collaboration models. We’ve designed the system to make it easy to share packages on services like GitHub, but packages are also great for private personal development, sharing code within a team, or at any other granularity. From 5049f2792dfea8e1eeeb97a09ac5c5d63eafd6c1 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Fri, 13 Jun 2025 13:43:55 -0400 Subject: [PATCH 054/225] Make the template option optional when initializing with a template --- Sources/Commands/PackageCommands/Init.swift | 29 ++++++++++--------- .../PackageCommands/PluginCommand.swift | 9 ++++-- Sources/Workspace/InitTemplatePackage.swift | 9 ++---- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 62c1d24c852..b83c3c0ea88 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -70,11 +70,11 @@ extension SwiftPackageCommand { var createPackagePath = true /// Name of a template to use for package initialization. - @Option(name: .customLong("template"), help: "Name of a template to initialize the package.") - var template: String = "" + @Option(name: .customLong("template"), help: "Name of a template to initialize the package, unspecified if the default template should be used.") + var template: String? /// Returns true if a template is specified. - var useTemplates: Bool { !self.template.isEmpty } + var useTemplates: Bool { self.templateSource != nil } /// The type of template to use: `registry`, `git`, or `local`. @Option(name: .customLong("template-type"), help: "Template type: registry, git, local.") @@ -230,7 +230,6 @@ extension SwiftPackageCommand { let initTemplatePackage = try InitTemplatePackage( name: packageName, - templateName: template, initMode: dependencyKind, templatePath: resolvedTemplatePath, fileSystem: swiftCommandState.fileSystem, @@ -252,8 +251,14 @@ extension SwiftPackageCommand { let packageGraph = try await swiftCommandState.loadPackageGraph() let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: packageGraph, limitedTo: nil) + guard let commandPlugin = matchingPlugins.first else { + guard let template = self.template else { throw ValidationError("No templates were found in \(packageName)") } + + throw ValidationError("No templates were found that match the name \(template)") + } + let output = try await TemplatePluginRunner.run( - plugin: matchingPlugins[0], + plugin: commandPlugin, package: packageGraph.rootPackages.first!, packageGraph: packageGraph, arguments: ["--", "--experimental-dump-help"], @@ -289,18 +294,16 @@ extension SwiftPackageCommand { let products = rootManifest.products let targets = rootManifest.targets - for product in products { - for targetName in product.targets { - if let target = targets.first(where: { $0.name == template }) { - if let options = target.templateInitializationOptions { - if case .packageInit(let templateType, _, _) = options { - return try .init(from: templateType) - } + for _ in products { + if let target = targets.first(where: { template == nil || $0.name == template }) { + if let options = target.templateInitializationOptions { + if case .packageInit(let templateType, _, _) = options { + return try .init(from: templateType) } } } } - throw InternalError("Could not find template \(self.template)") + throw InternalError("Could not find \(self.template != nil ? "template \(self.template!)" : "any templates in the package")") } } } diff --git a/Sources/Commands/PackageCommands/PluginCommand.swift b/Sources/Commands/PackageCommands/PluginCommand.swift index d81bf6cea61..422fe3a64fc 100644 --- a/Sources/Commands/PackageCommands/PluginCommand.swift +++ b/Sources/Commands/PackageCommands/PluginCommand.swift @@ -411,14 +411,19 @@ struct PluginCommand: AsyncSwiftCommand { } } - static func findPlugins(matching verb: String, in graph: ModulesGraph, limitedTo packageIdentity: String?) -> [ResolvedModule] { + static func findPlugins(matching verb: String?, in graph: ModulesGraph, limitedTo packageIdentity: String?) -> [ResolvedModule] { // Find and return the command plugins that match the command. - Self.availableCommandPlugins(in: graph, limitedTo: packageIdentity).filter { + let plugins = Self.availableCommandPlugins(in: graph, limitedTo: packageIdentity).filter { let plugin = $0.underlying as! PluginModule // Filter out any non-command plugins and any whose verb is different. guard case .command(let intent, _) = plugin.capability else { return false } + + guard let verb else { return true } + return verb == intent.invocationVerb } + + return plugins } } diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 683a088cfec..45262798740 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -36,8 +36,6 @@ public final class InitTemplatePackage { /// The set of testing libraries supported by the generated package. public var supportedTestingLibraries: Set - /// The name of the template to use. - let templateName: String /// The file system abstraction to use for file operations. let fileSystem: FileSystem @@ -51,13 +49,12 @@ public final class InitTemplatePackage { var packageName: String /// The path to the template files. - var templatePath: Basics.AbsolutePath - /// The type of package to create (e.g., library, executable). + /// The type of package to create (e.g., library, executable). let packageType: InitPackage.PackageType - /// Options used to configure package initialization. + /// Options used to configure package initialization. public struct InitPackageOptions { /// The type of package to create. public var packageType: InitPackage.PackageType @@ -112,7 +109,6 @@ public final class InitTemplatePackage { /// - installedSwiftPMConfiguration: Configuration from the SwiftPM toolchain. public init( name: String, - templateName: String, initMode: MappablePackageDependency.Kind, templatePath: Basics.AbsolutePath, fileSystem: FileSystem, @@ -129,7 +125,6 @@ public final class InitTemplatePackage { self.destinationPath = destinationPath self.installedSwiftPMConfiguration = installedSwiftPMConfiguration self.fileSystem = fileSystem - self.templateName = templateName } /// Sets up the package manifest by creating the package structure and From 517215f02ae23c027e6643b8853f8dac2857f5d1 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Fri, 13 Jun 2025 14:09:19 -0400 Subject: [PATCH 055/225] On failure due to multiple available templates provide the list as part of the error message --- Sources/Commands/PackageCommands/Init.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index b83c3c0ea88..5b952c80b88 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -257,6 +257,16 @@ extension SwiftPackageCommand { throw ValidationError("No templates were found that match the name \(template)") } + guard matchingPlugins.count == 1 else { + let templateNames = matchingPlugins.compactMap { module in + let plugin = module.underlying as! PluginModule + guard case .command(let intent, _) = plugin.capability else { return Optional.none } + + return intent.invocationVerb + } + throw ValidationError("More than one template was found in the package. Please use `--template` to select from one of the available templates: \(templateNames.joined(separator: ", "))") + } + let output = try await TemplatePluginRunner.run( plugin: commandPlugin, package: packageGraph.rootPackages.first!, From 12b3110b8af5e69fae4acafda51feab0789e21fa Mon Sep 17 00:00:00 2001 From: John Bute Date: Mon, 16 Jun 2025 09:24:04 -0400 Subject: [PATCH 056/225] ergonomics, reducing repetition of template keyword --- Sources/Commands/PackageCommands/Init.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 5b952c80b88..fb8c33fc082 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -81,11 +81,11 @@ extension SwiftPackageCommand { var templateSource: InitTemplatePackage.TemplateSource? /// Path to a local template. - @Option(name: .customLong("template-path"), help: "Path to the local template.", completion: .directory) + @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) var templateDirectory: Basics.AbsolutePath? /// Git URL of the template. - @Option(name: .customLong("template-url"), help: "The git URL of the template.") + @Option(name: .customLong("url"), help: "The git URL of the template.") var templateURL: String? @Option(name: .customLong("package-id"), help: "The package identifier of the template") From ca8a4960b71bc3e5d184472771ccc16eec4e7283 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 17 Jun 2025 11:26:03 -0400 Subject: [PATCH 057/225] added documentation + predetermined args --- Sources/Commands/PackageCommands/Init.swift | 30 +++- .../PackageCommands/ShowTemplates.swift | 2 +- Sources/Workspace/InitTemplatePackage.swift | 160 +++++++++++++++++- 3 files changed, 180 insertions(+), 12 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index fb8c33fc082..9e8c7d524d5 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -70,7 +70,10 @@ extension SwiftPackageCommand { var createPackagePath = true /// Name of a template to use for package initialization. - @Option(name: .customLong("template"), help: "Name of a template to initialize the package, unspecified if the default template should be used.") + @Option( + name: .customLong("template"), + help: "Name of a template to initialize the package, unspecified if the default template should be used." + ) var template: String? /// Returns true if a template is specified. @@ -88,9 +91,18 @@ extension SwiftPackageCommand { @Option(name: .customLong("url"), help: "The git URL of the template.") var templateURL: String? + /// Package Registry ID of the template. @Option(name: .customLong("package-id"), help: "The package identifier of the template") var templatePackageID: String? + /// Predetermined arguments specified by the consumer. + @Option( + name: [.customLong("args")], + parsing: .unconditional, + help: "Predetermined arguments to pass to the template." + ) + var args: String? + // MARK: - Versioning Options for Remote Git Templates and Registry templates /// The exact version of the remote package to use. @@ -252,7 +264,8 @@ extension SwiftPackageCommand { let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: packageGraph, limitedTo: nil) guard let commandPlugin = matchingPlugins.first else { - guard let template = self.template else { throw ValidationError("No templates were found in \(packageName)") } + guard let template = self.template + else { throw ValidationError("No templates were found in \(packageName)") } throw ValidationError("No templates were found that match the name \(template)") } @@ -260,11 +273,13 @@ extension SwiftPackageCommand { guard matchingPlugins.count == 1 else { let templateNames = matchingPlugins.compactMap { module in let plugin = module.underlying as! PluginModule - guard case .command(let intent, _) = plugin.capability else { return Optional.none } + guard case .command(let intent, _) = plugin.capability else { return String?.none } return intent.invocationVerb } - throw ValidationError("More than one template was found in the package. Please use `--template` to select from one of the available templates: \(templateNames.joined(separator: ", "))") + throw ValidationError( + "More than one template was found in the package. Please use `--template` to select from one of the available templates: \(templateNames.joined(separator: ", "))" + ) } let output = try await TemplatePluginRunner.run( @@ -276,7 +291,8 @@ extension SwiftPackageCommand { ) let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let response = try initTemplatePackage.promptUser(tool: toolInfo) + let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: self.args) + do { let _ = try await TemplatePluginRunner.run( plugin: matchingPlugins[0], @@ -313,7 +329,9 @@ extension SwiftPackageCommand { } } } - throw InternalError("Could not find \(self.template != nil ? "template \(self.template!)" : "any templates in the package")") + throw InternalError( + "Could not find \(self.template != nil ? "template \(self.template!)" : "any templates in the package")" + ) } } } diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 9a25f254470..eabb637b28e 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -34,7 +34,7 @@ struct ShowTemplates: AsyncSwiftCommand { /// The Git URL of the template to list executables from. /// /// If not provided, the command uses the current working directory. - @Option(name: .customLong("template-url"), help: "The git URL of the template.") + @Option(name: .customLong("url"), help: "The git URL of the template.") var templateURL: String? @Option(name: .customLong("package-id"), help: "The package identifier of the template") diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 45262798740..4cabe6e52ea 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -201,14 +201,116 @@ public final class InitTemplatePackage { /// - Returns: An array of strings representing the command line arguments built from user input. /// - Throws: `TemplateError.noArguments` if the tool command has no arguments. - public func promptUser(tool: ToolInfoV0) throws -> [String] { - let arguments = try convertArguments(from: tool.command) + public func promptUser(tool: ToolInfoV0, arguments: String?) throws -> [String] { + let allArgs = try convertArguments(from: tool.command) - let responses = UserPrompter.prompt(for: arguments) + let providedResponses = try arguments.flatMap { + try self.parseAndMatchArguments($0, definedArgs: allArgs) + } ?? [] - let commandLine = self.buildCommandLine(from: responses) + let missingArgs = self.findMissingArguments(from: allArgs, excluding: providedResponses) - return commandLine + let promptedResponses = UserPrompter.prompt(for: missingArgs) + + return self.buildCommandLine(from: providedResponses + promptedResponses) + } + + /// Parses predetermined arguments and validates the arguments + /// + /// This method converts user's predetermined arguments into the ArgumentResponse struct + /// and validates the user's predetermined arguments against the template's available arguments. + /// + /// - Parameter input: The input arguments from the consumer. + /// - parameter definedArgs: the arguments defined by the template + /// - Returns: An array of responses to the tool's arguments + /// - Throws: Invalid values if the value is not within all the possible values allowed by the argument + /// - Throws: Throws an unexpected argument if the user specifies an argument that does not match any arguments + /// defined by the template. + private func parseAndMatchArguments( + _ input: String, + definedArgs: [ArgumentInfoV0] + ) throws -> [ArgumentResponse] { + let parsedTokens = self.parseArgs(input) + var responses: [ArgumentResponse] = [] + var providedMap: [String: [String]] = [:] + var index = 0 + + while index < parsedTokens.count { + let token = parsedTokens[index] + + if token.starts(with: "--") { + let name = String(token.dropFirst(2)) + guard let arg = definedArgs.first(where: { $0.valueName == name }) else { + throw TemplateError.invalidArgument(name: name) + } + + switch arg.kind { + case .flag: + providedMap[name] = ["true"] + case .option: + index += 1 + guard index < parsedTokens.count else { + throw TemplateError.missingValueForOption(name: name) + } + providedMap[name] = [parsedTokens[index]] + default: + throw TemplateError.unexpectedNamedArgument(name: name) + } + } else { + // Positional handling + providedMap["__positional", default: []].append(token) + } + + index += 1 + } + + for arg in definedArgs { + let name = arg.valueName ?? "__positional" + guard let values = providedMap[name] else { + continue + } + + if let allowed = arg.allValues { + let invalid = values.filter { !allowed.contains($0) } + if !invalid.isEmpty { + throw TemplateError.invalidValue( + argument: name, + invalidValues: invalid, + allowed: allowed + ) + } + } + + responses.append(ArgumentResponse(argument: arg, values: values)) + providedMap[name] = nil + } + + for unexpected in providedMap.keys { + throw TemplateError.unexpectedArgument(name: unexpected) + } + + return responses + } + + /// Determines the rest of the arguments that need a user's response + /// + /// This method determines the rest of the responses needed from the user to complete the generation of a template + /// + /// + /// - Parameter all: All the arguments from the template. + /// - parameter excluding: The arguments that do not need prompting + /// - Returns: An array of arguments that need to be prompted for user response + + private func findMissingArguments( + from all: [ArgumentInfoV0], + excluding responses: [ArgumentResponse] + ) -> [ArgumentInfoV0] { + let seen = Set(responses.map { $0.argument.valueName ?? "__positional" }) + + return all.filter { arg in + let name = arg.valueName ?? "__positional" + return !seen.contains(name) + } } /// Converts the command information into an array of argument metadata. @@ -346,6 +448,38 @@ public final class InitTemplatePackage { } } } + + private func parseArgs(_ input: String) -> [String] { + var result: [String] = [] + + var current = "" + var inQuotes = false + var escapeNext = false + + for char in input { + if escapeNext { + current.append(char) + escapeNext = false + } else if char == "\\" { + escapeNext = true + } else if char == "\"" { + inQuotes.toggle() + } else if char == " " && !inQuotes { + if !current.isEmpty { + result.append(current) + current = "" + } + } else { + current.append(char) + } + } + + if !current.isEmpty { + result.append(current) + } + + return result + } } /// An error enum representing various template-related errors. @@ -357,7 +491,13 @@ private enum TemplateError: Swift.Error { case manifestAlreadyExists /// The template has no arguments to prompt for. + case noArguments + case invalidArgument(name: String) + case unexpectedArgument(name: String) + case unexpectedNamedArgument(name: String) + case missingValueForOption(name: String) + case invalidValue(argument: String, invalidValues: [String], allowed: [String]) } extension TemplateError: CustomStringConvertible { @@ -370,6 +510,16 @@ extension TemplateError: CustomStringConvertible { "Path does not exist, or is invalid." case .noArguments: "Template has no arguments" + case .invalidArgument(name: let name): + "Invalid argument name: \(name)" + case .unexpectedArgument(name: let name): + "Unexpected argument: \(name)" + case .unexpectedNamedArgument(name: let name): + "Unexpected named argument: \(name)" + case .missingValueForOption(name: let name): + "Missing value for option: \(name)" + case .invalidValue(argument: let argument, invalidValues: let invalidValues, allowed: let allowed): + "Invalid value \(argument). Valid values are: \(allowed.joined(separator: ", ")). \(invalidValues.isEmpty ? "" : "Also, \(invalidValues.joined(separator: ", ")) are not valid.")" } } } From df8286b6f65d4471df521ab5b18cf1c0b7ba7e3b Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 17 Jun 2025 15:00:04 -0400 Subject: [PATCH 058/225] changed args option to instead take any arguments after as predetermined args for templates --- Sources/Commands/PackageCommands/Init.swift | 17 ++++--- Sources/Workspace/InitTemplatePackage.swift | 49 +++------------------ 2 files changed, 15 insertions(+), 51 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 9e8c7d524d5..59f2990e0d3 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -95,14 +95,6 @@ extension SwiftPackageCommand { @Option(name: .customLong("package-id"), help: "The package identifier of the template") var templatePackageID: String? - /// Predetermined arguments specified by the consumer. - @Option( - name: [.customLong("args")], - parsing: .unconditional, - help: "Predetermined arguments to pass to the template." - ) - var args: String? - // MARK: - Versioning Options for Remote Git Templates and Registry templates /// The exact version of the remote package to use. @@ -129,6 +121,13 @@ extension SwiftPackageCommand { @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? + /// Predetermined arguments specified by the consumer. + @Argument( + parsing: .captureForPassthrough, + help: "Predetermined arguments to pass to the template." + ) + var args: [String] = [] + func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") @@ -291,7 +290,7 @@ extension SwiftPackageCommand { ) let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: self.args) + let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: args) do { let _ = try await TemplatePluginRunner.run( diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 4cabe6e52ea..ca3513e60b5 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -201,12 +201,10 @@ public final class InitTemplatePackage { /// - Returns: An array of strings representing the command line arguments built from user input. /// - Throws: `TemplateError.noArguments` if the tool command has no arguments. - public func promptUser(tool: ToolInfoV0, arguments: String?) throws -> [String] { + public func promptUser(tool: ToolInfoV0, arguments: [String]) throws -> [String] { let allArgs = try convertArguments(from: tool.command) - let providedResponses = try arguments.flatMap { - try self.parseAndMatchArguments($0, definedArgs: allArgs) - } ?? [] + let providedResponses = try self.parseAndMatchArguments(arguments, definedArgs: allArgs) let missingArgs = self.findMissingArguments(from: allArgs, excluding: providedResponses) @@ -227,16 +225,15 @@ public final class InitTemplatePackage { /// - Throws: Throws an unexpected argument if the user specifies an argument that does not match any arguments /// defined by the template. private func parseAndMatchArguments( - _ input: String, + _ input: [String], definedArgs: [ArgumentInfoV0] ) throws -> [ArgumentResponse] { - let parsedTokens = self.parseArgs(input) var responses: [ArgumentResponse] = [] var providedMap: [String: [String]] = [:] var index = 0 - while index < parsedTokens.count { - let token = parsedTokens[index] + while index < input.count { + let token = input[index] if token.starts(with: "--") { let name = String(token.dropFirst(2)) @@ -249,10 +246,10 @@ public final class InitTemplatePackage { providedMap[name] = ["true"] case .option: index += 1 - guard index < parsedTokens.count else { + guard index < input.count else { throw TemplateError.missingValueForOption(name: name) } - providedMap[name] = [parsedTokens[index]] + providedMap[name] = [input[index]] default: throw TemplateError.unexpectedNamedArgument(name: name) } @@ -448,38 +445,6 @@ public final class InitTemplatePackage { } } } - - private func parseArgs(_ input: String) -> [String] { - var result: [String] = [] - - var current = "" - var inQuotes = false - var escapeNext = false - - for char in input { - if escapeNext { - current.append(char) - escapeNext = false - } else if char == "\\" { - escapeNext = true - } else if char == "\"" { - inQuotes.toggle() - } else if char == " " && !inQuotes { - if !current.isEmpty { - result.append(current) - current = "" - } - } else { - current.append(char) - } - } - - if !current.isEmpty { - result.append(current) - } - - return result - } } /// An error enum representing various template-related errors. From 2fcb829aecfd25b7fbb8c60b0ae82c1ce16601d8 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 17 Jun 2025 15:09:11 -0400 Subject: [PATCH 059/225] inferring source of template --- Sources/Commands/PackageCommands/Init.swift | 17 ++++++++++++----- Sources/Workspace/InitTemplatePackage.swift | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 59f2990e0d3..8d41a2e1104 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -77,11 +77,20 @@ extension SwiftPackageCommand { var template: String? /// Returns true if a template is specified. - var useTemplates: Bool { self.templateSource != nil } + var useTemplates: Bool { self.templateURL != nil || self.templatePackageID != nil || self.templateDirectory != nil } /// The type of template to use: `registry`, `git`, or `local`. - @Option(name: .customLong("template-type"), help: "Template type: registry, git, local.") - var templateSource: InitTemplatePackage.TemplateSource? + var templateSource: InitTemplatePackage.TemplateSource? { + if templateDirectory != nil { + .local + } else if templateURL != nil { + .git + } else if templatePackageID != nil { + .registry + } else { + nil + } + } /// Path to a local template. @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) @@ -355,5 +364,3 @@ extension InitPackage.PackageType: ExpressibleByArgument { } } } - -extension InitTemplatePackage.TemplateSource: ExpressibleByArgument {} diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index ca3513e60b5..a44e0d68d61 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -85,7 +85,7 @@ public final class InitTemplatePackage { } /// The type of template source. - public enum TemplateSource: String, CustomStringConvertible { + public enum TemplateSource: String, CustomStringConvertible, Decodable { case local case git case registry From bdb65e43a2f4b0c1fa38632733dd30c464dda0e4 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 18 Jun 2025 14:54:24 -0400 Subject: [PATCH 060/225] fixing error regarding passing args to template --- Sources/Commands/PackageCommands/Init.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 8d41a2e1104..3bafed4dd22 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -132,7 +132,6 @@ extension SwiftPackageCommand { /// Predetermined arguments specified by the consumer. @Argument( - parsing: .captureForPassthrough, help: "Predetermined arguments to pass to the template." ) var args: [String] = [] From 6af858afe421b754b051a8e7477c8211dc5077bc Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 18 Jun 2025 16:01:54 -0400 Subject: [PATCH 061/225] args checking + file existing checks if local template option picked --- Sources/Commands/PackageCommands/Init.swift | 47 +++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 3bafed4dd22..fa02b5da05a 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -141,6 +141,10 @@ extension SwiftPackageCommand { throw InternalError("Could not find the current working directory") } + if case let .failure(errors) = validateArgs(swiftCommandState: swiftCommandState) { + throw ValidationError(errors.joined(separator: "\n")) + } + let packageName = self.packageName ?? cwd.basename if self.useTemplates { @@ -213,6 +217,10 @@ extension SwiftPackageCommand { swiftCommandState: swiftCommandState ).resolve() + if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { + throw ValidationError("The specified template path does not exist: \(dir.pathString)") + } + let templateInitType = try await swiftCommandState .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in try await self.checkConditions(swiftCommandState) @@ -363,3 +371,42 @@ extension InitPackage.PackageType: ExpressibleByArgument { } } } + +extension SwiftPackageCommand.Init { + enum ValidationResult { + case success + case failure([String]) + } + + func validateArgs(swiftCommandState: SwiftCommandState) -> ValidationResult { + var errors: [String] = [] + + // 1. Validate consistency of template-related arguments + let isUsingTemplate = self.useTemplates + + if isUsingTemplate { + + let templateSources: [Any?] = [templateDirectory, templateURL, templatePackageID] + let nonNilCount = templateSources.compactMap { $0 }.count + + if nonNilCount > 1{ + errors.append("Only one of --path, --url, or --package-id may be specified.") + } + + if (self.exact != nil || self.from != nil || self.upToNextMinorFrom != nil || self.branch != nil || self.revision != nil || self.to != nil) && self.templateSource == .local { + errors.append("Cannot specify a version requirement alongside a local template") + } + + } else { + // 2. In non-template mode, template-related flags should not be used + if template != nil { + errors.append("The --template option can only be used with a specified template source (--path, --url, or --package-id).") + } + + if !args.isEmpty { + errors.append("Template arguments are only supported when initializing from a template.") + } + } + return errors.isEmpty ? .success : .failure(errors) + } +} From 21fb739f1672ed494338c3c8da339f1da382d78e Mon Sep 17 00:00:00 2001 From: John Bute Date: Fri, 20 Jun 2025 12:18:06 -0400 Subject: [PATCH 062/225] made description, permissions, and initial type top level --- Sources/Commands/PackageCommands/Init.swift | 2 +- Sources/PackageDescription/Target.swift | 114 ++++++++++---------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index fa02b5da05a..21832d3f7f2 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -336,7 +336,7 @@ extension SwiftPackageCommand { let targets = rootManifest.targets for _ in products { - if let target = targets.first(where: { template == nil || $0.name == template }) { + if let target: TargetDescription = targets.first(where: { template == nil || $0.name == template }) { if let options = target.templateInitializationOptions { if case .packageInit(let templateType, _, _) = options { return try .init(from: templateType) diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index cf7cfd8e853..4ff724df1ad 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -1273,7 +1273,7 @@ public final class Target { public extension [Target] { @available(_PackageDescription, introduced: 999.0.0) - public static func template( + static func template( name: String, dependencies: [Target.Dependency] = [], path: String? = nil, @@ -1287,74 +1287,74 @@ public extension [Target] { swiftSettings: [SwiftSetting]? = nil, linkerSettings: [LinkerSetting]? = nil, plugins: [Target.PluginUsage]? = nil, - templateInitializationOptions: Target.TemplateInitializationOptions, + initialType: Target.TemplateType, + templatePermissions: [TemplatePermissions]? = nil, + description: String ) -> [Target] { let templatePluginName = "\(name)Plugin" let templateExecutableName = "\(name)" - let (verb, description): (String, String) - switch templateInitializationOptions { - case .packageInit(_, _, let desc): - verb = templateExecutableName - description = desc - } let permissions: [PluginPermission] = { - switch templateInitializationOptions { - case .packageInit(_, let templatePermissions, _): - return templatePermissions?.compactMap { permission in - switch permission { - case .allowNetworkConnections(let scope, let reason): - // Map from TemplateNetworkPermissionScope to PluginNetworkPermissionScope - let pluginScope: PluginNetworkPermissionScope - switch scope { - case .none: - pluginScope = .none - case .local(let ports): - pluginScope = .local(ports: ports) - case .all(let ports): - pluginScope = .all(ports: ports) - case .docker: - pluginScope = .docker - case .unixDomainSocket: - pluginScope = .unixDomainSocket - } - return .allowNetworkConnections(scope: pluginScope, reason: reason) + return templatePermissions?.compactMap { permission in + switch permission { + case .allowNetworkConnections(let scope, let reason): + // Map from TemplateNetworkPermissionScope to PluginNetworkPermissionScope + let pluginScope: PluginNetworkPermissionScope + switch scope { + case .none: + pluginScope = .none + case .local(let ports): + pluginScope = .local(ports: ports) + case .all(let ports): + pluginScope = .all(ports: ports) + case .docker: + pluginScope = .docker + case .unixDomainSocket: + pluginScope = .unixDomainSocket } - } ?? [] - } + return .allowNetworkConnections(scope: pluginScope, reason: reason) + } + } ?? [] }() - let templateTarget = Target( - name: templateExecutableName, - dependencies: dependencies, - path: path, - exclude: exclude, - sources: sources, - resources: resources, - publicHeadersPath: publicHeadersPath, - type: .executable, - packageAccess: packageAccess, - cSettings: cSettings, - cxxSettings: cxxSettings, - swiftSettings: swiftSettings, - linkerSettings: linkerSettings, - plugins: plugins, - templateInitializationOptions: templateInitializationOptions - ) - // Plugin target that depends on the template - let pluginTarget = Target.plugin( - name: templatePluginName, - capability: .command( - intent: .custom(verb: verb, description: description), - permissions: permissions - ), - dependencies: [Target.Dependency.target(name: templateExecutableName, condition: nil)] - ) + let templateInitializationOptions = Target.TemplateInitializationOptions.packageInit( + templateType: initialType, + templatePermissions: templatePermissions, + description: description + ) + + let templateTarget = Target( + name: templateExecutableName, + dependencies: dependencies, + path: path, + exclude: exclude, + sources: sources, + resources: resources, + publicHeadersPath: publicHeadersPath, + type: .executable, + packageAccess: packageAccess, + cSettings: cSettings, + cxxSettings: cxxSettings, + swiftSettings: swiftSettings, + linkerSettings: linkerSettings, + plugins: plugins, + templateInitializationOptions: templateInitializationOptions + ) + + // Plugin target that depends on the template + let pluginTarget = Target.plugin( + name: templatePluginName, + capability: .command( + intent: .custom(verb: templateExecutableName, description: description), + permissions: permissions + ), + dependencies: [Target.Dependency.target(name: templateExecutableName, condition: nil)] + ) - return [templateTarget, pluginTarget] + return [templateTarget, pluginTarget] } } From 643ac8a80255c2c4ad607df3b5d756cc7366326e Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 24 Jun 2025 11:08:08 -0400 Subject: [PATCH 063/225] made scaffolding top level command --- Package.swift | 6 + Sources/Commands/PackageCommands/Init.swift | 370 ++---------------- Sources/Commands/SwiftScaffold.swift | 313 +++++++++++++++ .../TestingLibrarySupport.swift | 53 --- Sources/swift-scaffold/CMakeLists.txt | 18 + Sources/swift-scaffold/Entrypoint.swift | 20 + 6 files changed, 382 insertions(+), 398 deletions(-) create mode 100644 Sources/Commands/SwiftScaffold.swift delete mode 100644 Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift create mode 100644 Sources/swift-scaffold/CMakeLists.txt create mode 100644 Sources/swift-scaffold/Entrypoint.swift diff --git a/Package.swift b/Package.swift index ca107d6428b..cf44baeba3b 100644 --- a/Package.swift +++ b/Package.swift @@ -718,6 +718,12 @@ let package = Package( dependencies: ["Commands"], exclude: ["CMakeLists.txt"] ), + .executableTarget( + /** Scaffolds a package */ + name: "swift-scaffold", + dependencies: ["Commands"], + exclude: ["CMakeLists.txt"] + ), .executableTarget( /** Interacts with package collections */ name: "swift-package-collection", diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 21832d3f7f2..d93600002b1 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -17,154 +17,61 @@ import Basics import CoreCommands import PackageModel -import SPMBuildCore -import TSCUtility import Workspace - -import Foundation -import PackageGraph -import SourceControl import SPMBuildCore -import TSCBasic -import XCBuildSupport - -import ArgumentParserToolInfo extension SwiftPackageCommand { - struct Init: AsyncSwiftCommand { + struct Init: SwiftCommand { public static let configuration = CommandConfiguration( - abstract: "Initialize a new package.", - ) + abstract: "Initialize a new package.") @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions - @OptionGroup(visibility: .hidden) - var buildOptions: BuildCommandOptions - @Option( name: .customLong("type"), help: ArgumentHelp("Package type:", discussion: """ - library - A package with a library. - executable - A package with an executable. - tool - A package with an executable that uses - Swift Argument Parser. Use this template if you - plan to have a rich set of command-line arguments. - build-tool-plugin - A package that vends a build tool plugin. - command-plugin - A package that vends a command plugin. - macro - A package that vends a macro. - empty - An empty package with a Package.swift manifest. - """) - ) + library - A package with a library. + executable - A package with an executable. + tool - A package with an executable that uses + Swift Argument Parser. Use this template if you + plan to have a rich set of command-line arguments. + build-tool-plugin - A package that vends a build tool plugin. + command-plugin - A package that vends a command plugin. + macro - A package that vends a macro. + empty - An empty package with a Package.swift manifest. + """)) var initMode: InitPackage.PackageType = .library /// Which testing libraries to use (and any related options.) @OptionGroup() var testLibraryOptions: TestLibraryOptions - /// A custom name for the package. Defaults to the current directory name. @Option(name: .customLong("name"), help: "Provide custom package name.") var packageName: String? // This command should support creating the supplied --package-path if it isn't created. var createPackagePath = true - /// Name of a template to use for package initialization. - @Option( - name: .customLong("template"), - help: "Name of a template to initialize the package, unspecified if the default template should be used." - ) - var template: String? - - /// Returns true if a template is specified. - var useTemplates: Bool { self.templateURL != nil || self.templatePackageID != nil || self.templateDirectory != nil } - - /// The type of template to use: `registry`, `git`, or `local`. - var templateSource: InitTemplatePackage.TemplateSource? { - if templateDirectory != nil { - .local - } else if templateURL != nil { - .git - } else if templatePackageID != nil { - .registry - } else { - nil - } - } - - /// Path to a local template. - @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) - var templateDirectory: Basics.AbsolutePath? - - /// Git URL of the template. - @Option(name: .customLong("url"), help: "The git URL of the template.") - var templateURL: String? - - /// Package Registry ID of the template. - @Option(name: .customLong("package-id"), help: "The package identifier of the template") - var templatePackageID: String? - - // MARK: - Versioning Options for Remote Git Templates and Registry templates - - /// The exact version of the remote package to use. - @Option(help: "The exact package version to depend on.") - var exact: Version? - - /// Specific revision to use (for Git templates). - @Option(help: "The specific package revision to depend on.") - var revision: String? - - /// Branch name to use (for Git templates). - @Option(help: "The branch of the package to depend on.") - var branch: String? - - /// Version to depend on, up to the next major version. - @Option(help: "The package version to depend on (up to the next major version).") - var from: Version? - - /// Version to depend on, up to the next minor version. - @Option(help: "The package version to depend on (up to the next minor version).") - var upToNextMinorFrom: Version? - - /// Upper bound on the version range (exclusive). - @Option(help: "Specify upper bound on the package version range (exclusive).") - var to: Version? - - /// Predetermined arguments specified by the consumer. - @Argument( - help: "Predetermined arguments to pass to the template." - ) - var args: [String] = [] - - func run(_ swiftCommandState: SwiftCommandState) async throws { + func run(_ swiftCommandState: SwiftCommandState) throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") } - if case let .failure(errors) = validateArgs(swiftCommandState: swiftCommandState) { - throw ValidationError(errors.joined(separator: "\n")) - } - let packageName = self.packageName ?? cwd.basename - if self.useTemplates { - try await self.runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) - } else { - try self.runPackageInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) + // Testing is on by default, with XCTest only enabled explicitly. + // For macros this is reversed, since we don't support testing + // macros with Swift Testing yet. + var supportedTestingLibraries = Set() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.swiftTesting) } - } - - /// Runs the standard package initialization (non-template). - private func runPackageInit( - swiftCommandState: SwiftCommandState, - packageName: String, - cwd: Basics.AbsolutePath - ) throws { - let supportedTestingLibraries = computeSupportedTestingLibraries( - for: testLibraryOptions, - initMode: initMode, - swiftCommandState: swiftCommandState - ) let initPackage = try InitPackage( name: packageName, @@ -174,239 +81,12 @@ extension SwiftPackageCommand { installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, fileSystem: swiftCommandState.fileSystem ) - initPackage.progressReporter = { message in print(message) } - try initPackage.writePackageStructure() } - - /// Runs the package initialization using an author-defined template. - private func runTemplateInit( - swiftCommandState: SwiftCommandState, - packageName: String, - cwd: Basics.AbsolutePath - ) async throws { - guard let source = templateSource else { - throw ValidationError("No template source specified.") - } - - let requirementResolver = DependencyRequirementResolver( - exact: exact, - revision: revision, - branch: branch, - from: from, - upToNextMinorFrom: upToNextMinorFrom, - to: to - ) - - let registryRequirement: PackageDependency.Registry.Requirement? = - try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement - - let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = - try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement - - let resolvedTemplatePath = try await TemplatePathResolver( - source: templateSource, - templateDirectory: templateDirectory, - templateURL: templateURL, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - packageIdentity: templatePackageID, - swiftCommandState: swiftCommandState - ).resolve() - - if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { - throw ValidationError("The specified template path does not exist: \(dir.pathString)") - } - - let templateInitType = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in - try await self.checkConditions(swiftCommandState) - } - - // Clean up downloaded package after execution. - defer { - if templateSource == .git { - try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) - } else if templateSource == .registry { - let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL - try? FileManager.default.removeItem(at: parentDirectoryURL) - } - } - - let supportedTemplateTestingLibraries = computeSupportedTestingLibraries( - for: testLibraryOptions, - initMode: templateInitType, - swiftCommandState: swiftCommandState - ) - - let builder = DefaultPackageDependencyBuilder( - templateSource: source, - packageName: packageName, - templateURL: self.templateURL, - templatePackageID: self.templatePackageID - ) - - let dependencyKind = try builder.makePackageDependency( - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - resolvedTemplatePath: resolvedTemplatePath - ) - - let initTemplatePackage = try InitTemplatePackage( - name: packageName, - initMode: dependencyKind, - templatePath: resolvedTemplatePath, - fileSystem: swiftCommandState.fileSystem, - packageType: templateInitType, - supportedTestingLibraries: supportedTemplateTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration - ) - - try initTemplatePackage.setupTemplateManifest() - - try await TemplateBuildSupport.build( - swiftCommandState: swiftCommandState, - buildOptions: self.buildOptions, - globalOptions: self.globalOptions, - cwd: cwd - ) - - let packageGraph = try await swiftCommandState.loadPackageGraph() - let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: packageGraph, limitedTo: nil) - - guard let commandPlugin = matchingPlugins.first else { - guard let template = self.template - else { throw ValidationError("No templates were found in \(packageName)") } - - throw ValidationError("No templates were found that match the name \(template)") - } - - guard matchingPlugins.count == 1 else { - let templateNames = matchingPlugins.compactMap { module in - let plugin = module.underlying as! PluginModule - guard case .command(let intent, _) = plugin.capability else { return String?.none } - - return intent.invocationVerb - } - throw ValidationError( - "More than one template was found in the package. Please use `--template` to select from one of the available templates: \(templateNames.joined(separator: ", "))" - ) - } - - let output = try await TemplatePluginRunner.run( - plugin: commandPlugin, - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: ["--", "--experimental-dump-help"], - swiftCommandState: swiftCommandState - ) - - let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: args) - - do { - let _ = try await TemplatePluginRunner.run( - plugin: matchingPlugins[0], - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: response, - swiftCommandState: swiftCommandState - ) - } - } - - /// Validates the loaded manifest to determine package type. - private func checkConditions(_ swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType { - let workspace = try swiftCommandState.getActiveWorkspace() - let root = try swiftCommandState.getWorkspaceRoot() - - let rootManifests = try await workspace.loadRootManifests( - packages: root.packages, - observabilityScope: swiftCommandState.observabilityScope - ) - guard let rootManifest = rootManifests.values.first else { - throw InternalError("invalid manifests at \(root.packages)") - } - - let products = rootManifest.products - let targets = rootManifest.targets - - for _ in products { - if let target: TargetDescription = targets.first(where: { template == nil || $0.name == template }) { - if let options = target.templateInitializationOptions { - if case .packageInit(let templateType, _, _) = options { - return try .init(from: templateType) - } - } - } - } - throw InternalError( - "Could not find \(self.template != nil ? "template \(self.template!)" : "any templates in the package")" - ) - } - } -} - -extension InitPackage.PackageType: ExpressibleByArgument { - init(from templateType: TargetDescription.TemplateType) throws { - switch templateType { - case .executable: - self = .executable - case .library: - self = .library - case .tool: - self = .tool - case .macro: - self = .macro - case .buildToolPlugin: - self = .buildToolPlugin - case .commandPlugin: - self = .commandPlugin - case .empty: - self = .empty - } } } -extension SwiftPackageCommand.Init { - enum ValidationResult { - case success - case failure([String]) - } - - func validateArgs(swiftCommandState: SwiftCommandState) -> ValidationResult { - var errors: [String] = [] - - // 1. Validate consistency of template-related arguments - let isUsingTemplate = self.useTemplates - - if isUsingTemplate { - - let templateSources: [Any?] = [templateDirectory, templateURL, templatePackageID] - let nonNilCount = templateSources.compactMap { $0 }.count - - if nonNilCount > 1{ - errors.append("Only one of --path, --url, or --package-id may be specified.") - } - - if (self.exact != nil || self.from != nil || self.upToNextMinorFrom != nil || self.branch != nil || self.revision != nil || self.to != nil) && self.templateSource == .local { - errors.append("Cannot specify a version requirement alongside a local template") - } - - } else { - // 2. In non-template mode, template-related flags should not be used - if template != nil { - errors.append("The --template option can only be used with a specified template source (--path, --url, or --package-id).") - } - - if !args.isEmpty { - errors.append("Template arguments are only supported when initializing from a template.") - } - } - return errors.isEmpty ? .success : .failure(errors) - } -} +extension InitPackage.PackageType: ExpressibleByArgument {} diff --git a/Sources/Commands/SwiftScaffold.swift b/Sources/Commands/SwiftScaffold.swift new file mode 100644 index 00000000000..9e449f481b8 --- /dev/null +++ b/Sources/Commands/SwiftScaffold.swift @@ -0,0 +1,313 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +@_spi(SwiftPMInternal) +import CoreCommands + +import PackageModel +import SPMBuildCore +import TSCUtility +import Workspace + +import Foundation +import PackageGraph +import SourceControl +import SPMBuildCore +import TSCBasic +import XCBuildSupport + +import ArgumentParserToolInfo + +public struct SwiftScaffoldCommand: AsyncSwiftCommand { + public static var configuration = CommandConfiguration( + commandName: "scaffold", + _superCommandName: "swift", + abstract: "Scaffold packages from templates.", + discussion: "SEE ALSO: swift run, swift package, swift test", + version: SwiftVersion.current.completeDisplayString, + helpNames: [.short, .long, .customLong("help", withSingleDash: true)]) + + + @OptionGroup(visibility: .hidden) + public var globalOptions: GlobalOptions + + @OptionGroup(visibility: .hidden) + var buildOptions: BuildCommandOptions + + @Option(name: .customLong("name"), help: "Provide custom package name.") + var packageName: String? + + /// Name of a template to use for package initialization. + @Option( + name: .customLong("template"), + help: "Name of a template to initialize the package, unspecified if the default template should be used." + ) + var template: String? + + /// The type of template to use: `registry`, `git`, or `local`. + var templateSource: InitTemplatePackage.TemplateSource? { + if templateDirectory != nil { + .local + } else if templateURL != nil { + .git + } else if templatePackageID != nil { + .registry + } else { + nil + } + } + + /// Path to a local template. + @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) + var templateDirectory: Basics.AbsolutePath? + + /// Git URL of the template. + @Option(name: .customLong("url"), help: "The git URL of the template.") + var templateURL: String? + + /// Package Registry ID of the template. + @Option(name: .customLong("package-id"), help: "The package identifier of the template") + var templatePackageID: String? + + // MARK: - Versioning Options for Remote Git Templates and Registry templates + + /// The exact version of the remote package to use. + @Option(help: "The exact package version to depend on.") + var exact: Version? + + /// Specific revision to use (for Git templates). + @Option(help: "The specific package revision to depend on.") + var revision: String? + + /// Branch name to use (for Git templates). + @Option(help: "The branch of the package to depend on.") + var branch: String? + + /// Version to depend on, up to the next major version. + @Option(help: "The package version to depend on (up to the next major version).") + var from: Version? + + /// Version to depend on, up to the next minor version. + @Option(help: "The package version to depend on (up to the next minor version).") + var upToNextMinorFrom: Version? + + /// Upper bound on the version range (exclusive). + @Option(help: "Specify upper bound on the package version range (exclusive).") + var to: Version? + + /// Predetermined arguments specified by the consumer. + @Argument( + help: "Predetermined arguments to pass to the template." + ) + var args: [String] = [] + + public func run(_ swiftCommandState: SwiftCommandState) async throws { + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") + } + + let packageName = self.packageName ?? cwd.basename + + try await self.runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) + + } + + /// Runs the package initialization using an author-defined template. + private func runTemplateInit( + swiftCommandState: SwiftCommandState, + packageName: String, + cwd: Basics.AbsolutePath + ) async throws { + guard let source = templateSource else { + throw ValidationError("No template source specified.") + } + + let requirementResolver = DependencyRequirementResolver( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ) + + let registryRequirement: PackageDependency.Registry.Requirement? = + try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement + + let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = + try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + + let resolvedTemplatePath = try await TemplatePathResolver( + source: templateSource, + templateDirectory: templateDirectory, + templateURL: templateURL, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() + + if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { + throw ValidationError("The specified template path does not exist: \(dir.pathString)") + } + + let templateInitType = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in + try await self.checkConditions(swiftCommandState) + } + + // Clean up downloaded package after execution. + defer { + if templateSource == .git { + try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) + } else if templateSource == .registry { + let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL + try? FileManager.default.removeItem(at: parentDirectoryURL) + } + } + + let supportedTemplateTestingLibraries: Set = .init() + + let builder = DefaultPackageDependencyBuilder( + templateSource: source, + packageName: packageName, + templateURL: self.templateURL, + templatePackageID: self.templatePackageID + ) + + let dependencyKind = try builder.makePackageDependency( + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ) + + let initTemplatePackage = try InitTemplatePackage( + name: packageName, + initMode: dependencyKind, + templatePath: resolvedTemplatePath, + fileSystem: swiftCommandState.fileSystem, + packageType: templateInitType, + supportedTestingLibraries: supportedTemplateTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration + ) + + try initTemplatePackage.setupTemplateManifest() + + try await TemplateBuildSupport.build( + swiftCommandState: swiftCommandState, + buildOptions: self.buildOptions, + globalOptions: self.globalOptions, + cwd: cwd + ) + + let packageGraph = try await swiftCommandState.loadPackageGraph() + let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: packageGraph, limitedTo: nil) + + guard let commandPlugin = matchingPlugins.first else { + guard let template = self.template + else { throw ValidationError("No templates were found in \(packageName)") } + + throw ValidationError("No templates were found that match the name \(template)") + } + + guard matchingPlugins.count == 1 else { + let templateNames = matchingPlugins.compactMap { module in + let plugin = module.underlying as! PluginModule + guard case .command(let intent, _) = plugin.capability else { return String?.none } + + return intent.invocationVerb + } + throw ValidationError( + "More than one template was found in the package. Please use `--template` to select from one of the available templates: \(templateNames.joined(separator: ", "))" + ) + } + + let output = try await TemplatePluginRunner.run( + plugin: commandPlugin, + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: ["--", "--experimental-dump-help"], + swiftCommandState: swiftCommandState + ) + + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: args) + + do { + let _ = try await TemplatePluginRunner.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: response, + swiftCommandState: swiftCommandState + ) + } + } + + /// Validates the loaded manifest to determine package type. + private func checkConditions(_ swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType { + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() + + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope + ) + guard let rootManifest = rootManifests.values.first else { + throw InternalError("invalid manifests at \(root.packages)") + } + + let products = rootManifest.products + let targets = rootManifest.targets + + for _ in products { + if let target: TargetDescription = targets.first(where: { template == nil || $0.name == template }) { + if let options = target.templateInitializationOptions { + if case .packageInit(let templateType, _, _) = options { + return try .init(from: templateType) + } + } + } + } + throw InternalError( + "Could not find \(self.template != nil ? "template \(self.template!)" : "any templates in the package")" + ) + } + public init() {} + + } + + +extension InitPackage.PackageType { + init(from templateType: TargetDescription.TemplateType) throws { + switch templateType { + case .executable: + self = .executable + case .library: + self = .library + case .tool: + self = .tool + case .macro: + self = .macro + case .buildToolPlugin: + self = .buildToolPlugin + case .commandPlugin: + self = .commandPlugin + case .empty: + self = .empty + } + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift b/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift deleted file mode 100644 index c802e4ac71b..00000000000 --- a/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift +++ /dev/null @@ -1,53 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 -@_spi(SwiftPMInternal) -import CoreCommands -import Workspace - -/// Computes the set of supported testing libraries to be included in a package template -/// based on the user's specified testing options, the type of package being initialized, -/// and the Swift command state. -/// -/// This function takes into account whether the testing libraries were explicitly requested -/// (via command-line flags or configuration) or implicitly enabled based on package type. -/// -/// - Parameters: -/// - testLibraryOptions: The testing library preferences specified by the user. -/// - initMode: The type of package being initialized (e.g., executable, library, macro). -/// - swiftCommandState: The command state which includes environment and context information. -/// -/// - Returns: A set of `TestingLibrary` values that should be included in the generated template. -func computeSupportedTestingLibraries( - for testLibraryOptions: TestLibraryOptions, - initMode: InitPackage.PackageType, - swiftCommandState: SwiftCommandState -) -> Set { - var supportedTemplateTestingLibraries: Set = .init() - - // XCTest is enabled either explicitly, or implicitly for macro packages. - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) - { - supportedTemplateTestingLibraries.insert(.xctest) - } - - // Swift Testing is enabled either explicitly, or implicitly for non-macro packages. - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) - { - supportedTemplateTestingLibraries.insert(.swiftTesting) - } - - return supportedTemplateTestingLibraries -} diff --git a/Sources/swift-scaffold/CMakeLists.txt b/Sources/swift-scaffold/CMakeLists.txt new file mode 100644 index 00000000000..94f49824fbd --- /dev/null +++ b/Sources/swift-scaffold/CMakeLists.txt @@ -0,0 +1,18 @@ +# This source file is part of the Swift open source project +# +# Copyright (c) 2014 - 2019 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 Swift project authors + +add_executable(swift-scaffold + Entrypoint.swift) +target_link_libraries(swift-run PRIVATE + Commands) + +target_compile_options(swift-scaffold PRIVATE + -parse-as-library) + +install(TARGETS swift-scaffold + RUNTIME DESTINATION bin) diff --git a/Sources/swift-scaffold/Entrypoint.swift b/Sources/swift-scaffold/Entrypoint.swift new file mode 100644 index 00000000000..0f79e310eab --- /dev/null +++ b/Sources/swift-scaffold/Entrypoint.swift @@ -0,0 +1,20 @@ +//===----------------------------------------------------------------------===// +// +// 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 Commands + +@main +struct Entrypoint { + static func main() async { + await SwiftScaffoldCommand.main() + } +} From afa6404be59e65c9bd5adb5de028ccc1d252a557 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 24 Jun 2025 11:27:36 -0400 Subject: [PATCH 064/225] added ability to view description of templates to show-templates --- .../PackageCommands/ShowTemplates.swift | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index eabb637b28e..782da198123 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -151,6 +151,7 @@ struct ShowTemplates: AsyncSwiftCommand { } } + // Load the package graph. let packageGraph = try await swiftCommandState .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in @@ -172,10 +173,13 @@ struct ShowTemplates: AsyncSwiftCommand { switch self.format { case .flatlist: for template in templates.sorted(by: { $0.name < $1.name }) { + let description = try await swiftCommandState.withTemporaryWorkspace(switchingTo: resolvedTemplatePath) {_, _ in + try await getDescription(swiftCommandState, template: template.name) + } if let package = template.package { - print("\(template.name) (\(package))") + print("\(template.name) (\(package)) : \(description)") } else { - print(template.name) + print("\(template.name) : \(description)") } } @@ -188,6 +192,35 @@ struct ShowTemplates: AsyncSwiftCommand { } } + private func getDescription(_ swiftCommandState: SwiftCommandState, template: String) async throws -> String { + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() + + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope + ) + guard let rootManifest = rootManifests.values.first else { + throw InternalError("invalid manifests at \(root.packages)") + } + + let products = rootManifest.products + let targets = rootManifest.targets + + for _ in products { + if let target: TargetDescription = targets.first(where: { $0.name == template }) { + if let options = target.templateInitializationOptions { + if case .packageInit(_, _, let description) = options { + return description + } + } + } + } + throw InternalError( + "Could not find template \(template)" + ) + } + /// Represents a discovered template. struct Template: Codable { /// Optional name of the external package, if the template comes from one. From 8455874806c12e4f73a08cb7bd12431932c23347 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 24 Jun 2025 14:53:41 -0400 Subject: [PATCH 065/225] new command swift package scaffold --- Package.swift | 6 ------ .../Scaffold.swift} | 19 +++++++----------- .../PackageCommands/ShowTemplates.swift | 2 +- .../PackageCommands/SwiftPackageCommand.swift | 1 + Sources/swift-scaffold/CMakeLists.txt | 18 ----------------- Sources/swift-scaffold/Entrypoint.swift | 20 ------------------- 6 files changed, 9 insertions(+), 57 deletions(-) rename Sources/Commands/{SwiftScaffold.swift => PackageCommands/Scaffold.swift} (94%) delete mode 100644 Sources/swift-scaffold/CMakeLists.txt delete mode 100644 Sources/swift-scaffold/Entrypoint.swift diff --git a/Package.swift b/Package.swift index cf44baeba3b..ca107d6428b 100644 --- a/Package.swift +++ b/Package.swift @@ -718,12 +718,6 @@ let package = Package( dependencies: ["Commands"], exclude: ["CMakeLists.txt"] ), - .executableTarget( - /** Scaffolds a package */ - name: "swift-scaffold", - dependencies: ["Commands"], - exclude: ["CMakeLists.txt"] - ), .executableTarget( /** Interacts with package collections */ name: "swift-package-collection", diff --git a/Sources/Commands/SwiftScaffold.swift b/Sources/Commands/PackageCommands/Scaffold.swift similarity index 94% rename from Sources/Commands/SwiftScaffold.swift rename to Sources/Commands/PackageCommands/Scaffold.swift index 9e449f481b8..83f8fa7d678 100644 --- a/Sources/Commands/SwiftScaffold.swift +++ b/Sources/Commands/PackageCommands/Scaffold.swift @@ -30,15 +30,10 @@ import XCBuildSupport import ArgumentParserToolInfo -public struct SwiftScaffoldCommand: AsyncSwiftCommand { - public static var configuration = CommandConfiguration( - commandName: "scaffold", - _superCommandName: "swift", - abstract: "Scaffold packages from templates.", - discussion: "SEE ALSO: swift run, swift package, swift test", - version: SwiftVersion.current.completeDisplayString, - helpNames: [.short, .long, .customLong("help", withSingleDash: true)]) - +extension SwiftPackageCommand { + public struct Scaffold: AsyncSwiftCommand { + public static let configuration = CommandConfiguration( + abstract: "Generate a new Swift package from a template.") @OptionGroup(visibility: .hidden) public var globalOptions: GlobalOptions @@ -144,10 +139,10 @@ public struct SwiftScaffoldCommand: AsyncSwiftCommand { ) let registryRequirement: PackageDependency.Registry.Requirement? = - try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement + try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = - try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement let resolvedTemplatePath = try await TemplatePathResolver( source: templateSource, @@ -289,7 +284,7 @@ public struct SwiftScaffoldCommand: AsyncSwiftCommand { public init() {} } - +} extension InitPackage.PackageType { init(from templateType: TargetDescription.TemplateType) throws { diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 782da198123..442161a08bd 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -156,7 +156,7 @@ struct ShowTemplates: AsyncSwiftCommand { let packageGraph = try await swiftCommandState .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in try await swiftCommandState.loadPackageGraph() - } + } let rootPackages = packageGraph.rootPackages.map(\.identity) diff --git a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift index a63586f1f99..3d8556e2c51 100644 --- a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift +++ b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift @@ -44,6 +44,7 @@ public struct SwiftPackageCommand: AsyncParsableCommand { Update.self, Describe.self, Init.self, + Scaffold.self, Format.self, Migrate.self, diff --git a/Sources/swift-scaffold/CMakeLists.txt b/Sources/swift-scaffold/CMakeLists.txt deleted file mode 100644 index 94f49824fbd..00000000000 --- a/Sources/swift-scaffold/CMakeLists.txt +++ /dev/null @@ -1,18 +0,0 @@ -# This source file is part of the Swift open source project -# -# Copyright (c) 2014 - 2019 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 Swift project authors - -add_executable(swift-scaffold - Entrypoint.swift) -target_link_libraries(swift-run PRIVATE - Commands) - -target_compile_options(swift-scaffold PRIVATE - -parse-as-library) - -install(TARGETS swift-scaffold - RUNTIME DESTINATION bin) diff --git a/Sources/swift-scaffold/Entrypoint.swift b/Sources/swift-scaffold/Entrypoint.swift deleted file mode 100644 index 0f79e310eab..00000000000 --- a/Sources/swift-scaffold/Entrypoint.swift +++ /dev/null @@ -1,20 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 Commands - -@main -struct Entrypoint { - static func main() async { - await SwiftScaffoldCommand.main() - } -} From d102b2e7efd59f33f0dfbd4fd5e5c1d3a71ac25a Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 24 Jun 2025 14:56:42 -0400 Subject: [PATCH 066/225] added more clarity to .template parameter initialType --- Sources/PackageDescription/Target.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index 4ff724df1ad..90d8a188cb5 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -1287,7 +1287,7 @@ public extension [Target] { swiftSettings: [SwiftSetting]? = nil, linkerSettings: [LinkerSetting]? = nil, plugins: [Target.PluginUsage]? = nil, - initialType: Target.TemplateType, + initialPackageType: Target.TemplateType = .empty, templatePermissions: [TemplatePermissions]? = nil, description: String ) -> [Target] { @@ -1321,7 +1321,7 @@ public extension [Target] { let templateInitializationOptions = Target.TemplateInitializationOptions.packageInit( - templateType: initialType, + templateType: initialPackageType, templatePermissions: templatePermissions, description: description ) From 4f15959002e70fa3bf5271dfa4a363df7112efcf Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 8 Jul 2025 15:18:58 -0400 Subject: [PATCH 067/225] registry stuff, might revert later, but for now we can keep --- Sources/Basics/SourceControlURL.swift | 4 + Sources/PackageMetadata/PackageMetadata.swift | 41 ++++- Sources/PackageRegistry/RegistryClient.swift | 68 +++++++- .../PackageRegistryCommand+Discover.swift | 101 +++++++++++ .../PackageRegistryCommand+Get.swift | 164 ++++++++++++++++++ .../PackageRegistryCommand.swift | 8 + .../Workspace/Workspace+Dependencies.swift | 88 +++++++++- 7 files changed, 469 insertions(+), 5 deletions(-) create mode 100644 Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift create mode 100644 Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift diff --git a/Sources/Basics/SourceControlURL.swift b/Sources/Basics/SourceControlURL.swift index 398c2f89ddf..70bb1df9cee 100644 --- a/Sources/Basics/SourceControlURL.swift +++ b/Sources/Basics/SourceControlURL.swift @@ -23,6 +23,10 @@ public struct SourceControlURL: Codable, Equatable, Hashable, Sendable { self.urlString = urlString } + public init(argument: String) { + self.urlString = argument + } + public init(_ url: URL) { self.urlString = url.absoluteString } diff --git a/Sources/PackageMetadata/PackageMetadata.swift b/Sources/PackageMetadata/PackageMetadata.swift index 531430a09dd..4729caefcac 100644 --- a/Sources/PackageMetadata/PackageMetadata.swift +++ b/Sources/PackageMetadata/PackageMetadata.swift @@ -24,6 +24,20 @@ import struct Foundation.URL import struct TSCUtility.Version public struct Package { + + public struct Template: Sendable { + public let name: String + public let description: String? + //public let permissions: [String]? TODO ADD + public let arguments: [TemplateArguments]? + } + + public struct TemplateArguments: Sendable { + public let name: String + public let description: String? + public let isRequired: Bool? + } + public enum Source { case indexAndCollections(collections: [PackageCollectionsModel.CollectionIdentifier], indexes: [URL]) case registry(url: URL) @@ -74,6 +88,7 @@ public struct Package { public let publishedAt: Date? public let signingEntity: SigningEntity? public let latestVersion: Version? + public let templates: [Template]? fileprivate init( identity: PackageIdentity, @@ -89,7 +104,8 @@ public struct Package { publishedAt: Date? = nil, signingEntity: SigningEntity? = nil, latestVersion: Version? = nil, - source: Source + source: Source, + templates: [Template]? = nil ) { self.identity = identity self.location = location @@ -105,6 +121,7 @@ public struct Package { self.signingEntity = signingEntity self.latestVersion = latestVersion self.source = source + self.templates = templates } } @@ -164,6 +181,7 @@ public struct PackageSearchClient { public let description: String? public let publishedAt: Date? public let signingEntity: SigningEntity? + public let templates: [Package.Template]? } private func getVersionMetadata( @@ -185,7 +203,8 @@ public struct PackageSearchClient { author: metadata.author.map { .init($0) }, description: metadata.description, publishedAt: metadata.publishedAt, - signingEntity: metadata.sourceArchive?.signingEntity + signingEntity: metadata.sourceArchive?.signingEntity, + templates: metadata.templates?.map { .init($0) } ) } @@ -424,6 +443,24 @@ extension Package.Organization { } } +extension Package.Template { + fileprivate init(_ template: RegistryClient.PackageVersionMetadata.Template) { + self.init( + name: template.name, description: template.description, arguments: template.arguments?.map { .init($0) } + ) + } +} + +extension Package.TemplateArguments { + fileprivate init(_ argument: RegistryClient.PackageVersionMetadata.TemplateArguments) { + self.init( + name: argument.name, + description: argument.description, + isRequired: argument.isRequired + ) + } +} + extension SigningEntity { fileprivate init(signer: PackageCollectionsModel.Signer) { // All package collection signers are "recognized" diff --git a/Sources/PackageRegistry/RegistryClient.swift b/Sources/PackageRegistry/RegistryClient.swift index 2eec40a5453..f46e95b0132 100644 --- a/Sources/PackageRegistry/RegistryClient.swift +++ b/Sources/PackageRegistry/RegistryClient.swift @@ -363,7 +363,20 @@ public final class RegistryClient: AsyncCancellable { ) }, description: versionMetadata.metadata?.description, - publishedAt: versionMetadata.metadata?.originalPublicationTime ?? versionMetadata.publishedAt + publishedAt: versionMetadata.metadata?.originalPublicationTime ?? versionMetadata.publishedAt, + templates: versionMetadata.metadata?.templates?.compactMap { template in + PackageVersionMetadata.Template( + name: template.name, + description: template.description, + arguments: template.arguments?.map { arg in + PackageVersionMetadata.TemplateArguments( + name: arg.name, + description: arg.description, + isRequired: arg.isRequired + ) + } + ) + } ) return packageVersionMetadata @@ -1724,11 +1737,45 @@ extension RegistryClient { public let author: Author? public let description: String? public let publishedAt: Date? + public let templates: [Template]? public var sourceArchive: Resource? { self.resources.first(where: { $0.name == "source-archive" }) } + public struct Template: Sendable { + public let name: String + public let description: String? + //public let permissions: [String]? TODO ADD + public let arguments: [TemplateArguments]? + + public init( + name: String, + description: String? = nil, + arguments: [TemplateArguments]? = nil + ) { + self.name = name + self.description = description + self.arguments = arguments + } + } + + public struct TemplateArguments: Sendable { + public let name: String + public let description: String? + public let isRequired: Bool? + + public init( + name: String, + description: String? = nil, + isRequired: Bool? = nil + ) { + self.name = name + self.description = description + self.isRequired = isRequired + } + } + public struct Resource: Sendable { public let name: String public let type: String @@ -2149,6 +2196,7 @@ extension RegistryClient { public let readmeURL: String? public let repositoryURLs: [String]? public let originalPublicationTime: Date? + public let templates: [Template]? public init( author: Author? = nil, @@ -2156,7 +2204,8 @@ extension RegistryClient { licenseURL: String? = nil, readmeURL: String? = nil, repositoryURLs: [String]? = nil, - originalPublicationTime: Date? = nil + originalPublicationTime: Date? = nil, + templates: [Template]? = nil ) { self.author = author self.description = description @@ -2164,9 +2213,24 @@ extension RegistryClient { self.readmeURL = readmeURL self.repositoryURLs = repositoryURLs self.originalPublicationTime = originalPublicationTime + self.templates = templates } } + public struct Template: Codable { + public let name: String + public let description: String? + //public let permissions: [String]? TODO ADD + public let arguments: [TemplateArguments]? + } + + public struct TemplateArguments: Codable { + public let name: String + public let description: String? + public let isRequired: Bool? + } + + public struct Author: Codable { public let name: String public let email: String? diff --git a/Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift b/Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift new file mode 100644 index 00000000000..f6f0137342e --- /dev/null +++ b/Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2023 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 Commands +import CoreCommands +import Foundation +import PackageModel +import PackageFingerprint +import PackageRegistry +import PackageSigning +import Workspace + +#if USE_IMPL_ONLY_IMPORTS +@_implementationOnly import X509 // FIXME: need this import or else SwiftSigningIdentity initializer fails +#else +import X509 +#endif + +import struct TSCBasic.ByteString +import struct TSCBasic.RegEx +import struct TSCBasic.SHA256 + +import struct TSCUtility.Version + +extension PackageRegistryCommand { + struct Discover: AsyncSwiftCommand { + static let configuration = CommandConfiguration( + abstract: "Get a package registry entry." + ) + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + @Argument(help: .init("URL pointing towards package identifiers", valueName: "scm-url")) + var url: SourceControlURL + + @Flag(name: .customLong("allow-insecure-http"), help: "Allow using a non-HTTPS registry URL.") + var allowInsecureHTTP: Bool = false + + @Option(name: [.customLong("url"), .customLong("registry-url")], help: "Override registry URL.") + var registryURL: URL? + + func run(_ swiftCommandState: SwiftCommandState) async throws { + let packageDirectory = try resolvePackageDirectory(swiftCommandState) + let authorizationProvider = try resolveAuthorizationProvider(swiftCommandState) + + let registryClient = RegistryClient( + configuration: try getRegistriesConfig(swiftCommandState, global: false).configuration, + fingerprintStorage: .none, + fingerprintCheckingMode: .strict, + skipSignatureValidation: false, + signingEntityStorage: .none, + signingEntityCheckingMode: .strict, + authorizationProvider: authorizationProvider, + delegate: .none, + checksumAlgorithm: SHA256() + ) + + let set = try await registryClient.lookupIdentities(scmURL: url, observabilityScope: swiftCommandState.observabilityScope) + + if set.isEmpty { + throw ValidationError.invalidLookupURL(url) + } + + print(set) + } + + private func resolvePackageDirectory(_ swiftCommandState: SwiftCommandState) throws -> AbsolutePath { + let directory = try self.globalOptions.locations.packageDirectory + ?? swiftCommandState.getPackageRoot() + + guard localFileSystem.isDirectory(directory) else { + throw StringError("No package found at '\(directory)'.") + } + + return directory + } + + private func resolveAuthorizationProvider(_ swiftCommandState: SwiftCommandState) throws -> AuthorizationProvider { + guard let provider = try swiftCommandState.getRegistryAuthorizationProvider() else { + throw ValidationError.unknownCredentialStore + } + + return provider + } + } +} + + +extension SourceControlURL: ExpressibleByArgument {} diff --git a/Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift b/Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift new file mode 100644 index 00000000000..a785075ef4e --- /dev/null +++ b/Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift @@ -0,0 +1,164 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2023 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 Commands +import CoreCommands +import Foundation +import PackageModel +import PackageFingerprint +import PackageRegistry +import PackageSigning +import Workspace + +#if USE_IMPL_ONLY_IMPORTS +@_implementationOnly import X509 // FIXME: need this import or else SwiftSigningIdentity initializer fails +#else +import X509 +#endif + +import struct TSCBasic.ByteString +import struct TSCBasic.RegEx +import struct TSCBasic.SHA256 + +import struct TSCUtility.Version + +extension PackageRegistryCommand { + struct Get: AsyncSwiftCommand { + static let configuration = CommandConfiguration( + abstract: "Get a package registry entry." + ) + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + @Argument(help: .init("The package identifier.", valueName: "package-id")) + var packageIdentity: PackageIdentity + + @Option(help: .init("The package release version being queried.", valueName: "package-version")) + var packageVersion: Version? + + @Flag(help: .init("Fetch the Package.swift manifest of the registry entry", valueName: "manifest")) + var manifest: Bool = false + + @Option(help: .init("Swift tools version of the manifest", valueName: "custom-tools-version")) + var customToolsVersion: String? + + @Flag(name: .customLong("allow-insecure-http"), help: "Allow using a non-HTTPS registry URL.") + var allowInsecureHTTP: Bool = false + + @Option(name: [.customLong("url"), .customLong("registry-url")], help: "Override registry URL.") + var registryURL: URL? + + func run(_ swiftCommandState: SwiftCommandState) async throws { + let packageDirectory = try resolvePackageDirectory(swiftCommandState) + let registryURL = try resolveRegistryURL(swiftCommandState) + let authorizationProvider = try resolveAuthorizationProvider(swiftCommandState) + + let registryClient = RegistryClient( + configuration: try getRegistriesConfig(swiftCommandState, global: false).configuration, + fingerprintStorage: .none, + fingerprintCheckingMode: .strict, + skipSignatureValidation: false, + signingEntityStorage: .none, + signingEntityCheckingMode: .strict, + authorizationProvider: authorizationProvider, + delegate: .none, + checksumAlgorithm: SHA256() + ) + + try await fetchRegistryData(using: registryClient, swiftCommandState: swiftCommandState) + } + + private func resolvePackageDirectory(_ swiftCommandState: SwiftCommandState) throws -> AbsolutePath { + let directory = try self.globalOptions.locations.packageDirectory + ?? swiftCommandState.getPackageRoot() + + guard localFileSystem.isDirectory(directory) else { + throw StringError("No package found at '\(directory)'.") + } + + return directory + } + + private func resolveRegistryURL(_ swiftCommandState: SwiftCommandState) throws -> URL { + let config = try getRegistriesConfig(swiftCommandState, global: false).configuration + guard let identity = self.packageIdentity.registry else { + throw ValidationError.invalidPackageIdentity(self.packageIdentity) + } + + guard let url = self.registryURL ?? config.registry(for: identity.scope)?.url else { + throw ValidationError.unknownRegistry + } + + let allowHTTP = try self.allowInsecureHTTP && (config.authentication(for: url) == nil) + try url.validateRegistryURL(allowHTTP: allowHTTP) + + return url + } + + private func resolveAuthorizationProvider(_ swiftCommandState: SwiftCommandState) throws -> AuthorizationProvider { + guard let provider = try swiftCommandState.getRegistryAuthorizationProvider() else { + throw ValidationError.unknownCredentialStore + } + + return provider + } + + private func fetchToolsVersion() -> ToolsVersion? { + return customToolsVersion.flatMap { ToolsVersion(string: $0) } + } + + private func fetchRegistryData( + using client: RegistryClient, + swiftCommandState: SwiftCommandState + ) async throws { + let scope = swiftCommandState.observabilityScope + + if manifest { + guard let version = packageVersion else { + throw ValidationError.noPackageVersion(packageIdentity) + } + + let toolsVersion = fetchToolsVersion() + let content = try await client.getManifestContent( + package: self.packageIdentity, + version: version, + customToolsVersion: toolsVersion, + observabilityScope: scope + ) + + print(content) + return + } + + if let version = packageVersion { + let metadata = try await client.getPackageVersionMetadata( + package: self.packageIdentity, + version: version, + fileSystem: localFileSystem, + observabilityScope: scope + ) + + print(metadata) + } else { + let metadata = try await client.getPackageMetadata( + package: self.packageIdentity, + observabilityScope: scope + ) + + print(metadata) + } + } + } +} diff --git a/Sources/PackageRegistryCommand/PackageRegistryCommand.swift b/Sources/PackageRegistryCommand/PackageRegistryCommand.swift index f41004fcd8e..516ed6947a4 100644 --- a/Sources/PackageRegistryCommand/PackageRegistryCommand.swift +++ b/Sources/PackageRegistryCommand/PackageRegistryCommand.swift @@ -32,6 +32,8 @@ public struct PackageRegistryCommand: AsyncParsableCommand { Login.self, Logout.self, Publish.self, + Get.self, + Discover.self ], helpNames: [.short, .long, .customLong("help", withSingleDash: true)] ) @@ -141,6 +143,8 @@ public struct PackageRegistryCommand: AsyncParsableCommand { case unknownCredentialStore case invalidCredentialStore(Error) case credentialLengthLimitExceeded(Int) + case noPackageVersion(PackageIdentity) + case invalidLookupURL(SourceControlURL) } static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace.Configuration.Registries { @@ -199,6 +203,10 @@ extension PackageRegistryCommand.ValidationError: CustomStringConvertible { return "credential store is invalid: \(error.interpolationDescription)" case .credentialLengthLimitExceeded(let limit): return "password or access token must be \(limit) characters or less" + case .noPackageVersion(let identity): + return "no package version found for '\(identity)'" + case .invalidLookupURL(let url): + return "no package identifier was found in URL: \(url)" } } } diff --git a/Sources/Workspace/Workspace+Dependencies.swift b/Sources/Workspace/Workspace+Dependencies.swift index af20f11c39f..3fc9272af6d 100644 --- a/Sources/Workspace/Workspace+Dependencies.swift +++ b/Sources/Workspace/Workspace+Dependencies.swift @@ -40,6 +40,7 @@ import struct PackageGraph.Term import class PackageLoading.ManifestLoader import enum PackageModel.PackageDependency import struct PackageModel.PackageIdentity +import struct Basics.SourceControlURL import struct PackageModel.PackageReference import enum PackageModel.ProductFilter import struct PackageModel.ToolsVersion @@ -50,7 +51,7 @@ import struct PackageModel.TraitDescription import enum PackageModel.TraitConfiguration import class PackageModel.Manifest -extension Workspace { +public extension Workspace { enum ResolvedFileStrategy { case lockFile case update(forceResolution: Bool) @@ -818,6 +819,91 @@ extension Workspace { } } + /* + func resolveTemplatePackage( + templateDirectory: AbsolutePath? = nil, + + templateURL: SourceControlURL? = nil, + templatePackageID: PackageIdentity? = nil, + observabilityScope: ObservabilityScope, + revisionParsed: String?, + branchParsed: String?, + exactVersion: Version?, + fromParsed: String?, + toParsed: String?, + upToNextMinorParsed: String? + + ) async throws -> AbsolutePath { + if let path = templateDirectory { + // Local filesystem path + let packageRef = PackageReference.root(identity: .init(path: path), path: path) + let dependency = try ManagedDependency.fileSystem(packageRef: packageRef) + + guard case .fileSystem(let resolvedPath) = dependency.state else { + throw InternalError("invalid file system package state") + } + + await self.state.add(dependency: dependency) + try await self.state.save() + return resolvedPath + + } else if let url = templateURL { + // Git URL + let packageRef = PackageReference.remoteSourceControl(identity: PackageIdentity(url: url), url: url) + + let requirement: PackageStateChange.Requirement + if let revision = revisionParsed { + if let branch = branchParsed { + requirement = .revision(.init(identifier:revision), branch: branch) + } else { + requirement = .revision(.init(identifier: revision), branch: nil) + } + } else if let version = exactVersion { + requirement = .version(version) + } else { + throw InternalError("No usable Git version/revision/branch provided") + } + + return try await self.updateDependency( + package: packageRef, + requirement: requirement, + productFilter: .everything, + observabilityScope: observabilityScope + ) + + } else if let packageID = templatePackageID { + // Registry package + let identity = packageID + let packageRef = PackageReference.registry(identity: identity) + + let requirement: PackageStateChange.Requirement + if let exact = exactVersion { + requirement = .version(exact) + } else if let from = fromParsed, let to = toParsed { + // Not supported in updateDependency – adjust logic if needed + throw InternalError("Version range constraints are not supported here") + } else if let upToMinor = upToNextMinorParsed { + // SwiftPM normally supports this – you may need to expand updateDependency to support it + throw InternalError("upToNextMinorFrom not currently supported") + } else { + throw InternalError("No usable Registry version provided") + } + + return try await self.updateDependency( + package: packageRef, + requirement: requirement, + productFilter: .everything, + observabilityScope: observabilityScope + ) + + } else { + throw InternalError("No template source provided (path, url, or package-id)") + } + } + + */ + + public enum ResolutionPrecomputationResult: Equatable { case required(reason: WorkspaceResolveReason) case notRequired From 6355348522821bc26d1d595385ca1f7ba448d6a9 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 8 Jul 2025 16:46:09 -0400 Subject: [PATCH 068/225] added back to init templates --- Sources/Commands/PackageCommands/Init.swift | 336 ++++++++++++++++-- .../Commands/PackageCommands/Scaffold.swift | 308 ---------------- .../PackageCommands/SwiftPackageCommand.swift | 1 - Sources/Workspace/InitPackage.swift | 1 - 4 files changed, 314 insertions(+), 332 deletions(-) delete mode 100644 Sources/Commands/PackageCommands/Scaffold.swift diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index d93600002b1..b4b56ea3826 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -11,6 +11,8 @@ //===----------------------------------------------------------------------===// import ArgumentParser +import ArgumentParserToolInfo + import Basics @_spi(SwiftPMInternal) @@ -19,9 +21,14 @@ import CoreCommands import PackageModel import Workspace import SPMBuildCore +import TSCBasic +import TSCUtility +import Foundation +import PackageGraph + extension SwiftPackageCommand { - struct Init: SwiftCommand { + struct Init: AsyncSwiftCommand { public static let configuration = CommandConfiguration( abstract: "Initialize a new package.") @@ -41,50 +48,335 @@ extension SwiftPackageCommand { macro - A package that vends a macro. empty - An empty package with a Package.swift manifest. """)) - var initMode: InitPackage.PackageType = .library + var initMode: String? + + //if --type is mentioned with one of the seven above, then normal initialization + // if --type is mentioned along with a templateSource, its a template (no matter what) + // if-type is not mentioned with no templatesoURCE, then defaults to library + // if --type is not mentioned and templateSource is not nil, then there is only one template in package /// Which testing libraries to use (and any related options.) - @OptionGroup() + @OptionGroup(visibility: .hidden) var testLibraryOptions: TestLibraryOptions @Option(name: .customLong("name"), help: "Provide custom package name.") var packageName: String? + @OptionGroup(visibility: .hidden) + var buildOptions: BuildCommandOptions + + /// The type of template to use: `registry`, `git`, or `local`. + var templateSource: InitTemplatePackage.TemplateSource? { + if templateDirectory != nil { + .local + } else if templateURL != nil { + .git + } else if templatePackageID != nil { + .registry + } else { + nil + } + } + + + // + // + // + // + /// Path to a local template. + @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) + var templateDirectory: Basics.AbsolutePath? + + /// Git URL of the template. + @Option(name: .customLong("url"), help: "The git URL of the template.") + var templateURL: String? + + /// Package Registry ID of the template. + @Option(name: .customLong("package-id"), help: "The package identifier of the template") + var templatePackageID: String? + + // MARK: - Versioning Options for Remote Git Templates and Registry templates + + /// The exact version of the remote package to use. + @Option(help: "The exact package version to depend on.") + var exact: Version? + + /// Specific revision to use (for Git templates). + @Option(help: "The specific package revision to depend on.") + var revision: String? + + /// Branch name to use (for Git templates). + @Option(help: "The branch of the package to depend on.") + var branch: String? + + /// Version to depend on, up to the next major version. + @Option(help: "The package version to depend on (up to the next major version).") + var from: Version? + + /// Version to depend on, up to the next minor version. + @Option(help: "The package version to depend on (up to the next minor version).") + var upToNextMinorFrom: Version? + + /// Upper bound on the version range (exclusive). + @Option(help: "Specify upper bound on the package version range (exclusive).") + var to: Version? + + /// Predetermined arguments specified by the consumer. + @Argument( + help: "Predetermined arguments to pass to the template." + ) + var args: [String] = [] + // This command should support creating the supplied --package-path if it isn't created. var createPackagePath = true - func run(_ swiftCommandState: SwiftCommandState) throws { + func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") } let packageName = self.packageName ?? cwd.basename - // Testing is on by default, with XCTest only enabled explicitly. - // For macros this is reversed, since we don't support testing - // macros with Swift Testing yet. - var supportedTestingLibraries = Set() - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.xctest) + // Check for template init path + if let _ = templateSource { + // When a template source is provided: + // - If the user gives a known type, it's probably a misuse + // - If the user gives an unknown value for --type, treat it as the name of the template + // - If --type is missing entirely, assume the package has a single template + try await initTemplate(swiftCommandState) + return + } else { + guard let initModeString = self.initMode else { + throw ValidationError("Specify a package type using the --type option.") + } + guard let knownType = InitPackage.PackageType(rawValue: initModeString) else { + throw ValidationError("Package type \(initModeString) not supported") + } + // Configure testing libraries + var supportedTestingLibraries = Set() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (knownType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (knownType != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.swiftTesting) + } + + let initPackage = try InitPackage( + name: packageName, + packageType: knownType, + supportedTestingLibraries: supportedTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, + fileSystem: swiftCommandState.fileSystem + ) + initPackage.progressReporter = { message in print(message) } + try initPackage.writePackageStructure() + + + } + + } + + + public func initTemplate(_ swiftCommandState: SwiftCommandState) async throws { + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.swiftTesting) + + let packageName = self.packageName ?? cwd.basename + + try await self.runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) + + } + + /// Runs the package initialization using an author-defined template. + private func runTemplateInit( + swiftCommandState: SwiftCommandState, + packageName: String, + cwd: Basics.AbsolutePath + ) async throws { + + let template = initMode + guard let source = templateSource else { + throw ValidationError("No template source specified.") } - let initPackage = try InitPackage( + let requirementResolver = DependencyRequirementResolver( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ) + + let registryRequirement: PackageDependency.Registry.Requirement? = + try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement + + let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = + try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + + let resolvedTemplatePath = try await TemplatePathResolver( + source: templateSource, + templateDirectory: templateDirectory, + templateURL: templateURL, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() + + if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { + throw ValidationError("The specified template path does not exist: \(dir.pathString)") + } + + let templateInitType = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in + try await self.checkConditions(swiftCommandState, template: template) + } + + // Clean up downloaded package after execution. + defer { + if templateSource == .git { + try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) + } else if templateSource == .registry { + let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL + try? FileManager.default.removeItem(at: parentDirectoryURL) + } + } + + let supportedTemplateTestingLibraries: Set = .init() + + let builder = DefaultPackageDependencyBuilder( + templateSource: source, + packageName: packageName, + templateURL: self.templateURL, + templatePackageID: self.templatePackageID + ) + + let dependencyKind = try builder.makePackageDependency( + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ) + + let initTemplatePackage = try InitTemplatePackage( name: packageName, - packageType: initMode, - supportedTestingLibraries: supportedTestingLibraries, + initMode: dependencyKind, + templatePath: resolvedTemplatePath, + fileSystem: swiftCommandState.fileSystem, + packageType: templateInitType, + supportedTestingLibraries: supportedTemplateTestingLibraries, destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, - fileSystem: swiftCommandState.fileSystem + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration + ) + + try initTemplatePackage.setupTemplateManifest() + + try await TemplateBuildSupport.build( + swiftCommandState: swiftCommandState, + buildOptions: self.buildOptions, + globalOptions: self.globalOptions, + cwd: cwd + ) + + let packageGraph = try await swiftCommandState.loadPackageGraph() + let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) + + guard let commandPlugin = matchingPlugins.first else { + guard let template = template + else { throw ValidationError("No templates were found in \(packageName)") } + + throw ValidationError("No templates were found that match the name \(template)") + } + + guard matchingPlugins.count == 1 else { + let templateNames = matchingPlugins.compactMap { module in + let plugin = module.underlying as! PluginModule + guard case .command(let intent, _) = plugin.capability else { return String?.none } + + return intent.invocationVerb + } + throw ValidationError( + "More than one template was found in the package. Please use `--type` along with one of the available templates: \(templateNames.joined(separator: ", "))" + ) + } + + let output = try await TemplatePluginRunner.run( + plugin: commandPlugin, + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: ["--", "--experimental-dump-help"], + swiftCommandState: swiftCommandState + ) + + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: args) + + do { + let _ = try await TemplatePluginRunner.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: response, + swiftCommandState: swiftCommandState + ) + } + } + + /// Validates the loaded manifest to determine package type. + private func checkConditions(_ swiftCommandState: SwiftCommandState, template: String?) async throws -> InitPackage.PackageType { + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() + + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope ) - initPackage.progressReporter = { message in - print(message) + guard let rootManifest = rootManifests.values.first else { + throw InternalError("invalid manifests at \(root.packages)") } - try initPackage.writePackageStructure() + + let products = rootManifest.products + let targets = rootManifest.targets + + for _ in products { + if let target: TargetDescription = targets.first(where: { template == nil || $0.name == template }) { + if let options = target.templateInitializationOptions { + if case .packageInit(let templateType, _, _) = options { + return try .init(from: templateType) + } + } + } + } + throw ValidationError( + "Could not find \(template != nil ? "template \(template!)" : "any templates in the package")" + ) + } + public init() {} + + } +} + +extension InitPackage.PackageType { + init(from templateType: TargetDescription.TemplateType) throws { + switch templateType { + case .executable: + self = .executable + case .library: + self = .library + case .tool: + self = .tool + case .macro: + self = .macro + case .buildToolPlugin: + self = .buildToolPlugin + case .commandPlugin: + self = .commandPlugin + case .empty: + self = .empty } } } diff --git a/Sources/Commands/PackageCommands/Scaffold.swift b/Sources/Commands/PackageCommands/Scaffold.swift deleted file mode 100644 index 83f8fa7d678..00000000000 --- a/Sources/Commands/PackageCommands/Scaffold.swift +++ /dev/null @@ -1,308 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 - -@_spi(SwiftPMInternal) -import CoreCommands - -import PackageModel -import SPMBuildCore -import TSCUtility -import Workspace - -import Foundation -import PackageGraph -import SourceControl -import SPMBuildCore -import TSCBasic -import XCBuildSupport - -import ArgumentParserToolInfo - -extension SwiftPackageCommand { - public struct Scaffold: AsyncSwiftCommand { - public static let configuration = CommandConfiguration( - abstract: "Generate a new Swift package from a template.") - - @OptionGroup(visibility: .hidden) - public var globalOptions: GlobalOptions - - @OptionGroup(visibility: .hidden) - var buildOptions: BuildCommandOptions - - @Option(name: .customLong("name"), help: "Provide custom package name.") - var packageName: String? - - /// Name of a template to use for package initialization. - @Option( - name: .customLong("template"), - help: "Name of a template to initialize the package, unspecified if the default template should be used." - ) - var template: String? - - /// The type of template to use: `registry`, `git`, or `local`. - var templateSource: InitTemplatePackage.TemplateSource? { - if templateDirectory != nil { - .local - } else if templateURL != nil { - .git - } else if templatePackageID != nil { - .registry - } else { - nil - } - } - - /// Path to a local template. - @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) - var templateDirectory: Basics.AbsolutePath? - - /// Git URL of the template. - @Option(name: .customLong("url"), help: "The git URL of the template.") - var templateURL: String? - - /// Package Registry ID of the template. - @Option(name: .customLong("package-id"), help: "The package identifier of the template") - var templatePackageID: String? - - // MARK: - Versioning Options for Remote Git Templates and Registry templates - - /// The exact version of the remote package to use. - @Option(help: "The exact package version to depend on.") - var exact: Version? - - /// Specific revision to use (for Git templates). - @Option(help: "The specific package revision to depend on.") - var revision: String? - - /// Branch name to use (for Git templates). - @Option(help: "The branch of the package to depend on.") - var branch: String? - - /// Version to depend on, up to the next major version. - @Option(help: "The package version to depend on (up to the next major version).") - var from: Version? - - /// Version to depend on, up to the next minor version. - @Option(help: "The package version to depend on (up to the next minor version).") - var upToNextMinorFrom: Version? - - /// Upper bound on the version range (exclusive). - @Option(help: "Specify upper bound on the package version range (exclusive).") - var to: Version? - - /// Predetermined arguments specified by the consumer. - @Argument( - help: "Predetermined arguments to pass to the template." - ) - var args: [String] = [] - - public func run(_ swiftCommandState: SwiftCommandState) async throws { - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") - } - - let packageName = self.packageName ?? cwd.basename - - try await self.runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) - - } - - /// Runs the package initialization using an author-defined template. - private func runTemplateInit( - swiftCommandState: SwiftCommandState, - packageName: String, - cwd: Basics.AbsolutePath - ) async throws { - guard let source = templateSource else { - throw ValidationError("No template source specified.") - } - - let requirementResolver = DependencyRequirementResolver( - exact: exact, - revision: revision, - branch: branch, - from: from, - upToNextMinorFrom: upToNextMinorFrom, - to: to - ) - - let registryRequirement: PackageDependency.Registry.Requirement? = - try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement - - let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = - try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement - - let resolvedTemplatePath = try await TemplatePathResolver( - source: templateSource, - templateDirectory: templateDirectory, - templateURL: templateURL, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - packageIdentity: templatePackageID, - swiftCommandState: swiftCommandState - ).resolve() - - if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { - throw ValidationError("The specified template path does not exist: \(dir.pathString)") - } - - let templateInitType = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in - try await self.checkConditions(swiftCommandState) - } - - // Clean up downloaded package after execution. - defer { - if templateSource == .git { - try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) - } else if templateSource == .registry { - let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL - try? FileManager.default.removeItem(at: parentDirectoryURL) - } - } - - let supportedTemplateTestingLibraries: Set = .init() - - let builder = DefaultPackageDependencyBuilder( - templateSource: source, - packageName: packageName, - templateURL: self.templateURL, - templatePackageID: self.templatePackageID - ) - - let dependencyKind = try builder.makePackageDependency( - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - resolvedTemplatePath: resolvedTemplatePath - ) - - let initTemplatePackage = try InitTemplatePackage( - name: packageName, - initMode: dependencyKind, - templatePath: resolvedTemplatePath, - fileSystem: swiftCommandState.fileSystem, - packageType: templateInitType, - supportedTestingLibraries: supportedTemplateTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration - ) - - try initTemplatePackage.setupTemplateManifest() - - try await TemplateBuildSupport.build( - swiftCommandState: swiftCommandState, - buildOptions: self.buildOptions, - globalOptions: self.globalOptions, - cwd: cwd - ) - - let packageGraph = try await swiftCommandState.loadPackageGraph() - let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: packageGraph, limitedTo: nil) - - guard let commandPlugin = matchingPlugins.first else { - guard let template = self.template - else { throw ValidationError("No templates were found in \(packageName)") } - - throw ValidationError("No templates were found that match the name \(template)") - } - - guard matchingPlugins.count == 1 else { - let templateNames = matchingPlugins.compactMap { module in - let plugin = module.underlying as! PluginModule - guard case .command(let intent, _) = plugin.capability else { return String?.none } - - return intent.invocationVerb - } - throw ValidationError( - "More than one template was found in the package. Please use `--template` to select from one of the available templates: \(templateNames.joined(separator: ", "))" - ) - } - - let output = try await TemplatePluginRunner.run( - plugin: commandPlugin, - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: ["--", "--experimental-dump-help"], - swiftCommandState: swiftCommandState - ) - - let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: args) - - do { - let _ = try await TemplatePluginRunner.run( - plugin: matchingPlugins[0], - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: response, - swiftCommandState: swiftCommandState - ) - } - } - - /// Validates the loaded manifest to determine package type. - private func checkConditions(_ swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType { - let workspace = try swiftCommandState.getActiveWorkspace() - let root = try swiftCommandState.getWorkspaceRoot() - - let rootManifests = try await workspace.loadRootManifests( - packages: root.packages, - observabilityScope: swiftCommandState.observabilityScope - ) - guard let rootManifest = rootManifests.values.first else { - throw InternalError("invalid manifests at \(root.packages)") - } - - let products = rootManifest.products - let targets = rootManifest.targets - - for _ in products { - if let target: TargetDescription = targets.first(where: { template == nil || $0.name == template }) { - if let options = target.templateInitializationOptions { - if case .packageInit(let templateType, _, _) = options { - return try .init(from: templateType) - } - } - } - } - throw InternalError( - "Could not find \(self.template != nil ? "template \(self.template!)" : "any templates in the package")" - ) - } - public init() {} - - } -} - -extension InitPackage.PackageType { - init(from templateType: TargetDescription.TemplateType) throws { - switch templateType { - case .executable: - self = .executable - case .library: - self = .library - case .tool: - self = .tool - case .macro: - self = .macro - case .buildToolPlugin: - self = .buildToolPlugin - case .commandPlugin: - self = .commandPlugin - case .empty: - self = .empty - } - } -} diff --git a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift index 3d8556e2c51..a63586f1f99 100644 --- a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift +++ b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift @@ -44,7 +44,6 @@ public struct SwiftPackageCommand: AsyncParsableCommand { Update.self, Describe.self, Init.self, - Scaffold.self, Format.self, Migrate.self, diff --git a/Sources/Workspace/InitPackage.swift b/Sources/Workspace/InitPackage.swift index 1c1fc6b8953..43d461be6f5 100644 --- a/Sources/Workspace/InitPackage.swift +++ b/Sources/Workspace/InitPackage.swift @@ -55,7 +55,6 @@ public final class InitPackage { case buildToolPlugin = "build-tool-plugin" case commandPlugin = "command-plugin" case macro = "macro" - public var description: String { return rawValue } From 240c0e286112925323a679a92b0b47153f284810 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 15 Jul 2025 16:35:56 -0400 Subject: [PATCH 069/225] prompting improvements --- Sources/Commands/PackageCommands/Init.swift | 32 ++-- Sources/Workspace/InitTemplatePackage.swift | 188 +++++++++++++++++--- 2 files changed, 177 insertions(+), 43 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index b4b56ea3826..7ab40e2dbbb 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -78,11 +78,6 @@ extension SwiftPackageCommand { } } - - // - // - // - // /// Path to a local template. @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) var templateDirectory: Basics.AbsolutePath? @@ -313,18 +308,21 @@ extension SwiftPackageCommand { ) let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: args) - - do { - let _ = try await TemplatePluginRunner.run( - plugin: matchingPlugins[0], - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: response, - swiftCommandState: swiftCommandState - ) - } - } + let cliResponses = try initTemplatePackage.promptUser(command: toolInfo.command, arguments: args) + + for response in cliResponses { + print(response) + do { + let _ = try await TemplatePluginRunner.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: response, + swiftCommandState: swiftCommandState + ) + } + }} + /// Validates the loaded manifest to determine package type. private func checkConditions(_ swiftCommandState: SwiftCommandState, template: String?) async throws -> InitPackage.PackageType { diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index a44e0d68d61..53e7887ed1d 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -192,27 +192,160 @@ public final class InitTemplatePackage { ) } - /// Prompts the user for input based on the given tool information. + /// Prompts the user for input based on the given command definition and arguments. /// - /// This method converts the command arguments of the tool into prompt questions, - /// collects user input, and builds a command line argument array from the responses. + /// This method collects responses for a command's arguments by first validating any user-provided + /// arguments (`arguments`) against the command's defined parameters. Any required arguments that are + /// missing will be interactively prompted from the user. /// - /// - Parameter tool: The tool information containing command and argument metadata. - /// - Returns: An array of strings representing the command line arguments built from user input. - /// - Throws: `TemplateError.noArguments` if the tool command has no arguments. + /// If the command has subcommands, the method will attempt to detect a subcommand from any leftover + /// arguments. If no subcommand is found, the user is interactively prompted to select one. This process + /// is recursive: each subcommand is treated as a new command and processed accordingly. + /// + /// When building each CLI command line, only arguments defined for the current command level are included— + /// inherited arguments from previous levels are excluded to avoid duplication. + /// + /// - Parameters: + /// - command: The top-level or current `CommandInfoV0` to prompt for. + /// - arguments: The list of pre-supplied command-line arguments to match against defined arguments. + /// - subcommandTrail: An internal list of command names to build the final CLI path (used recursively). + /// - inheritedResponses: Argument responses collected from parent commands that should be passed down. + /// + /// - Returns: A list of command line invocations (`[[String]]`), each representing a full CLI command. + /// Each entry includes only arguments relevant to the specific command or subcommand level. + /// + /// - Throws: An error if argument parsing or user prompting fails. - public func promptUser(tool: ToolInfoV0, arguments: [String]) throws -> [String] { - let allArgs = try convertArguments(from: tool.command) + public func promptUser(command: CommandInfoV0, arguments: [String], subcommandTrail: [String] = [], inheritedResponses: [ArgumentResponse] = []) throws -> [[String]] { - let providedResponses = try self.parseAndMatchArguments(arguments, definedArgs: allArgs) + var commandLines = [[String]]() - let missingArgs = self.findMissingArguments(from: allArgs, excluding: providedResponses) + let allArgs = try convertArguments(from: command) + let (providedResponses, leftoverArgs) = try self.parseAndMatchArgumentsWithLeftovers(arguments, definedArgs: allArgs) + let missingArgs = self.findMissingArguments(from: allArgs, excluding: providedResponses) let promptedResponses = UserPrompter.prompt(for: missingArgs) - return self.buildCommandLine(from: providedResponses + promptedResponses) + // Combine all inherited + current-level responses + let allCurrentResponses = inheritedResponses + providedResponses + promptedResponses + + let currentArgNames = Set(allArgs.map { $0.valueName }) + let currentCommandResponses = allCurrentResponses.filter { currentArgNames.contains($0.argument.valueName) } + + let currentArgs = self.buildCommandLine(from: currentCommandResponses) + let fullCommand = subcommandTrail + currentArgs + + commandLines.append(fullCommand) + + + if let subCommands = getSubCommand(from: command) { + // Try to auto-detect a subcommand from leftover args + if let (index, matchedSubcommand) = leftoverArgs + .enumerated() + .compactMap({ (i, token) -> (Int, CommandInfoV0)? in + if let match = subCommands.first(where: { $0.commandName == token }) { + print("Detected subcommand '\(match.commandName)' from user input.") + return (i, match) + } + return nil + }) + .first { + + var newTrail = subcommandTrail + newTrail.append(matchedSubcommand.commandName) + + var newArgs = leftoverArgs + newArgs.remove(at: index) + + let subCommandLines = try self.promptUser( + command: matchedSubcommand, + arguments: newArgs, + subcommandTrail: newTrail, + inheritedResponses: allCurrentResponses + ) + + commandLines.append(contentsOf: subCommandLines) + } else { + // Fall back to interactive prompt + let chosenSubcommand = try self.promptUserForSubcommand(for: subCommands) + + var newTrail = subcommandTrail + newTrail.append(chosenSubcommand.commandName) + + let subCommandLines = try self.promptUser( + command: chosenSubcommand, + arguments: leftoverArgs, + subcommandTrail: newTrail, + inheritedResponses: allCurrentResponses + ) + + commandLines.append(contentsOf: subCommandLines) + } + } + + return commandLines } + /// Prompts the user to select a subcommand from a list of available options. + /// + /// This method prints a list of available subcommands, including their names and brief descriptions. + /// It then interactively prompts the user to enter the name of a subcommand. If the entered name + /// matches one of the available subcommands, that subcommand is returned. Otherwise, the user is + /// repeatedly prompted until a valid subcommand name is provided. + /// + /// - Parameter commands: An array of `CommandInfoV0` representing the available subcommands. + /// + /// - Returns: The `CommandInfoV0` instance corresponding to the subcommand selected by the user. + /// + /// - Throws: This method does not throw directly, but may propagate errors thrown by downstream callers. + + private func promptUserForSubcommand(for commands: [CommandInfoV0]) throws -> CommandInfoV0 { + + print("Available subcommands:\n") + + for command in commands { + print(""" + • \(command.commandName) + Name: \(command.commandName) + About: \(command.abstract ?? "") + + """) + } + + print("Type the name of the subcommand you'd like to use:") + while true { + if let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), !input.isEmpty { + if let match = commands.first(where: { $0.commandName == input }) { + return match + } else { + print("No subcommand found with name '\(input)'. Please try again:") + } + } else { + print("Please enter a valid subcommand name:") + } + } + } + + /// Retrieves the list of subcommands for a given command, excluding common utility commands. + /// + /// This method checks whether the given command contains any subcommands. If so, it filters + /// out the `"help"` subcommand (often auto-generated or reserved), and returns the remaining + /// subcommands. + /// + /// - Parameter command: The `CommandInfoV0` instance representing the current command. + /// + /// - Returns: An array of `CommandInfoV0` representing valid subcommands, or `nil` if no subcommands exist. + private func getSubCommand(from command: CommandInfoV0) -> [CommandInfoV0]? { + guard let subcommands = command.subcommands else { return nil } + + let filteredSubcommands = subcommands.filter { $0.commandName.lowercased() != "help" } + + for sub in filteredSubcommands { + print(sub.commandName) + } + + return filteredSubcommands + } /// Parses predetermined arguments and validates the arguments /// /// This method converts user's predetermined arguments into the ArgumentResponse struct @@ -224,12 +357,13 @@ public final class InitTemplatePackage { /// - Throws: Invalid values if the value is not within all the possible values allowed by the argument /// - Throws: Throws an unexpected argument if the user specifies an argument that does not match any arguments /// defined by the template. - private func parseAndMatchArguments( + private func parseAndMatchArgumentsWithLeftovers( _ input: [String], definedArgs: [ArgumentInfoV0] - ) throws -> [ArgumentResponse] { + ) throws -> ([ArgumentResponse], [String]) { var responses: [ArgumentResponse] = [] var providedMap: [String: [String]] = [:] + var leftover: [String] = [] var index = 0 while index < input.count { @@ -238,7 +372,14 @@ public final class InitTemplatePackage { if token.starts(with: "--") { let name = String(token.dropFirst(2)) guard let arg = definedArgs.first(where: { $0.valueName == name }) else { - throw TemplateError.invalidArgument(name: name) + // Unknown — defer for potential subcommand + leftover.append(token) + index += 1 + if index < input.count && !input[index].starts(with: "--") { + leftover.append(input[index]) + index += 1 + } + continue } switch arg.kind { @@ -254,8 +395,8 @@ public final class InitTemplatePackage { throw TemplateError.unexpectedNamedArgument(name: name) } } else { - // Positional handling - providedMap["__positional", default: []].append(token) + // Positional, include it anyway + leftover.append(token) } index += 1 @@ -282,11 +423,7 @@ public final class InitTemplatePackage { providedMap[name] = nil } - for unexpected in providedMap.keys { - throw TemplateError.unexpectedArgument(name: unexpected) - } - - return responses + return (responses, leftover) } /// Determines the rest of the arguments that need a user's response @@ -379,7 +516,7 @@ public final class InitTemplatePackage { } else if let def = arg.defaultValue { values = [def] } else if arg.isOptional == false { - fatalError("Required argument '\(arg.valueName)' not provided.") + fatalError("Required argument '\(arg.valueName ?? "")' not provided.") } } } @@ -421,15 +558,14 @@ public final class InitTemplatePackage { /// Represents a user's response to an argument prompt. - struct ArgumentResponse { + public struct ArgumentResponse { /// The argument metadata. - let argument: ArgumentInfoV0 - /// The values provided by the user. + /// The values provided by the user. let values: [String] - /// Returns the command line fragments representing this argument and its values. + /// Returns the command line fragments representing this argument and its values. var commandLineFragments: [String] { guard let name = argument.valueName else { return self.values From 7b2e0e345893eb34f48578b0545c59cf51c309bd Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 22 Jul 2025 13:59:06 -0400 Subject: [PATCH 070/225] added validate package, first-class testing support, temporary packages --- Sources/Commands/PackageCommands/Init.swift | 90 +++- Sources/Commands/SwiftTestCommand.swift | 391 +++++++++++++++++- .../_InternalInitSupport/TemplateBuild.swift | 59 ++- Sources/Workspace/InitPackage.swift | 10 +- Sources/Workspace/InitTemplatePackage.swift | 43 +- 5 files changed, 553 insertions(+), 40 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 7ab40e2dbbb..43695e19e27 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -37,7 +37,9 @@ extension SwiftPackageCommand { @Option( name: .customLong("type"), - help: ArgumentHelp("Package type:", discussion: """ + help: ArgumentHelp("Specifies the package type or template.", discussion: """ + Valid values include: + library - A package with a library. executable - A package with an executable. tool - A package with an executable that uses @@ -47,6 +49,9 @@ extension SwiftPackageCommand { command-plugin - A package that vends a command plugin. macro - A package that vends a macro. empty - An empty package with a Package.swift manifest. + - When used with --path, --url, or --package-id, + this resolves to a template from the specified + package or location. """)) var initMode: String? @@ -116,6 +121,10 @@ extension SwiftPackageCommand { @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? + /// Validation step to build package post generation and run if package is of type executable + @Flag(name: .customLong("validate-package"), help: "Run 'swift build' after package generation to validate the template.") + var validatePackage: Bool = false + /// Predetermined arguments specified by the consumer. @Argument( help: "Predetermined arguments to pass to the template." @@ -192,12 +201,23 @@ extension SwiftPackageCommand { packageName: String, cwd: Basics.AbsolutePath ) async throws { - - let template = initMode guard let source = templateSource else { throw ValidationError("No template source specified.") } + let manifest = cwd.appending(component: Manifest.filename) + guard swiftCommandState.fileSystem.exists(manifest) == false else { + throw InitError.manifestAlreadyExists + } + + + let contents = try swiftCommandState.fileSystem.getDirectoryContents(cwd) + + guard contents.isEmpty else { + throw InitError.nonEmptyDirectory(contents) + } + + let template = initMode let requirementResolver = DependencyRequirementResolver( exact: exact, revision: revision, @@ -227,6 +247,17 @@ extension SwiftPackageCommand { throw ValidationError("The specified template path does not exist: \(dir.pathString)") } + // Use a transitive staging directory + let tempDir = try swiftCommandState.fileSystem.tempDirectory.appending(component: UUID().uuidString) + let stagingPackagePath = tempDir.appending(component: "generated-package") + let buildDir = tempDir.appending(component: ".build") + + try swiftCommandState.fileSystem.createDirectory(tempDir) + defer { + try? swiftCommandState.fileSystem.removeFileTree(tempDir) + } + + // Determine the type by loading the resolved template let templateInitType = try await swiftCommandState .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in try await self.checkConditions(swiftCommandState, template: template) @@ -264,20 +295,29 @@ extension SwiftPackageCommand { fileSystem: swiftCommandState.fileSystem, packageType: templateInitType, supportedTestingLibraries: supportedTemplateTestingLibraries, - destinationPath: cwd, + destinationPath: stagingPackagePath, installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration ) + try swiftCommandState.fileSystem.createDirectory(stagingPackagePath, recursive: true) + try initTemplatePackage.setupTemplateManifest() + // Build once inside the transitive folder try await TemplateBuildSupport.build( swiftCommandState: swiftCommandState, buildOptions: self.buildOptions, globalOptions: self.globalOptions, - cwd: cwd + cwd: stagingPackagePath, + transitiveFolder: stagingPackagePath ) - let packageGraph = try await swiftCommandState.loadPackageGraph() + let packageGraph = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: stagingPackagePath) { _, _ in + try await swiftCommandState.loadPackageGraph() + } + + let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) guard let commandPlugin = matchingPlugins.first else { @@ -311,17 +351,33 @@ extension SwiftPackageCommand { let cliResponses = try initTemplatePackage.promptUser(command: toolInfo.command, arguments: args) for response in cliResponses { - print(response) - do { - let _ = try await TemplatePluginRunner.run( - plugin: matchingPlugins[0], - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: response, - swiftCommandState: swiftCommandState - ) - } - }} + _ = try await TemplatePluginRunner.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: response, + swiftCommandState: swiftCommandState + ) + } + + // Move finalized package to target cwd + if swiftCommandState.fileSystem.exists(cwd) { + try swiftCommandState.fileSystem.removeFileTree(cwd) + } + try swiftCommandState.fileSystem.copy(from: stagingPackagePath, to: cwd) + + // Restore cwd for build + + if validatePackage { + try await TemplateBuildSupport.build( + swiftCommandState: swiftCommandState, + buildOptions: self.buildOptions, + globalOptions: self.globalOptions, + cwd: cwd + ) + } + } + /// Validates the loaded manifest to determine package type. diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index a5b1e2cd919..5cdb3577d8f 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -11,6 +11,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser +import ArgumentParserToolInfo @_spi(SwiftPMInternal) import Basics @@ -261,7 +262,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand { discussion: "SEE ALSO: swift build, swift run, swift package", version: SwiftVersion.current.completeDisplayString, subcommands: [ - List.self, Last.self + List.self, Last.self, Template.self ], helpNames: [.short, .long, .customLong("help", withSingleDash: true)]) @@ -709,6 +710,143 @@ extension SwiftTestCommand { } } +final class ArgumentTreeNode { + let command: CommandInfoV0 + var children: [ArgumentTreeNode] = [] + + var arguments: [String: InitTemplatePackage.ArgumentResponse] = [:] + + init(command: CommandInfoV0) { + self.command = command + } + + static func build(from command: CommandInfoV0) -> ArgumentTreeNode { + let node = ArgumentTreeNode(command: command) + if let subcommands = command.subcommands { + node.children = subcommands.map { build(from: $0) } + } + return node + } + + func collectUniqueArguments() -> [String: ArgumentInfoV0] { + var dict: [String: ArgumentInfoV0] = [:] + if let args = command.arguments { + for arg in args { + let key = arg.preferredName?.name ?? arg.valueName ?? UUID().uuidString + dict[key] = arg + } + } + for child in children { + let childDict = child.collectUniqueArguments() + for (key, arg) in childDict { + dict[key] = arg + } + } + return dict + } + + + static func promptForUniqueArguments( + uniqueArguments: [String: ArgumentInfoV0] + ) -> [String: InitTemplatePackage.ArgumentResponse] { + var collected: [String: InitTemplatePackage.ArgumentResponse] = [:] + let argsToPrompt = Array(uniqueArguments.values) + + // Prompt for all unique arguments at once + _ = InitTemplatePackage.UserPrompter.prompt(for: argsToPrompt, collected: &collected) + + return collected + } + + //Fill node arguments by assigning the prompted values for keys it requires + func fillArguments(with responses: [String: InitTemplatePackage.ArgumentResponse]) { + if let args = command.arguments { + for arg in args { + if let resp = responses[arg.valueName ?? ""] { + arguments[arg.valueName ?? ""] = resp + } + } + } + // Recurse + for child in children { + child.fillArguments(with: responses) + } + } + + func printTree(level: Int = 0) { + let indent = String(repeating: " ", count: level) + print("\(indent)- Command: \(command.commandName)") + for (key, response) in arguments { + print("\(indent) - \(key): \(response.values)") + } + for child in children { + child.printTree(level: level + 1) + } + } + + func createCLITree(root: ArgumentTreeNode) -> [[ArgumentTreeNode]] { + // Base case: If it's a leaf node, return a path with only itself + if root.children.isEmpty { + return [[root]] + } + + var result: [[ArgumentTreeNode]] = [] + + // Recurse into children and prepend the current root to each path + for child in root.children { + let childPaths = createCLITree(root: child) + for path in childPaths { + result.append([root] + path) + } + } + + return result + } +} + +extension ArgumentTreeNode { + /// Traverses all command paths and returns CLI paths along with their arguments + func collectCommandPaths( + currentPath: [String] = [], + currentArguments: [String: InitTemplatePackage.ArgumentResponse] = [:] + ) -> [([String], [String: InitTemplatePackage.ArgumentResponse])] { + var newPath = currentPath + [command.commandName] + + var combinedArguments = currentArguments + for (key, value) in arguments { + combinedArguments[key] = value + } + + if children.isEmpty { + return [(newPath, combinedArguments)] + } + + var results: [([String], [String: InitTemplatePackage.ArgumentResponse])] = [] + for child in children { + results += child.collectCommandPaths( + currentPath: newPath, + currentArguments: combinedArguments + ) + } + + return results + } +} + +extension DispatchTimeInterval { + var seconds: TimeInterval { + switch self { + case .seconds(let s): return TimeInterval(s) + case .milliseconds(let ms): return TimeInterval(Double(ms) / 1000) + case .microseconds(let us): return TimeInterval(Double(us) / 1_000_000) + case .nanoseconds(let ns): return TimeInterval(Double(ns) / 1_000_000_000) + case .never: return 0 + @unknown default: return 0 + } + } +} + + extension SwiftTestCommand { struct Last: SwiftCommand { @OptionGroup(visibility: .hidden) @@ -722,6 +860,257 @@ extension SwiftTestCommand { } } + struct Template: AsyncSwiftCommand { + static let configuration = CommandConfiguration( + abstract: "Test the various outputs of a template" + ) + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + @OptionGroup() + var sharedOptions: SharedOptions + + @Option(help: "Specify name of the template") + var templateName: String? + + @Option( + name: .customLong("output-path"), + help: "Specify the output path of the created templates.", + completion: .directory + ) + public var outputDirectory: AbsolutePath + + @OptionGroup(visibility: .hidden) + var buildOptions: BuildCommandOptions + + /// Predetermined arguments specified by the consumer. + @Argument( + help: "Predetermined arguments to pass to the template." + ) + var args: [String] = [] + + @Flag(help: "Dry-run to display argument tree") + var dryRun: Bool = false + + + func run(_ swiftCommandState: SwiftCommandState) async throws { + let manifest = outputDirectory.appending(component: Manifest.filename) + let fileSystem = swiftCommandState.fileSystem + let directoryExists = fileSystem.exists(outputDirectory) + + if !directoryExists { + try FileManager.default.createDirectory( + at: outputDirectory.asURL, + withIntermediateDirectories: true + ) + } else { + if fileSystem.exists(manifest) { + throw ValidationError("Package.swift was found in \(outputDirectory).") + } + } + + // Load Package Graph + let packageGraph = try await swiftCommandState.loadPackageGraph() + + // Find matching plugin + let matchingPlugins = PluginCommand.findPlugins(matching: templateName, in: packageGraph, limitedTo: nil) + guard let commandPlugin = matchingPlugins.first else { + throw ValidationError("No templates were found that match the name \(templateName ?? "")") + } + + guard matchingPlugins.count == 1 else { + let templateNames = matchingPlugins.compactMap { module in + let plugin = module.underlying as! PluginModule + guard case .command(let intent, _) = plugin.capability else { return String?.none } + + return intent.invocationVerb + } + throw ValidationError( + "More than one template was found in the package. Please use `--type` along with one of the available templates: \(templateNames.joined(separator: ", "))" + ) + } + + let output = try await TemplatePluginRunner.run( + plugin: commandPlugin, + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: ["--", "--experimental-dump-help"], + swiftCommandState: swiftCommandState + ) + + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + let root = ArgumentTreeNode.build(from: toolInfo.command) + + let uniqueArguments = root.collectUniqueArguments() + let responses = ArgumentTreeNode.promptForUniqueArguments(uniqueArguments: uniqueArguments) + root.fillArguments(with: responses) + + if dryRun { + root.printTree() + return + } + + let cliArgumentPaths = root.createCLITree(root: root) // [[ArgumentTreeNode]] + let commandPaths = root.collectCommandPaths() + + func checkConditions(_ swiftCommandState: SwiftCommandState, template: String?) async throws -> InitPackage.PackageType { + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() + + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope + ) + guard let rootManifest = rootManifests.values.first else { + throw InternalError("Invalid manifests at \(root.packages)") + } + + let targets = rootManifest.targets + for target in targets { + if template == nil || target.name == template, + let options = target.templateInitializationOptions, + case .packageInit(let templateType, _, _) = options { + return try .init(from: templateType) + } + } + + throw ValidationError("Could not find \(template != nil ? "template \(template!)" : "any templates")") + } + + let initialPackageType: InitPackage.PackageType = try await checkConditions(swiftCommandState, template: templateName) + + var buildMatrix: [String: BuildInfo] = [:] + + for path in cliArgumentPaths { + let commandNames = path.map { $0.command.commandName } + let folderName = commandNames.joined(separator: "-") + let destinationAbsolutePath = outputDirectory.appending(component: folderName) + let destinationURL = destinationAbsolutePath.asURL + + print("\n Generating for: \(folderName)") + try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + let initTemplatePackage = try InitTemplatePackage( + name: folderName, + initMode: .fileSystem(name: templateName, path: swiftCommandState.originalWorkingDirectory.pathString), + templatePath: swiftCommandState.originalWorkingDirectory, + fileSystem: swiftCommandState.fileSystem, + packageType: initialPackageType, + supportedTestingLibraries: [], + destinationPath: destinationAbsolutePath, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration + ) + + try initTemplatePackage.setupTemplateManifest() + + + let generatedGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in + try await swiftCommandState.loadPackageGraph() + } + + try await TemplateBuildSupport.buildForTesting( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + testingFolder: destinationAbsolutePath + ) + + for (index, node) in path.enumerated() { + let currentPath = index == 0 ? [] : path[1...index].map { $0.command.commandName } + let currentArgs = node.arguments.values.flatMap { $0.commandLineFragments } + let fullCommand = currentPath + currentArgs + let commandString = fullCommand.joined(separator: " ") + + print("Running: \(commandString)") + + do { + try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath, perform: {_,_ in print(String(data: try await TemplatePluginRunner.run( + plugin: commandPlugin, + package: generatedGraph.rootPackages.first!, + packageGraph: generatedGraph, + arguments: fullCommand, + swiftCommandState: swiftCommandState + ), encoding: .utf8) ?? "") + + }) + + print("Success: \(commandString)") + } catch { + print("Failed: \(commandString) — error: \(error)") + } + } + + //Test for building + + let buildInfo = await buildGeneratedTemplate(swiftCommandState: swiftCommandState, buildOptions: buildOptions, testingFolder: destinationAbsolutePath) + buildMatrix[folderName] = buildInfo + + + } + + printBuildMatrix(buildMatrix) + + func printBuildMatrix(_ matrix: [String: BuildInfo]) { + // Print header with manual padding + let header = [ + "Argument Branch".padding(toLength: 30, withPad: " ", startingAt: 0), + "Success".padding(toLength: 10, withPad: " ", startingAt: 0), + "Time(s)".padding(toLength: 10, withPad: " ", startingAt: 0), + "Error" + ] + print(header.joined(separator: " ")) + + for (folder, info) in matrix { + let duration = String(format: "%.2f", info.duration.seconds) + let status = info.success ? "true" : "false" + let errorText = info.error?.localizedDescription ?? "-" + + let row = [ + folder.padding(toLength: 30, withPad: " ", startingAt: 0), + status.padding(toLength: 10, withPad: " ", startingAt: 0), + duration.padding(toLength: 10, withPad: " ", startingAt: 0), + errorText + ] + print(row.joined(separator: " ")) + } + } + } + + + struct BuildInfo { + var startTime: DispatchTime + var duration: DispatchTimeInterval + var success: Bool + var error: Error? + } + + private func buildGeneratedTemplate(swiftCommandState: SwiftCommandState, buildOptions: BuildCommandOptions, testingFolder: AbsolutePath) async -> BuildInfo { + var buildInfo: BuildInfo + + let start = DispatchTime.now() + + do { + try await + TemplateBuildSupport.buildForTesting( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + testingFolder: testingFolder + ) + + let duration = start.distance(to: .now()) + buildInfo = BuildInfo(startTime: start, duration: duration, success: true, error: nil) + } catch { + let duration = start.distance(to: .now()) + + buildInfo = BuildInfo(startTime: start, duration: duration, success: false, error: error) + } + + return buildInfo + } + } + + + struct List: AsyncSwiftCommand { static let configuration = CommandConfiguration( abstract: "Lists test methods in specifier format" diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift index e8b8c4212d9..043a89dd856 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -46,10 +46,14 @@ enum TemplateBuildSupport { swiftCommandState: SwiftCommandState, buildOptions: BuildCommandOptions, globalOptions: GlobalOptions, - cwd: Basics.AbsolutePath + cwd: Basics.AbsolutePath, + transitiveFolder: Basics.AbsolutePath? = nil ) async throws { + + let buildSystem = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + .withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in + try await swiftCommandState.createBuildSystem( explicitProduct: buildOptions.product, traitConfiguration: .init(traitOptions: buildOptions.traits), @@ -64,8 +68,10 @@ enum TemplateBuildSupport { throw ExitCode.failure } + + try await swiftCommandState - .withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + .withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in do { try await buildSystem.build(subset: subset) } catch _ as Diagnostics { @@ -73,4 +79,51 @@ enum TemplateBuildSupport { } } } + + static func buildForTesting( + swiftCommandState: SwiftCommandState, + buildOptions: BuildCommandOptions, + testingFolder: Basics.AbsolutePath + ) async throws { + + var productsBuildParameters = try swiftCommandState.productsBuildParameters + var toolsBuildParameters = try swiftCommandState.toolsBuildParameters + + if buildOptions.enableCodeCoverage { + productsBuildParameters.testingParameters.enableCodeCoverage = true + toolsBuildParameters.testingParameters.enableCodeCoverage = true + } + + if buildOptions.printPIFManifestGraphviz { + productsBuildParameters.printPIFManifestGraphviz = true + toolsBuildParameters.printPIFManifestGraphviz = true + } + + + let buildSystem = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: testingFolder) { _, _ in + try await swiftCommandState.createBuildSystem( + explicitProduct: buildOptions.product, + traitConfiguration: .init(traitOptions: buildOptions.traits), + shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: swiftCommandState.toolsBuildParameters, + outputStream: TSCBasic.stdoutStream + ) + } + + guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { + throw ExitCode.failure + } + + try await swiftCommandState + .withTemporaryWorkspace(switchingTo: testingFolder) { _, _ in + do { + try await buildSystem.build(subset: subset) + } catch _ as Diagnostics { + throw ExitCode.failure + } + } + } + } diff --git a/Sources/Workspace/InitPackage.swift b/Sources/Workspace/InitPackage.swift index 43d461be6f5..14c7d447352 100644 --- a/Sources/Workspace/InitPackage.swift +++ b/Sources/Workspace/InitPackage.swift @@ -20,7 +20,8 @@ import protocol TSCBasic.OutputByteStream /// Create an initial template package. public final class InitPackage { /// The tool version to be used for new packages. - public static let newPackageToolsVersion = ToolsVersion.current + public static let newPackageToolsVersion = ToolsVersion.v6_1 //TODO: JOHN CHANGE ME BACK TO: + // - public static let newPackageToolsVersion = ToolsVersion.current /// Options for the template package. public struct InitPackageOptions { @@ -883,18 +884,21 @@ public final class InitPackage { // Private helpers -private enum InitError: Swift.Error { +public enum InitError: Swift.Error { case manifestAlreadyExists case unsupportedTestingLibraryForPackageType(_ testingLibrary: TestingLibrary, _ packageType: InitPackage.PackageType) + case nonEmptyDirectory(_ content: [String]) } extension InitError: CustomStringConvertible { - var description: String { + public var description: String { switch self { case .manifestAlreadyExists: return "a manifest file already exists in this directory" case let .unsupportedTestingLibraryForPackageType(library, packageType): return "\(library) cannot be used when initializing a \(packageType) package" + case let .nonEmptyDirectory(content): + return "directory is not empty: \(content.joined(separator: ", "))" } } } diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 53e7887ed1d..8c4898dec6e 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -224,7 +224,9 @@ public final class InitTemplatePackage { let (providedResponses, leftoverArgs) = try self.parseAndMatchArgumentsWithLeftovers(arguments, definedArgs: allArgs) let missingArgs = self.findMissingArguments(from: allArgs, excluding: providedResponses) - let promptedResponses = UserPrompter.prompt(for: missingArgs) + + var collectedResponses: [String: ArgumentResponse] = [:] + let promptedResponses = UserPrompter.prompt(for: missingArgs, collected: &collectedResponses) // Combine all inherited + current-level responses let allCurrentResponses = inheritedResponses + providedResponses + promptedResponses @@ -462,19 +464,29 @@ public final class InitTemplatePackage { /// A helper struct to prompt the user for input values for command arguments. - private enum UserPrompter { + public enum UserPrompter { /// Prompts the user for input for each argument, handling flags, options, and positional arguments. /// /// - Parameter arguments: The list of argument metadata to prompt for. /// - Returns: An array of `ArgumentResponse` representing the user's input. - static func prompt(for arguments: [ArgumentInfoV0]) -> [ArgumentResponse] { - arguments - .filter { $0.valueName != "help" && $0.shouldDisplay != false } - .map { arg in + public static func prompt( + for arguments: [ArgumentInfoV0], + collected: inout [String: ArgumentResponse] + ) -> [ArgumentResponse] { + return arguments + .filter { $0.valueName != "help" && $0.shouldDisplay } + .compactMap { arg in + let key = arg.preferredName?.name ?? arg.valueName ?? UUID().uuidString + + if let existing = collected[key] { + print("Using previous value for '\(key)': \(existing.values.joined(separator: ", "))") + return existing + } + let defaultText = arg.defaultValue.map { " (default: \($0))" } ?? "" let allValuesText = (arg.allValues?.isEmpty == false) ? - " [\(arg.allValues!.joined(separator: ", "))]" : "" + " [\(arg.allValues!.joined(separator: ", "))]" : "" let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(defaultText):" var values: [String] = [] @@ -493,9 +505,7 @@ public final class InitTemplatePackage { if arg.isRepeating { while let input = readLine(), !input.isEmpty { if let allowed = arg.allValues, !allowed.contains(input) { - print( - "Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))" - ) + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") continue } values.append(input) @@ -507,9 +517,7 @@ public final class InitTemplatePackage { let input = readLine() if let input, !input.isEmpty { if let allowed = arg.allValues, !allowed.contains(input) { - print( - "Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))" - ) + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") exit(1) } values = [input] @@ -521,8 +529,11 @@ public final class InitTemplatePackage { } } - return ArgumentResponse(argument: arg, values: values) + let response = ArgumentResponse(argument: arg, values: values) + collected[key] = response + return response } + } } @@ -563,10 +574,10 @@ public final class InitTemplatePackage { let argument: ArgumentInfoV0 /// The values provided by the user. - let values: [String] + public let values: [String] /// Returns the command line fragments representing this argument and its values. - var commandLineFragments: [String] { + public var commandLineFragments: [String] { guard let name = argument.valueName else { return self.values } From 87503186af341bf205826e4e81715f9a7e98d804 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 23 Jul 2025 14:23:14 -0400 Subject: [PATCH 071/225] added testing logs + revamped testing system for templates --- Sources/Commands/SwiftTestCommand.swift | 305 +++++++++++++----- .../Commands/Utilities/PluginDelegate.swift | 4 + .../TemplatePluginRunner.swift | 14 +- 3 files changed, 245 insertions(+), 78 deletions(-) diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index 5cdb3577d8f..228d8c91391 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -914,9 +914,9 @@ extension SwiftTestCommand { let packageGraph = try await swiftCommandState.loadPackageGraph() // Find matching plugin - let matchingPlugins = PluginCommand.findPlugins(matching: templateName, in: packageGraph, limitedTo: nil) + let matchingPlugins = PluginCommand.findPlugins(matching: self.templateName, in: packageGraph, limitedTo: nil) guard let commandPlugin = matchingPlugins.first else { - throw ValidationError("No templates were found that match the name \(templateName ?? "")") + throw ValidationError("No templates were found that match the name \(self.templateName ?? "")") } guard matchingPlugins.count == 1 else { @@ -951,7 +951,7 @@ extension SwiftTestCommand { return } - let cliArgumentPaths = root.createCLITree(root: root) // [[ArgumentTreeNode]] + let cliArgumentPaths = root.createCLITree(root: root) let commandPaths = root.collectCommandPaths() func checkConditions(_ swiftCommandState: SwiftCommandState, template: String?) async throws -> InitPackage.PackageType { @@ -988,11 +988,98 @@ extension SwiftTestCommand { let destinationAbsolutePath = outputDirectory.appending(component: folderName) let destinationURL = destinationAbsolutePath.asURL - print("\n Generating for: \(folderName)") + print("\nGenerating \(folderName)") try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + let buildInfo = try await testTemplateIntialization( + commandPlugin: commandPlugin, + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + destinationAbsolutePath: destinationAbsolutePath, + testingFolderName: folderName, + argumentPath: path, + initialPackageType: initialPackageType + ) + + buildMatrix[folderName] = buildInfo + + + } + + printBuildMatrix(buildMatrix) + + func printBuildMatrix(_ matrix: [String: BuildInfo]) { + let header = [ + "Argument Branch".padding(toLength: 30, withPad: " ", startingAt: 0), + "Gen Success".padding(toLength: 12, withPad: " ", startingAt: 0), + "Gen Time(s)".padding(toLength: 12, withPad: " ", startingAt: 0), + "Build Success".padding(toLength: 14, withPad: " ", startingAt: 0), + "Build Time(s)".padding(toLength: 14, withPad: " ", startingAt: 0), + "Log File" + ] + print(header.joined(separator: " ")) + + for (folder, info) in matrix { + let row = [ + folder.padding(toLength: 30, withPad: " ", startingAt: 0), + String(info.generationSuccess).padding(toLength: 12, withPad: " ", startingAt: 0), + String(format: "%.2f", info.generationDuration.seconds).padding(toLength: 12, withPad: " ", startingAt: 0), + String(info.buildSuccess).padding(toLength: 14, withPad: " ", startingAt: 0), + String(format: "%.2f", info.buildDuration.seconds).padding(toLength: 14, withPad: " ", startingAt: 0), + info.logFilePath ?? "-" + ] + print(row.joined(separator: " ")) + } + } + } + + struct BuildInfo { + var generationDuration: DispatchTimeInterval + var buildDuration: DispatchTimeInterval + var generationSuccess: Bool + var buildSuccess: Bool + var generationError: Error? + var buildError: Error? + var logFilePath: String? + } + + private func testTemplateIntialization( + commandPlugin: ResolvedModule, + swiftCommandState: SwiftCommandState, + buildOptions: BuildCommandOptions, + destinationAbsolutePath: AbsolutePath, + testingFolderName: String, + argumentPath: [ArgumentTreeNode], + initialPackageType: InitPackage.PackageType + ) async throws -> BuildInfo { + + let generationStart = DispatchTime.now() + var generationDuration: DispatchTimeInterval = .never + var buildDuration: DispatchTimeInterval = .never + var generationSuccess = false + var buildSuccess = false + var generationError: Error? + var buildError: Error? + var logFilePath: String? = nil + + + var pluginOutput: String = "" + + do { + + let logPath = destinationAbsolutePath.appending("generation-output.log").pathString + + // Redirect stdout/stderr to file before starting generation + let (origOut, origErr) = try redirectStdoutAndStderr(to: logPath) + + defer { + restoreStdoutAndStderr(originalStdout: origOut, originalStderr: origErr) + } + + let initTemplatePackage = try InitTemplatePackage( - name: folderName, + name: testingFolderName, initMode: .fileSystem(name: templateName, path: swiftCommandState.originalWorkingDirectory.pathString), templatePath: swiftCommandState.originalWorkingDirectory, fileSystem: swiftCommandState.fileSystem, @@ -1004,113 +1091,179 @@ extension SwiftTestCommand { try initTemplatePackage.setupTemplateManifest() - let generatedGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in try await swiftCommandState.loadPackageGraph() } try await TemplateBuildSupport.buildForTesting( - swiftCommandState: swiftCommandState, - buildOptions: buildOptions, - testingFolder: destinationAbsolutePath + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + testingFolder: destinationAbsolutePath ) - for (index, node) in path.enumerated() { - let currentPath = index == 0 ? [] : path[1...index].map { $0.command.commandName } + for (index, node) in argumentPath.enumerated() { + let currentPath = index == 0 ? [] : argumentPath[1...index].map { $0.command.commandName } let currentArgs = node.arguments.values.flatMap { $0.commandLineFragments } let fullCommand = currentPath + currentArgs - let commandString = fullCommand.joined(separator: " ") - - print("Running: \(commandString)") - do { - try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath, perform: {_,_ in print(String(data: try await TemplatePluginRunner.run( - plugin: commandPlugin, - package: generatedGraph.rootPackages.first!, - packageGraph: generatedGraph, - arguments: fullCommand, - swiftCommandState: swiftCommandState - ), encoding: .utf8) ?? "") + try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in + do { + let outputData = try await TemplatePluginRunner.run( + plugin: commandPlugin, + package: generatedGraph.rootPackages.first!, + packageGraph: generatedGraph, + arguments: fullCommand, + swiftCommandState: swiftCommandState + ) + pluginOutput = String(data: outputData, encoding: .utf8) ?? "[Invalid UTF-8 output]" + print(pluginOutput) + } + } + } - }) + generationDuration = generationStart.distance(to: .now()) + generationSuccess = true - print("Success: \(commandString)") - } catch { - print("Failed: \(commandString) — error: \(error)") - } + if generationSuccess { + try FileManager.default.removeItem(atPath: logPath) } - //Test for building + } catch { + generationDuration = generationStart.distance(to: .now()) + generationError = error + generationSuccess = false - let buildInfo = await buildGeneratedTemplate(swiftCommandState: swiftCommandState, buildOptions: buildOptions, testingFolder: destinationAbsolutePath) - buildMatrix[folderName] = buildInfo - + let logPath = destinationAbsolutePath.appending("generation-output.log") + let outputPath = logPath.pathString + let capturedOutput = (try? String(contentsOfFile: outputPath)) ?? "" + + let unifiedLog = """ + Error: + -------------------------------- + \(error.localizedDescription) + + Plugin Output (before failure): + -------------------------------- + \(capturedOutput) + """ + + try? unifiedLog.write(to: logPath.asURL, atomically: true, encoding: .utf8) + logFilePath = logPath.pathString + } + // Only start the build step if generation was successful + if generationSuccess { + let buildStart = DispatchTime.now() + do { - printBuildMatrix(buildMatrix) + let logPath = destinationAbsolutePath.appending("build-output.log").pathString - func printBuildMatrix(_ matrix: [String: BuildInfo]) { - // Print header with manual padding - let header = [ - "Argument Branch".padding(toLength: 30, withPad: " ", startingAt: 0), - "Success".padding(toLength: 10, withPad: " ", startingAt: 0), - "Time(s)".padding(toLength: 10, withPad: " ", startingAt: 0), - "Error" - ] - print(header.joined(separator: " ")) + // Redirect stdout/stderr to file before starting build + let (origOut, origErr) = try redirectStdoutAndStderr(to: logPath) - for (folder, info) in matrix { - let duration = String(format: "%.2f", info.duration.seconds) - let status = info.success ? "true" : "false" - let errorText = info.error?.localizedDescription ?? "-" + defer { + restoreStdoutAndStderr(originalStdout: origOut, originalStderr: origErr) + } + + + try await TemplateBuildSupport.buildForTesting( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + testingFolder: destinationAbsolutePath + ) + + buildDuration = buildStart.distance(to: .now()) + buildSuccess = true + + + if buildSuccess { + try FileManager.default.removeItem(atPath: logPath) + } + + + } catch { + buildDuration = buildStart.distance(to: .now()) + buildError = error + buildSuccess = false + + let logPath = destinationAbsolutePath.appending("build-output.log") + let outputPath = logPath.pathString + let capturedOutput = (try? String(contentsOfFile: outputPath)) ?? "" + + let unifiedLog = """ + Error: + -------------------------------- + \(error.localizedDescription) + + Build Output (before failure): + -------------------------------- + \(capturedOutput) + """ + + try? unifiedLog.write(to: logPath.asURL, atomically: true, encoding: .utf8) + logFilePath = logPath.pathString - let row = [ - folder.padding(toLength: 30, withPad: " ", startingAt: 0), - status.padding(toLength: 10, withPad: " ", startingAt: 0), - duration.padding(toLength: 10, withPad: " ", startingAt: 0), - errorText - ] - print(row.joined(separator: " ")) } } + + + return BuildInfo( + generationDuration: generationDuration, + buildDuration: buildDuration, + generationSuccess: generationSuccess, + buildSuccess: buildSuccess, + generationError: generationError, + buildError: buildError, + logFilePath: logFilePath + ) } - - struct BuildInfo { - var startTime: DispatchTime - var duration: DispatchTimeInterval - var success: Bool - var error: Error? + func writeLogToFile(_ content: String, to directory: AbsolutePath, named fileName: String) throws { + let fileURL = URL(fileURLWithPath: directory.pathString).appendingPathComponent(fileName) + try content.write(to: fileURL, atomically: true, encoding: .utf8) } - private func buildGeneratedTemplate(swiftCommandState: SwiftCommandState, buildOptions: BuildCommandOptions, testingFolder: AbsolutePath) async -> BuildInfo { - var buildInfo: BuildInfo + func redirectStdoutAndStderr(to path: String) throws -> (originalStdout: Int32, originalStderr: Int32) { + // Open file for writing (create/truncate) + guard let file = fopen(path, "w") else { + throw NSError(domain: "RedirectError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot open file for writing"]) + } - let start = DispatchTime.now() + let originalStdout = dup(STDOUT_FILENO) + let originalStderr = dup(STDERR_FILENO) - do { - try await - TemplateBuildSupport.buildForTesting( - swiftCommandState: swiftCommandState, - buildOptions: buildOptions, - testingFolder: testingFolder - ) + dup2(fileno(file), STDOUT_FILENO) + dup2(fileno(file), STDERR_FILENO) - let duration = start.distance(to: .now()) - buildInfo = BuildInfo(startTime: start, duration: duration, success: true, error: nil) - } catch { - let duration = start.distance(to: .now()) + fclose(file) + + return (originalStdout, originalStderr) + } + + func restoreStdoutAndStderr(originalStdout: Int32, originalStderr: Int32) { + fflush(stdout) + fflush(stderr) - buildInfo = BuildInfo(startTime: start, duration: duration, success: false, error: error) + if dup2(originalStdout, STDOUT_FILENO) == -1 { + perror("dup2 stdout restore failed") } + if dup2(originalStderr, STDERR_FILENO) == -1 { + perror("dup2 stderr restore failed") + } + + fflush(stdout) + fflush(stderr) - return buildInfo + if close(originalStdout) == -1 { + perror("close originalStdout failed") + } + if close(originalStderr) == -1 { + perror("close originalStderr failed") + } } } - - struct List: AsyncSwiftCommand { static let configuration = CommandConfiguration( abstract: "Lists test methods in specifier format" diff --git a/Sources/Commands/Utilities/PluginDelegate.swift b/Sources/Commands/Utilities/PluginDelegate.swift index f14861050c9..e2a063fe290 100644 --- a/Sources/Commands/Utilities/PluginDelegate.swift +++ b/Sources/Commands/Utilities/PluginDelegate.swift @@ -28,6 +28,7 @@ final class PluginDelegate: PluginInvocationDelegate { let plugin: PluginModule var lineBufferedOutput: Data let echoOutput: Bool + var diagnostics: [Basics.Diagnostic] = [] init(swiftCommandState: SwiftCommandState, plugin: PluginModule, echoOutput: Bool = true) { self.swiftCommandState = swiftCommandState @@ -59,6 +60,9 @@ final class PluginDelegate: PluginInvocationDelegate { func pluginEmittedDiagnostic(_ diagnostic: Basics.Diagnostic) { swiftCommandState.observabilityScope.emit(diagnostic) + if diagnostic.severity == .error { + diagnostics.append(diagnostic) + } } func pluginEmittedProgress(_ message: String) { diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift index 891418903c9..6bd4329d261 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift @@ -123,7 +123,7 @@ enum TemplatePluginRunner { ?? swiftCommandState.fileSystem.currentWorkingDirectory ?? { throw InternalError("Could not determine working directory") }() - let _ = try await pluginTarget.invoke( + let success = try await pluginTarget.invoke( action: .performCommand(package: package, arguments: arguments), buildEnvironment: buildParams.buildEnvironment, scriptRunner: pluginScriptRunner, @@ -142,7 +142,17 @@ enum TemplatePluginRunner { callbackQueue: DispatchQueue(label: "plugin-invocation"), delegate: delegate ) - + + guard success else { + let stringError = delegate.diagnostics + .map { $0.message } + .joined(separator: "\n") + + throw DefaultPluginScriptRunnerError.invocationFailed( + error: StringError(stringError), + command: arguments + ) + } return delegate.lineBufferedOutput } From 82132156ab3a42611517f7f414fdbe3f93e8482a Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 23 Jul 2025 14:23:33 -0400 Subject: [PATCH 072/225] reverted package-metadata changes --- Sources/PackageMetadata/PackageMetadata.swift | 25 +------ Sources/PackageRegistry/RegistryClient.swift | 66 +------------------ 2 files changed, 2 insertions(+), 89 deletions(-) diff --git a/Sources/PackageMetadata/PackageMetadata.swift b/Sources/PackageMetadata/PackageMetadata.swift index 4729caefcac..12d5833c4de 100644 --- a/Sources/PackageMetadata/PackageMetadata.swift +++ b/Sources/PackageMetadata/PackageMetadata.swift @@ -88,7 +88,6 @@ public struct Package { public let publishedAt: Date? public let signingEntity: SigningEntity? public let latestVersion: Version? - public let templates: [Template]? fileprivate init( identity: PackageIdentity, @@ -105,7 +104,6 @@ public struct Package { signingEntity: SigningEntity? = nil, latestVersion: Version? = nil, source: Source, - templates: [Template]? = nil ) { self.identity = identity self.location = location @@ -121,7 +119,6 @@ public struct Package { self.signingEntity = signingEntity self.latestVersion = latestVersion self.source = source - self.templates = templates } } @@ -181,7 +178,6 @@ public struct PackageSearchClient { public let description: String? public let publishedAt: Date? public let signingEntity: SigningEntity? - public let templates: [Package.Template]? } private func getVersionMetadata( @@ -203,8 +199,7 @@ public struct PackageSearchClient { author: metadata.author.map { .init($0) }, description: metadata.description, publishedAt: metadata.publishedAt, - signingEntity: metadata.sourceArchive?.signingEntity, - templates: metadata.templates?.map { .init($0) } + signingEntity: metadata.sourceArchive?.signingEntity ) } @@ -443,24 +438,6 @@ extension Package.Organization { } } -extension Package.Template { - fileprivate init(_ template: RegistryClient.PackageVersionMetadata.Template) { - self.init( - name: template.name, description: template.description, arguments: template.arguments?.map { .init($0) } - ) - } -} - -extension Package.TemplateArguments { - fileprivate init(_ argument: RegistryClient.PackageVersionMetadata.TemplateArguments) { - self.init( - name: argument.name, - description: argument.description, - isRequired: argument.isRequired - ) - } -} - extension SigningEntity { fileprivate init(signer: PackageCollectionsModel.Signer) { // All package collection signers are "recognized" diff --git a/Sources/PackageRegistry/RegistryClient.swift b/Sources/PackageRegistry/RegistryClient.swift index f46e95b0132..150ff4faaa7 100644 --- a/Sources/PackageRegistry/RegistryClient.swift +++ b/Sources/PackageRegistry/RegistryClient.swift @@ -363,20 +363,7 @@ public final class RegistryClient: AsyncCancellable { ) }, description: versionMetadata.metadata?.description, - publishedAt: versionMetadata.metadata?.originalPublicationTime ?? versionMetadata.publishedAt, - templates: versionMetadata.metadata?.templates?.compactMap { template in - PackageVersionMetadata.Template( - name: template.name, - description: template.description, - arguments: template.arguments?.map { arg in - PackageVersionMetadata.TemplateArguments( - name: arg.name, - description: arg.description, - isRequired: arg.isRequired - ) - } - ) - } + publishedAt: versionMetadata.metadata?.originalPublicationTime ?? versionMetadata.publishedAt ) return packageVersionMetadata @@ -1737,45 +1724,11 @@ extension RegistryClient { public let author: Author? public let description: String? public let publishedAt: Date? - public let templates: [Template]? public var sourceArchive: Resource? { self.resources.first(where: { $0.name == "source-archive" }) } - public struct Template: Sendable { - public let name: String - public let description: String? - //public let permissions: [String]? TODO ADD - public let arguments: [TemplateArguments]? - - public init( - name: String, - description: String? = nil, - arguments: [TemplateArguments]? = nil - ) { - self.name = name - self.description = description - self.arguments = arguments - } - } - - public struct TemplateArguments: Sendable { - public let name: String - public let description: String? - public let isRequired: Bool? - - public init( - name: String, - description: String? = nil, - isRequired: Bool? = nil - ) { - self.name = name - self.description = description - self.isRequired = isRequired - } - } - public struct Resource: Sendable { public let name: String public let type: String @@ -2196,7 +2149,6 @@ extension RegistryClient { public let readmeURL: String? public let repositoryURLs: [String]? public let originalPublicationTime: Date? - public let templates: [Template]? public init( author: Author? = nil, @@ -2205,7 +2157,6 @@ extension RegistryClient { readmeURL: String? = nil, repositoryURLs: [String]? = nil, originalPublicationTime: Date? = nil, - templates: [Template]? = nil ) { self.author = author self.description = description @@ -2213,24 +2164,9 @@ extension RegistryClient { self.readmeURL = readmeURL self.repositoryURLs = repositoryURLs self.originalPublicationTime = originalPublicationTime - self.templates = templates } } - public struct Template: Codable { - public let name: String - public let description: String? - //public let permissions: [String]? TODO ADD - public let arguments: [TemplateArguments]? - } - - public struct TemplateArguments: Codable { - public let name: String - public let description: String? - public let isRequired: Bool? - } - - public struct Author: Codable { public let name: String public let email: String? From 89f4058f8e822321b80466c41d59af297b558581 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 23 Jul 2025 14:23:56 -0400 Subject: [PATCH 073/225] fixed dummyRepositorymanager to conform to protocol --- Tests/SourceControlTests/RepositoryManagerTests.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Tests/SourceControlTests/RepositoryManagerTests.swift b/Tests/SourceControlTests/RepositoryManagerTests.swift index 81b32496df4..2de6ff20127 100644 --- a/Tests/SourceControlTests/RepositoryManagerTests.swift +++ b/Tests/SourceControlTests/RepositoryManagerTests.swift @@ -850,6 +850,10 @@ private class DummyRepositoryProvider: RepositoryProvider { fatalError("not implemented") } + func checkout(branch: String) throws { + fatalError("not implemented") + } + func isAlternateObjectStoreValid(expected: AbsolutePath) -> Bool { fatalError("not implemented") } From eb511e98ad403bb43c3f6449ada817fd384fc0d3 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 23 Jul 2025 15:03:35 -0400 Subject: [PATCH 074/225] possibility to edit format of test result to json too --- Sources/Commands/SwiftTestCommand.swift | 108 +++++++++++++++++++++--- 1 file changed, 95 insertions(+), 13 deletions(-) diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index 228d8c91391..d3d45d3050e 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -810,7 +810,7 @@ extension ArgumentTreeNode { currentPath: [String] = [], currentArguments: [String: InitTemplatePackage.ArgumentResponse] = [:] ) -> [([String], [String: InitTemplatePackage.ArgumentResponse])] { - var newPath = currentPath + [command.commandName] + let newPath = currentPath + [command.commandName] var combinedArguments = currentArguments for (key, value) in arguments { @@ -893,6 +893,11 @@ extension SwiftTestCommand { @Flag(help: "Dry-run to display argument tree") var dryRun: Bool = false + /// Output format for the templates result. + /// + /// Can be either `.matrix` (default) or `.json`. + @Option(help: "Set the output format.") + var format: ShowTestTemplateOutput = .matrix func run(_ swiftCommandState: SwiftCommandState) async throws { let manifest = outputDirectory.appending(component: Manifest.filename) @@ -952,7 +957,6 @@ extension SwiftTestCommand { } let cliArgumentPaths = root.createCLITree(root: root) - let commandPaths = root.collectCommandPaths() func checkConditions(_ swiftCommandState: SwiftCommandState, template: String?) async throws -> InitPackage.PackageType { let workspace = try swiftCommandState.getActiveWorkspace() @@ -1007,7 +1011,21 @@ extension SwiftTestCommand { } - printBuildMatrix(buildMatrix) + switch self.format { + case .matrix: + printBuildMatrix(buildMatrix) + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + do { + let data = try encoder.encode(buildMatrix) + if let output = String(data: data, encoding: .utf8) { + print(output) + } + } catch { + print("Failed to encode JSON: \(error)") + } + } func printBuildMatrix(_ matrix: [String: BuildInfo]) { let header = [ @@ -1034,14 +1052,85 @@ extension SwiftTestCommand { } } - struct BuildInfo { + /// Output format modes for the `ShowTemplates` command. + enum ShowTestTemplateOutput: String, RawRepresentable, CustomStringConvertible, ExpressibleByArgument, CaseIterable { + /// Output as a matrix. + case matrix + /// Output as a JSON array of template along with fields. + case json + + public init?(rawValue: String) { + switch rawValue.lowercased() { + case "matrix": + self = .matrix + case "json": + self = .json + default: + return nil + } + } + + public var description: String { + switch self { + case .matrix: "matrix" + case .json: "json" + } + } + } + + struct BuildInfo: Encodable { var generationDuration: DispatchTimeInterval var buildDuration: DispatchTimeInterval var generationSuccess: Bool var buildSuccess: Bool - var generationError: Error? - var buildError: Error? var logFilePath: String? + + + public init(generationDuration: DispatchTimeInterval, buildDuration: DispatchTimeInterval, generationSuccess: Bool, buildSuccess: Bool, logFilePath: String? = nil) { + self.generationDuration = generationDuration + self.buildDuration = buildDuration + self.generationSuccess = generationSuccess + self.buildSuccess = buildSuccess + self.logFilePath = logFilePath + } + enum CodingKeys: String, CodingKey { + case generationDuration + case buildDuration + case generationSuccess + case buildSuccess + case logFilePath + } + + // Encoding + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(Self.dispatchTimeIntervalToSeconds(generationDuration), forKey: .generationDuration) + try container.encode(Self.dispatchTimeIntervalToSeconds(buildDuration), forKey: .buildDuration) + try container.encode(generationSuccess, forKey: .generationSuccess) + try container.encode(buildSuccess, forKey: .buildSuccess) + if logFilePath == nil { + try container.encodeNil(forKey: .logFilePath) + } else { + try container.encodeIfPresent(logFilePath, forKey: .logFilePath) + } + } + + // Helpers + private static func dispatchTimeIntervalToSeconds(_ interval: DispatchTimeInterval) -> Double { + switch interval { + case .seconds(let s): return Double(s) + case .milliseconds(let ms): return Double(ms) / 1000 + case .microseconds(let us): return Double(us) / 1_000_000 + case .nanoseconds(let ns): return Double(ns) / 1_000_000_000 + case .never: return -1 // or some sentinel value + @unknown default: return -1 + } + } + + private static func secondsToDispatchTimeInterval(_ seconds: Double) -> DispatchTimeInterval { + return .milliseconds(Int(seconds * 1000)) + } + } private func testTemplateIntialization( @@ -1059,8 +1148,6 @@ extension SwiftTestCommand { var buildDuration: DispatchTimeInterval = .never var generationSuccess = false var buildSuccess = false - var generationError: Error? - var buildError: Error? var logFilePath: String? = nil @@ -1130,7 +1217,6 @@ extension SwiftTestCommand { } catch { generationDuration = generationStart.distance(to: .now()) - generationError = error generationSuccess = false @@ -1184,7 +1270,6 @@ extension SwiftTestCommand { } catch { buildDuration = buildStart.distance(to: .now()) - buildError = error buildSuccess = false let logPath = destinationAbsolutePath.appending("build-output.log") @@ -1207,14 +1292,11 @@ extension SwiftTestCommand { } } - return BuildInfo( generationDuration: generationDuration, buildDuration: buildDuration, generationSuccess: generationSuccess, buildSuccess: buildSuccess, - generationError: generationError, - buildError: buildError, logFilePath: logFilePath ) } From 407d347c946c01fe7bbfa1037afdb924f6036126 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 23 Jul 2025 16:28:40 -0400 Subject: [PATCH 075/225] bug fix where subcommand was prompted for when there was no valid subcommand to choose from --- Sources/Workspace/InitTemplatePackage.swift | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 8c4898dec6e..73ea8c272bb 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -303,7 +303,7 @@ public final class InitTemplatePackage { private func promptUserForSubcommand(for commands: [CommandInfoV0]) throws -> CommandInfoV0 { - print("Available subcommands:\n") + print("Choose from the following:\n") for command in commands { print(""" @@ -314,16 +314,16 @@ public final class InitTemplatePackage { """) } - print("Type the name of the subcommand you'd like to use:") + print("Type the name of the option:") while true { if let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), !input.isEmpty { if let match = commands.first(where: { $0.commandName == input }) { return match } else { - print("No subcommand found with name '\(input)'. Please try again:") + print("No option found with name '\(input)'. Please try again:") } } else { - print("Please enter a valid subcommand name:") + print("Please enter a valid option name:") } } } @@ -342,9 +342,7 @@ public final class InitTemplatePackage { let filteredSubcommands = subcommands.filter { $0.commandName.lowercased() != "help" } - for sub in filteredSubcommands { - print(sub.commandName) - } + guard !filteredSubcommands.isEmpty else { return nil } return filteredSubcommands } From 559e1ecf37853f5de6a82afea317711013cc17df Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 6 Aug 2025 10:41:56 -0400 Subject: [PATCH 076/225] run package clean after generating package --- Sources/Commands/PackageCommands/Init.swift | 21 ++++++---- .../_InternalInitSupport/TemplateBuild.swift | 38 +++++++++---------- Sources/Workspace/InitTemplatePackage.swift | 2 - 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 43695e19e27..0f0f8d2202e 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -38,8 +38,6 @@ extension SwiftPackageCommand { @Option( name: .customLong("type"), help: ArgumentHelp("Specifies the package type or template.", discussion: """ - Valid values include: - library - A package with a library. executable - A package with an executable. tool - A package with an executable that uses @@ -49,7 +47,7 @@ extension SwiftPackageCommand { command-plugin - A package that vends a command plugin. macro - A package that vends a macro. empty - An empty package with a Package.swift manifest. - - When used with --path, --url, or --package-id, + custom - When used with --path, --url, or --package-id, this resolves to a template from the specified package or location. """)) @@ -247,10 +245,12 @@ extension SwiftPackageCommand { throw ValidationError("The specified template path does not exist: \(dir.pathString)") } - // Use a transitive staging directory + // Use a transitive staging directory for building let tempDir = try swiftCommandState.fileSystem.tempDirectory.appending(component: UUID().uuidString) let stagingPackagePath = tempDir.appending(component: "generated-package") - let buildDir = tempDir.appending(component: ".build") + + // Use a directory for cleaning dependencies post build + let cleanUpPath = tempDir.appending(component: "clean-up") try swiftCommandState.fileSystem.createDirectory(tempDir) defer { @@ -364,10 +364,17 @@ extension SwiftPackageCommand { if swiftCommandState.fileSystem.exists(cwd) { try swiftCommandState.fileSystem.removeFileTree(cwd) } - try swiftCommandState.fileSystem.copy(from: stagingPackagePath, to: cwd) - // Restore cwd for build + try swiftCommandState.fileSystem.copy(from: stagingPackagePath, to: cleanUpPath) + let _ = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: cleanUpPath) { _, _ in + try swiftCommandState.getActiveWorkspace().clean(observabilityScope: swiftCommandState.observabilityScope) + } + + try swiftCommandState.fileSystem.copy(from: cleanUpPath, to: cwd) + + // Restore cwd for build if validatePackage { try await TemplateBuildSupport.build( swiftCommandState: swiftCommandState, diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift index 043a89dd856..314a9cfd7a4 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -51,33 +51,29 @@ enum TemplateBuildSupport { ) async throws { - let buildSystem = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in - - try await swiftCommandState.createBuildSystem( - explicitProduct: buildOptions.product, - traitConfiguration: .init(traitOptions: buildOptions.traits), - shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: swiftCommandState.toolsBuildParameters, - outputStream: TSCBasic.stdoutStream - ) - } + let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in + + try await swiftCommandState.createBuildSystem( + explicitProduct: buildOptions.product, + traitConfiguration: .init(traitOptions: buildOptions.traits), + shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: swiftCommandState.toolsBuildParameters, + outputStream: TSCBasic.stdoutStream + ) + } guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { throw ExitCode.failure } - - - try await swiftCommandState - .withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in - do { - try await buildSystem.build(subset: subset) - } catch _ as Diagnostics { - throw ExitCode.failure - } + try await swiftCommandState.withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in + do { + try await buildSystem.build(subset: subset) + } catch _ as Diagnostics { + throw ExitCode.failure } + } } static func buildForTesting( diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 73ea8c272bb..1dc1421ddcc 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -307,10 +307,8 @@ public final class InitTemplatePackage { for command in commands { print(""" - • \(command.commandName) Name: \(command.commandName) About: \(command.abstract ?? "") - """) } From 0f3eb21172770c8b791668ad855f0959a56a7e8b Mon Sep 17 00:00:00 2001 From: John Bute Date: Fri, 8 Aug 2025 11:55:25 -0400 Subject: [PATCH 077/225] refactoring --- Sources/Commands/PackageCommands/Init.swift | 347 ++++-------------- .../PackageCommands/ShowTemplates.swift | 6 +- .../PackageDependencyBuilder.swift | 18 +- ...ackageInitializationDirectoryManager.swift | 50 +++ .../PackageInitializer.swift | 198 ++++++++++ .../RequirementResolver.swift | 33 +- .../_InternalInitSupport/TemplateBuild.swift | 129 ++++--- .../TemplatePathResolver.swift | 52 ++- .../TemplatePluginManager.swift | 91 +++++ Sources/Workspace/InitTemplatePackage.swift | 2 +- 10 files changed, 521 insertions(+), 405 deletions(-) create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 0f0f8d2202e..e1eddb1770d 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -29,6 +29,7 @@ import PackageGraph extension SwiftPackageCommand { struct Init: AsyncSwiftCommand { + public static let configuration = CommandConfiguration( abstract: "Initialize a new package.") @@ -68,19 +69,6 @@ extension SwiftPackageCommand { @OptionGroup(visibility: .hidden) var buildOptions: BuildCommandOptions - /// The type of template to use: `registry`, `git`, or `local`. - var templateSource: InitTemplatePackage.TemplateSource? { - if templateDirectory != nil { - .local - } else if templateURL != nil { - .git - } else if templatePackageID != nil { - .registry - } else { - nil - } - } - /// Path to a local template. @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) var templateDirectory: Basics.AbsolutePath? @@ -133,290 +121,68 @@ extension SwiftPackageCommand { var createPackagePath = true func run(_ swiftCommandState: SwiftCommandState) async throws { + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") - } - - let packageName = self.packageName ?? cwd.basename - - // Check for template init path - if let _ = templateSource { - // When a template source is provided: - // - If the user gives a known type, it's probably a misuse - // - If the user gives an unknown value for --type, treat it as the name of the template - // - If --type is missing entirely, assume the package has a single template - try await initTemplate(swiftCommandState) - return - } else { - guard let initModeString = self.initMode else { - throw ValidationError("Specify a package type using the --type option.") - } - guard let knownType = InitPackage.PackageType(rawValue: initModeString) else { - throw ValidationError("Package type \(initModeString) not supported") - } - // Configure testing libraries - var supportedTestingLibraries = Set() - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (knownType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.xctest) - } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (knownType != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.swiftTesting) - } - - let initPackage = try InitPackage( - name: packageName, - packageType: knownType, - supportedTestingLibraries: supportedTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, - fileSystem: swiftCommandState.fileSystem - ) - initPackage.progressReporter = { message in print(message) } - try initPackage.writePackageStructure() - - - } - - } - - - public func initTemplate(_ swiftCommandState: SwiftCommandState) async throws { - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") - } - - let packageName = self.packageName ?? cwd.basename - - try await self.runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) - - } - - /// Runs the package initialization using an author-defined template. - private func runTemplateInit( - swiftCommandState: SwiftCommandState, - packageName: String, - cwd: Basics.AbsolutePath - ) async throws { - guard let source = templateSource else { - throw ValidationError("No template source specified.") - } - - let manifest = cwd.appending(component: Manifest.filename) - guard swiftCommandState.fileSystem.exists(manifest) == false else { - throw InitError.manifestAlreadyExists - } - - - let contents = try swiftCommandState.fileSystem.getDirectoryContents(cwd) - - guard contents.isEmpty else { - throw InitError.nonEmptyDirectory(contents) - } - - let template = initMode - let requirementResolver = DependencyRequirementResolver( - exact: exact, - revision: revision, - branch: branch, - from: from, - upToNextMinorFrom: upToNextMinorFrom, - to: to - ) - - let registryRequirement: PackageDependency.Registry.Requirement? = - try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement - - let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = - try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement - - let resolvedTemplatePath = try await TemplatePathResolver( - source: templateSource, - templateDirectory: templateDirectory, - templateURL: templateURL, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - packageIdentity: templatePackageID, - swiftCommandState: swiftCommandState - ).resolve() - - if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { - throw ValidationError("The specified template path does not exist: \(dir.pathString)") - } - - // Use a transitive staging directory for building - let tempDir = try swiftCommandState.fileSystem.tempDirectory.appending(component: UUID().uuidString) - let stagingPackagePath = tempDir.appending(component: "generated-package") - - // Use a directory for cleaning dependencies post build - let cleanUpPath = tempDir.appending(component: "clean-up") - - try swiftCommandState.fileSystem.createDirectory(tempDir) - defer { - try? swiftCommandState.fileSystem.removeFileTree(tempDir) - } - - // Determine the type by loading the resolved template - let templateInitType = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in - try await self.checkConditions(swiftCommandState, template: template) - } - - // Clean up downloaded package after execution. - defer { - if templateSource == .git { - try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) - } else if templateSource == .registry { - let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL - try? FileManager.default.removeItem(at: parentDirectoryURL) - } - } - - let supportedTemplateTestingLibraries: Set = .init() - - let builder = DefaultPackageDependencyBuilder( - templateSource: source, - packageName: packageName, - templateURL: self.templateURL, - templatePackageID: self.templatePackageID - ) - - let dependencyKind = try builder.makePackageDependency( - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - resolvedTemplatePath: resolvedTemplatePath - ) - - let initTemplatePackage = try InitTemplatePackage( - name: packageName, - initMode: dependencyKind, - templatePath: resolvedTemplatePath, - fileSystem: swiftCommandState.fileSystem, - packageType: templateInitType, - supportedTestingLibraries: supportedTemplateTestingLibraries, - destinationPath: stagingPackagePath, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration - ) - - try swiftCommandState.fileSystem.createDirectory(stagingPackagePath, recursive: true) + } //Should this be refactored? + + let name = packageName ?? cwd.basename + - try initTemplatePackage.setupTemplateManifest() + var templateSourceResolver: TemplateSourceResolver = DefaultTemplateSourceResolver() - // Build once inside the transitive folder - try await TemplateBuildSupport.build( - swiftCommandState: swiftCommandState, - buildOptions: self.buildOptions, - globalOptions: self.globalOptions, - cwd: stagingPackagePath, - transitiveFolder: stagingPackagePath + let templateSource = templateSourceResolver.resolveSource( + directory: templateDirectory, + url: templateURL, + packageID: templatePackageID ) - let packageGraph = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: stagingPackagePath) { _, _ in - try await swiftCommandState.loadPackageGraph() + if templateSource == nil, let initMode { + guard let _ = InitPackage.PackageType(rawValue: initMode) else { + throw ValidationError("Unknown package type: '\(initMode)'") } - - - let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) - - guard let commandPlugin = matchingPlugins.first else { - guard let template = template - else { throw ValidationError("No templates were found in \(packageName)") } - - throw ValidationError("No templates were found that match the name \(template)") } - guard matchingPlugins.count == 1 else { - let templateNames = matchingPlugins.compactMap { module in - let plugin = module.underlying as! PluginModule - guard case .command(let intent, _) = plugin.capability else { return String?.none } - - return intent.invocationVerb - } - throw ValidationError( - "More than one template was found in the package. Please use `--type` along with one of the available templates: \(templateNames.joined(separator: ", "))" + if let source = templateSource { + let versionResolver = DependencyRequirementResolver( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to ) - } - - let output = try await TemplatePluginRunner.run( - plugin: commandPlugin, - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: ["--", "--experimental-dump-help"], - swiftCommandState: swiftCommandState - ) - - let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let cliResponses = try initTemplatePackage.promptUser(command: toolInfo.command, arguments: args) - for response in cliResponses { - _ = try await TemplatePluginRunner.run( - plugin: matchingPlugins[0], - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: response, + let initializer = TemplatePackageInitializer( + packageName: name, + cwd: cwd, + templateSource: source, + templateName: initMode, + templateDirectory: templateDirectory, + templateURL: templateURL, + templatePackageID: templatePackageID, + versionResolver: versionResolver, + buildOptions: buildOptions, + globalOptions: globalOptions, + validatePackage: validatePackage, + args: args, swiftCommandState: swiftCommandState ) - } - - // Move finalized package to target cwd - if swiftCommandState.fileSystem.exists(cwd) { - try swiftCommandState.fileSystem.removeFileTree(cwd) - } - - try swiftCommandState.fileSystem.copy(from: stagingPackagePath, to: cleanUpPath) - - let _ = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: cleanUpPath) { _, _ in - try swiftCommandState.getActiveWorkspace().clean(observabilityScope: swiftCommandState.observabilityScope) - } - - try swiftCommandState.fileSystem.copy(from: cleanUpPath, to: cwd) - - // Restore cwd for build - if validatePackage { - try await TemplateBuildSupport.build( - swiftCommandState: swiftCommandState, - buildOptions: self.buildOptions, - globalOptions: self.globalOptions, - cwd: cwd + try await initializer.run() + } else { + let initializer = StandardPackageInitializer( + packageName: name, + initMode: initMode, + testLibraryOptions: testLibraryOptions, + cwd: cwd, + swiftCommandState: swiftCommandState ) + try await initializer.run() } } - - - - /// Validates the loaded manifest to determine package type. - private func checkConditions(_ swiftCommandState: SwiftCommandState, template: String?) async throws -> InitPackage.PackageType { - let workspace = try swiftCommandState.getActiveWorkspace() - let root = try swiftCommandState.getWorkspaceRoot() - - let rootManifests = try await workspace.loadRootManifests( - packages: root.packages, - observabilityScope: swiftCommandState.observabilityScope - ) - guard let rootManifest = rootManifests.values.first else { - throw InternalError("invalid manifests at \(root.packages)") - } - - let products = rootManifest.products - let targets = rootManifest.targets - - for _ in products { - if let target: TargetDescription = targets.first(where: { template == nil || $0.name == template }) { - if let options = target.templateInitializationOptions { - if case .packageInit(let templateType, _, _) = options { - return try .init(from: templateType) - } - } - } - } - throw ValidationError( - "Could not find \(template != nil ? "template \(template!)" : "any templates in the package")" - ) + + init() { } - public init() {} } } @@ -442,4 +208,25 @@ extension InitPackage.PackageType { } } +protocol TemplateSourceResolver { + func resolveSource( + directory: Basics.AbsolutePath?, + url: String?, + packageID: String? + ) -> InitTemplatePackage.TemplateSource? +} + +struct DefaultTemplateSourceResolver: TemplateSourceResolver { + func resolveSource( + directory: Basics.AbsolutePath?, + url: String?, + packageID: String? + ) -> InitTemplatePackage.TemplateSource? { + if directory != nil { return .local } + if url != nil { return .git } + if packageID != nil { return .registry } + return nil + } +} + extension InitPackage.PackageType: ExpressibleByArgument {} diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 442161a08bd..ac50286ff19 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -73,8 +73,6 @@ struct ShowTemplates: AsyncSwiftCommand { var to: Version? func run(_ swiftCommandState: SwiftCommandState) async throws { - let packagePath: Basics.AbsolutePath - var shouldDeleteAfter = false let requirementResolver = DependencyRequirementResolver( exact: exact, @@ -86,10 +84,10 @@ struct ShowTemplates: AsyncSwiftCommand { ) let registryRequirement: PackageDependency.Registry.Requirement? = - try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement + try? requirementResolver.resolveRegistry() let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = - try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + try? requirementResolver.resolveSourceControl() var resolvedTemplatePath: Basics.AbsolutePath diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift index 5c84727436c..2896b0975a7 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift @@ -34,11 +34,7 @@ protocol PackageDependencyBuilder { /// /// - Throws: A `StringError` if required inputs (e.g., Git URL, Package ID) are missing or invalid for the selected /// source type. - func makePackageDependency( - sourceControlRequirement: PackageDependency.SourceControl.Requirement?, - registryRequirement: PackageDependency.Registry.Requirement?, - resolvedTemplatePath: Basics.AbsolutePath - ) throws -> MappablePackageDependency.Kind + func makePackageDependency() throws -> MappablePackageDependency.Kind } /// Default implementation of `PackageDependencyBuilder` that builds a package dependency @@ -58,6 +54,12 @@ struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { /// The registry package identifier, if the template source is registry-based. let templatePackageID: String? + + let sourceControlRequirement: PackageDependency.SourceControl.Requirement? + let registryRequirement: PackageDependency.Registry.Requirement? + let resolvedTemplatePath: Basics.AbsolutePath + + /// Constructs a package dependency kind based on the selected template source. /// /// - Parameters: @@ -68,11 +70,7 @@ struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { /// - Returns: A `MappablePackageDependency.Kind` representing the dependency. /// /// - Throws: A `StringError` if necessary information is missing or mismatched for the selected template source. - func makePackageDependency( - sourceControlRequirement: PackageDependency.SourceControl.Requirement? = nil, - registryRequirement: PackageDependency.Registry.Requirement? = nil, - resolvedTemplatePath: Basics.AbsolutePath - ) throws -> MappablePackageDependency.Kind { + func makePackageDependency() throws -> MappablePackageDependency.Kind { switch self.templateSource { case .local: return .fileSystem(name: self.packageName, path: resolvedTemplatePath.asURL.path) diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift new file mode 100644 index 00000000000..23c44762d1f --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift @@ -0,0 +1,50 @@ + +import Basics + +@_spi(SwiftPMInternal) + +import Workspace +import SPMBuildCore +import TSCBasic +import Foundation +import CoreCommands + +struct TemplateInitializationDirectoryManager { + let fileSystem: FileSystem + + func createTemporaryDirectories() throws -> (Basics.AbsolutePath, Basics.AbsolutePath, Basics.AbsolutePath) { + let tempRoot = try fileSystem.tempDirectory.appending(component: UUID().uuidString) + let stagingPath = tempRoot.appending(component: "generated-package") + let cleanupPath = tempRoot.appending(component: "clean-up") + try fileSystem.createDirectory(tempRoot) + return (stagingPath, cleanupPath, tempRoot) + } + + func finalize( + cwd: Basics.AbsolutePath, + stagingPath: Basics.AbsolutePath, + cleanupPath: Basics.AbsolutePath, + swiftCommandState: SwiftCommandState + ) async throws { + if fileSystem.exists(cwd) { + try fileSystem.removeFileTree(cwd) + } + try fileSystem.copy(from: stagingPath, to: cleanupPath) + _ = try await swiftCommandState.withTemporaryWorkspace(switchingTo: cleanupPath) { _, _ in + try SwiftPackageCommand.Clean().run(swiftCommandState) + } + try fileSystem.copy(from: cleanupPath, to: cwd) + } + + func cleanupTemporary(templateSource: InitTemplatePackage.TemplateSource, path: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) throws { + switch templateSource { + case .git: + try? FileManager.default.removeItem(at: path.asURL) + case .registry: + try? FileManager.default.removeItem(at: path.parentDirectory.asURL) + default: + break + } + try? fileSystem.removeFileTree(tempDir) + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift new file mode 100644 index 00000000000..743a60c8cc9 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -0,0 +1,198 @@ +import ArgumentParser +import ArgumentParserToolInfo + +import Basics + +@_spi(SwiftPMInternal) +import CoreCommands + +import PackageModel +import Workspace +import SPMBuildCore +import TSCBasic +import TSCUtility +import Foundation +import PackageGraph + +protocol PackageInitializer { + func run() async throws +} + +struct TemplatePackageInitializer: PackageInitializer { + let packageName: String + let cwd: Basics.AbsolutePath + let templateSource: InitTemplatePackage.TemplateSource + let templateName: String? + let templateDirectory: Basics.AbsolutePath? + let templateURL: String? + let templatePackageID: String? + let versionResolver: DependencyRequirementResolver + let buildOptions: BuildCommandOptions + let globalOptions: GlobalOptions + let validatePackage: Bool + let args: [String] + let swiftCommandState: SwiftCommandState + + func run() async throws { + let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem) + try precheck() + + let sourceControlRequirement = try? versionResolver.resolveSourceControl() + let registryRequirement = try? versionResolver.resolveRegistry() + + let resolvedTemplatePath = try await TemplatePathResolver( + source: templateSource, + templateDirectory: templateDirectory, + templateURL: templateURL, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() + + let (stagingPath, cleanupPath, tempDir) = try directoryManager.createTemporaryDirectories() + + let initType = try await inferPackageType(from: resolvedTemplatePath) + + let builder = DefaultPackageDependencyBuilder( + templateSource: templateSource, + packageName: packageName, + templateURL: templateURL, + templatePackageID: templatePackageID, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ) + + let templatePackage = try setUpPackage(builder: builder, packageType: initType, stagingPath: stagingPath) + + try await TemplateBuildSupport.build( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + globalOptions: globalOptions, + cwd: stagingPath, + transitiveFolder: stagingPath + ) + + try await TemplatePluginManager( + swiftCommandState: swiftCommandState, + template: templateName, + scratchDirectory: stagingPath, + args: args + ).run(templatePackage) + + try await directoryManager.finalize(cwd: cwd, stagingPath: stagingPath, cleanupPath: cleanupPath, swiftCommandState: swiftCommandState) + + if validatePackage { + try await TemplateBuildSupport.build( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + globalOptions: globalOptions, + cwd: cwd + ) + } + + try directoryManager.cleanupTemporary(templateSource: templateSource, path: resolvedTemplatePath, tempDir: tempDir) + } + + private func precheck() throws { + let manifest = cwd.appending(component: Manifest.filename) + guard !swiftCommandState.fileSystem.exists(manifest) else { + throw InitError.manifestAlreadyExists + } + + if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { + throw ValidationError("The specified template path does not exist: \(dir.pathString)") + } + } + + private func inferPackageType(from templatePath: Basics.AbsolutePath) async throws -> InitPackage.PackageType { + try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { _, _ in + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() + + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope + ) + + guard let manifest = rootManifests.values.first else { + throw InternalError("Invalid manifest in template at \(root.packages)") + } + + for target in manifest.targets { + if templateName == nil || target.name == templateName { + if let options = target.templateInitializationOptions { + if case .packageInit(let type, _, _) = options { + return try .init(from: type) + } + } + } + } + + throw ValidationError("Could not find template \(templateName ?? "")") + } + } + + private func setUpPackage( + builder: DefaultPackageDependencyBuilder, + packageType: InitPackage.PackageType, + stagingPath: Basics.AbsolutePath + ) throws -> InitTemplatePackage { + let templatePackage = try InitTemplatePackage( + name: packageName, + initMode: try builder.makePackageDependency(), + templatePath: builder.resolvedTemplatePath, + fileSystem: swiftCommandState.fileSystem, + packageType: packageType, + supportedTestingLibraries: [], + destinationPath: stagingPath, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration + ) + try swiftCommandState.fileSystem.createDirectory(stagingPath, recursive: true) + try templatePackage.setupTemplateManifest() + return templatePackage + } +} + + + +struct StandardPackageInitializer: PackageInitializer { + let packageName: String + let initMode: String? + let testLibraryOptions: TestLibraryOptions + let cwd: Basics.AbsolutePath + let swiftCommandState: SwiftCommandState + + func run() async throws { + + guard let initModeString = self.initMode else { + throw ValidationError("Specify a package type using the --type option.") + } + guard let knownType = InitPackage.PackageType(rawValue: initModeString) else { + throw ValidationError("Package type \(initModeString) not supported") + } + // Configure testing libraries + var supportedTestingLibraries = Set() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (knownType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (knownType != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.swiftTesting) + } + + let initPackage = try InitPackage( + name: packageName, + packageType: knownType, + supportedTestingLibraries: supportedTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, + fileSystem: swiftCommandState.fileSystem + ) + initPackage.progressReporter = { message in print(message) } + try initPackage.writePackageStructure() + } +} + diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index 61b2981074a..8e6064bd997 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -14,17 +14,14 @@ import PackageModel import TSCBasic import TSCUtility -/// A protocol defining an interface for resolving package dependency requirements -/// based on a user’s input (such as version, branch, or revision). +/// A protocol defining interfaces for resolving package dependency requirements +/// based on versioning input (e.g., version, branch, or revision). protocol DependencyRequirementResolving { - /// Resolves the requirement for the specified dependency type. - /// - /// - Parameter type: The type of dependency (`.sourceControl` or `.registry`) to resolve. - /// - Returns: A resolved requirement (`SourceControl.Requirement` or `Registry.Requirement`) as `Any`. - /// - Throws: `StringError` if resolution fails due to invalid or conflicting input. - func resolve(for type: DependencyType) throws -> Any + func resolveSourceControl() throws -> PackageDependency.SourceControl.Requirement + func resolveRegistry() throws -> PackageDependency.Registry.Requirement } + /// A utility for resolving a single, well-formed package dependency requirement /// from mutually exclusive versioning inputs, such as: /// - `exact`: A specific version (e.g., 1.2.3) @@ -55,28 +52,12 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// An optional manual upper bound for the version range. Must be used with `from` or `upToNextMinorFrom`. let to: Version? - /// Resolves a concrete requirement based on the provided fields and target dependency type. - /// - /// - Parameter type: The dependency type to resolve (`.sourceControl` or `.registry`). - /// - Returns: A resolved requirement object (`PackageDependency.SourceControl.Requirement` or - /// `PackageDependency.Registry.Requirement`). - /// - Throws: `StringError` if the inputs are invalid, ambiguous, or incomplete. - - func resolve(for type: DependencyType) throws -> Any { - switch type { - case .sourceControl: - try self.resolveSourceControlRequirement() - case .registry: - try self.resolveRegistryRequirement() - } - } - /// Internal helper for resolving a source control (Git) requirement. /// /// - Returns: A valid `PackageDependency.SourceControl.Requirement`. /// - Throws: `StringError` if multiple or no input fields are set, or if `to` is used without `from` or /// `upToNextMinorFrom`. - private func resolveSourceControlRequirement() throws -> PackageDependency.SourceControl.Requirement { + func resolveSourceControl() throws -> PackageDependency.SourceControl.Requirement { var requirements: [PackageDependency.SourceControl.Requirement] = [] if let v = exact { requirements.append(.exact(v)) } if let b = branch { requirements.append(.branch(b)) } @@ -102,7 +83,7 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// - Returns: A valid `PackageDependency.Registry.Requirement`. /// - Throws: `StringError` if more than one registry versioning input is provided or if `to` is used without a base /// range. - private func resolveRegistryRequirement() throws -> PackageDependency.Registry.Requirement { + func resolveRegistry() throws -> PackageDependency.Registry.Requirement { var requirements: [PackageDependency.Registry.Requirement] = [] if let v = exact { requirements.append(.exact(v)) } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift index 314a9cfd7a4..e0985730164 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -17,27 +17,22 @@ import SPMBuildCore import TSCBasic import TSCUtility -/// A utility for building Swift packages using the SwiftPM build system. +/// A utility for building Swift packages templates using the SwiftPM build system. /// /// `TemplateBuildSupport` encapsulates the logic needed to initialize the /// SwiftPM build system and perform a build operation based on a specific /// command configuration and workspace context. enum TemplateBuildSupport { + /// Builds a Swift package using the given command state, options, and working directory. /// - /// This method performs the following steps: - /// 1. Initializes a temporary workspace, optionally switching to a user-specified package directory. - /// 2. Creates a build system with the specified configuration, including product, traits, and build parameters. - /// 3. Resolves the build subset (e.g., targets or products to build). - /// 4. Executes the build within the workspace. - /// /// - Parameters: - /// - swiftCommandState: The current Swift command state, containing context such as the workspace and - /// diagnostics. + /// - swiftCommandState: The current Swift command state, containing context such as the workspace and diagnostics. /// - buildOptions: Options used to configure what and how to build, including the product and traits. /// - globalOptions: Global configuration such as the package directory and logging verbosity. /// - cwd: The current working directory to use if no package directory is explicitly provided. + /// - transitiveFolder: Optional override for the package directory. /// /// - Throws: /// - `ExitCode.failure` if no valid build subset can be resolved or if the build fails due to diagnostics. @@ -49,77 +44,101 @@ enum TemplateBuildSupport { cwd: Basics.AbsolutePath, transitiveFolder: Basics.AbsolutePath? = nil ) async throws { + let packageRoot = transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd - - let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in - - try await swiftCommandState.createBuildSystem( - explicitProduct: buildOptions.product, - traitConfiguration: .init(traitOptions: buildOptions.traits), - shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: swiftCommandState.toolsBuildParameters, - outputStream: TSCBasic.stdoutStream - ) - } + let buildSystem = try await makeBuildSystem( + swiftCommandState: swiftCommandState, + folder: packageRoot, + buildOptions: buildOptions + ) guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { throw ExitCode.failure } - try await swiftCommandState.withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in + try await swiftCommandState.withTemporaryWorkspace(switchingTo: packageRoot) { _, _ in do { try await buildSystem.build(subset: subset) - } catch _ as Diagnostics { + } catch let diagnostics as Diagnostics { + swiftCommandState.observabilityScope.emit(diagnostics) throw ExitCode.failure } } } + /// Builds a Swift package for testing, applying code coverage and PIF graph options. + /// + /// - Parameters: + /// - swiftCommandState: The current Swift command state. + /// - buildOptions: Options used to configure the build. + /// - testingFolder: The path to the folder containing the testable package. + /// + /// - Throws: Errors related to build preparation or diagnostics. static func buildForTesting( swiftCommandState: SwiftCommandState, buildOptions: BuildCommandOptions, testingFolder: Basics.AbsolutePath ) async throws { + let buildSystem = try await makeBuildSystem( + swiftCommandState: swiftCommandState, + folder: testingFolder, + buildOptions: buildOptions, + forTesting: true + ) - var productsBuildParameters = try swiftCommandState.productsBuildParameters - var toolsBuildParameters = try swiftCommandState.toolsBuildParameters - - if buildOptions.enableCodeCoverage { - productsBuildParameters.testingParameters.enableCodeCoverage = true - toolsBuildParameters.testingParameters.enableCodeCoverage = true - } - - if buildOptions.printPIFManifestGraphviz { - productsBuildParameters.printPIFManifestGraphviz = true - toolsBuildParameters.printPIFManifestGraphviz = true + guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { + throw ExitCode.failure } + try await swiftCommandState.withTemporaryWorkspace(switchingTo: testingFolder) { _, _ in + do { + try await buildSystem.build(subset: subset) + } catch let diagnostics as Diagnostics { + swiftCommandState.observabilityScope.emit(diagnostics) + throw ExitCode.failure + } + } + } - let buildSystem = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: testingFolder) { _, _ in - try await swiftCommandState.createBuildSystem( - explicitProduct: buildOptions.product, - traitConfiguration: .init(traitOptions: buildOptions.traits), - shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: swiftCommandState.toolsBuildParameters, - outputStream: TSCBasic.stdoutStream - ) + /// Internal helper to create a `BuildSystem` with appropriate parameters. + /// + /// - Parameters: + /// - swiftCommandState: The active command context. + /// - folder: The directory to switch into for workspace operations. + /// - buildOptions: Build configuration options. + /// - forTesting: Whether to apply test-specific parameters (like code coverage). + /// + /// - Returns: A configured `BuildSystem` instance ready to build. + private static func makeBuildSystem( + swiftCommandState: SwiftCommandState, + folder: Basics.AbsolutePath, + buildOptions: BuildCommandOptions, + forTesting: Bool = false + ) async throws -> BuildSystem { + var productsParams = try swiftCommandState.productsBuildParameters + var toolsParams = try swiftCommandState.toolsBuildParameters + + if forTesting { + if buildOptions.enableCodeCoverage { + productsParams.testingParameters.enableCodeCoverage = true + toolsParams.testingParameters.enableCodeCoverage = true } - guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { - throw ExitCode.failure + if buildOptions.printPIFManifestGraphviz { + productsParams.printPIFManifestGraphviz = true + toolsParams.printPIFManifestGraphviz = true + } } - try await swiftCommandState - .withTemporaryWorkspace(switchingTo: testingFolder) { _, _ in - do { - try await buildSystem.build(subset: subset) - } catch _ as Diagnostics { - throw ExitCode.failure - } - } + return try await swiftCommandState.withTemporaryWorkspace(switchingTo: folder) { _, _ in + try await swiftCommandState.createBuildSystem( + explicitProduct: buildOptions.product, + traitConfiguration: .init(traitOptions: buildOptions.traits), + shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, + productsBuildParameters: productsParams, + toolsBuildParameters: toolsParams, + outputStream: TSCBasic.stdoutStream + ) + } } - } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index be18d97df36..123398acc61 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -10,6 +10,7 @@ // //===----------------------------------------------------------------------===// +//TODO: needs review import ArgumentParser import Basics import CoreCommands @@ -142,42 +143,35 @@ struct GitTemplateFetcher: TemplateFetcher { /// Fetches a bare clone of the Git repository to the specified path. func fetch() async throws -> Basics.AbsolutePath { - let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in - try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in + try withTemporaryDirectory(removeTreeOnDeinit: false) { tempDir in + + let url = SourceControlURL(source) + let repositorySpecifier = RepositorySpecifier(url: url) + let repositoryProvider = GitRepositoryProvider() - let url = SourceControlURL(source) - let repositorySpecifier = RepositorySpecifier(url: url) - let repositoryProvider = GitRepositoryProvider() + let bareCopyPath = tempDir.appending(component: "bare-copy") + let workingCopyPath = tempDir.appending(component: "working-copy") - let bareCopyPath = tempDir.appending(component: "bare-copy") + try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) + try self.validateDirectory(provider: repositoryProvider, at: bareCopyPath) - let workingCopyPath = tempDir.appending(component: "working-copy") - - try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) - - try self.validateDirectory(provider: repositoryProvider, at: bareCopyPath) - - try FileManager.default.createDirectory( - atPath: workingCopyPath.pathString, - withIntermediateDirectories: true - ) - - let repository = try repositoryProvider.createWorkingCopyFromBare( - repository: repositorySpecifier, - sourcePath: bareCopyPath, - at: workingCopyPath, - editable: true - ) + try FileManager.default.createDirectory( + atPath: workingCopyPath.pathString, + withIntermediateDirectories: true + ) - try FileManager.default.removeItem(at: bareCopyPath.asURL) + let repository = try repositoryProvider.createWorkingCopyFromBare( + repository: repositorySpecifier, + sourcePath: bareCopyPath, + at: workingCopyPath, + editable: true + ) - try self.checkout(repository: repository) + try FileManager.default.removeItem(at: bareCopyPath.asURL) + try self.checkout(repository: repository) - return workingCopyPath - } + return workingCopyPath } - - return try await fetchStandalonePackageByURL() } /// Validates that the directory contains a valid Git repository. diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift new file mode 100644 index 00000000000..6e81f388f4c --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -0,0 +1,91 @@ + +import ArgumentParser +import ArgumentParserToolInfo + +import Basics + +@_spi(SwiftPMInternal) +import CoreCommands + +import PackageModel +import Workspace +import SPMBuildCore +import TSCBasic +import TSCUtility +import Foundation +import PackageGraph + + +struct TemplatePluginManager { + let swiftCommandState: SwiftCommandState + let template: String? + + let packageGraph: ModulesGraph + + let scratchDirectory: Basics.AbsolutePath + + let args: [String] + + init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String]) async throws { + self.swiftCommandState = swiftCommandState + self.template = template + self.scratchDirectory = scratchDirectory + self.args = args + + self.packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: scratchDirectory) { _, _ in + try await swiftCommandState.loadPackageGraph() + } + } + //revisit for future refactoring + func run(_ initTemplatePackage: InitTemplatePackage) async throws { + + let commandLinePlugin = try loadTemplatePlugin() + + let output = try await TemplatePluginRunner.run( + plugin: commandLinePlugin, + package: self.packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: ["--", "--experimental-dump-help"], + swiftCommandState: swiftCommandState + ) + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + let cliResponses = try initTemplatePackage.promptUser(command: toolInfo.command, arguments: args) + + for response in cliResponses { + _ = try await TemplatePluginRunner.run( + plugin: commandLinePlugin, + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: response, + swiftCommandState: swiftCommandState + ) + } + + } + + private func loadTemplatePlugin() throws -> ResolvedModule { + + let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: self.packageGraph, limitedTo: nil) + + guard let commandPlugin = matchingPlugins.first else { + guard let template = template + else { throw ValidationError("No templates were found in \(packageGraph.rootPackages.first!.path)") } //better error message + + throw ValidationError("No templates were found that match the name \(template)") + } + + guard matchingPlugins.count == 1 else { + let templateNames = matchingPlugins.compactMap { module in + let plugin = module.underlying as! PluginModule + guard case .command(let intent, _) = plugin.capability else { return String?.none } + + return intent.invocationVerb + } + throw ValidationError( + "More than one template was found in the package. Please use `--type` along with one of the available templates: \(templateNames.joined(separator: ", "))" + ) + } + + return commandPlugin + } +} diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 1dc1421ddcc..9073cbfda79 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -85,7 +85,7 @@ public final class InitTemplatePackage { } /// The type of template source. - public enum TemplateSource: String, CustomStringConvertible, Decodable { + public enum TemplateSource: String, CustomStringConvertible { case local case git case registry From 3e1d137455ca6a9e8067a0b152bbb1fcf01e5d6f Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 13 Aug 2025 09:55:02 -0400 Subject: [PATCH 078/225] refactoring + error handling --- Sources/Commands/PackageCommands/Init.swift | 184 +++++++++++++----- .../PackageCommands/ShowTemplates.swift | 2 +- Sources/Commands/SwiftTestCommand.swift | 18 +- .../PackageDependencyBuilder.swift | 23 +++ ...ackageInitializationDirectoryManager.swift | 77 ++++++-- .../PackageInitializer.swift | 57 +++++- .../RequirementResolver.swift | 69 +++++-- .../TemplatePathResolver.swift | 165 ++++++++++++---- .../TemplatePluginManager.swift | 184 ++++++++++++++---- Sources/Workspace/InitTemplatePackage.swift | 10 +- 10 files changed, 603 insertions(+), 186 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index e1eddb1770d..0bc142f9c0f 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -121,66 +121,34 @@ extension SwiftPackageCommand { var createPackagePath = true func run(_ swiftCommandState: SwiftCommandState) async throws { - - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") - } //Should this be refactored? - - let name = packageName ?? cwd.basename - - - var templateSourceResolver: TemplateSourceResolver = DefaultTemplateSourceResolver() + let versionFlags = VersionFlags( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ) - let templateSource = templateSourceResolver.resolveSource( + let state = try PackageInitConfiguration( + swiftCommandState: swiftCommandState, + name: packageName, + initMode: initMode, + testLibraryOptions: testLibraryOptions, + buildOptions: buildOptions, + globalOptions: globalOptions, + validatePackage: validatePackage, + args: args, directory: templateDirectory, url: templateURL, - packageID: templatePackageID + packageID: templatePackageID, + versionFlags: versionFlags ) - if templateSource == nil, let initMode { - guard let _ = InitPackage.PackageType(rawValue: initMode) else { - throw ValidationError("Unknown package type: '\(initMode)'") - } - } - - if let source = templateSource { - let versionResolver = DependencyRequirementResolver( - exact: exact, - revision: revision, - branch: branch, - from: from, - upToNextMinorFrom: upToNextMinorFrom, - to: to - ) - - let initializer = TemplatePackageInitializer( - packageName: name, - cwd: cwd, - templateSource: source, - templateName: initMode, - templateDirectory: templateDirectory, - templateURL: templateURL, - templatePackageID: templatePackageID, - versionResolver: versionResolver, - buildOptions: buildOptions, - globalOptions: globalOptions, - validatePackage: validatePackage, - args: args, - swiftCommandState: swiftCommandState - ) - try await initializer.run() - } else { - let initializer = StandardPackageInitializer( - packageName: name, - initMode: initMode, - testLibraryOptions: testLibraryOptions, - cwd: cwd, - swiftCommandState: swiftCommandState - ) - try await initializer.run() - } + let initializer = try state.makeInitializer() + try await initializer.run() } - + init() { } @@ -208,6 +176,114 @@ extension InitPackage.PackageType { } } + +struct PackageInitConfiguration { + let packageName: String + let cwd: Basics.AbsolutePath + let swiftCommandState: SwiftCommandState + let initMode: String? + let templateSource: InitTemplatePackage.TemplateSource? + let testLibraryOptions: TestLibraryOptions + let buildOptions: BuildCommandOptions? + let globalOptions: GlobalOptions? + let validatePackage: Bool? + let args: [String] + let versionResolver: DependencyRequirementResolver? + + init( + swiftCommandState: SwiftCommandState, + name: String?, + initMode: String?, + testLibraryOptions: TestLibraryOptions, + buildOptions: BuildCommandOptions, + globalOptions: GlobalOptions, + validatePackage: Bool, + args: [String], + directory: Basics.AbsolutePath?, + url: String?, + packageID: String?, + versionFlags: VersionFlags + ) throws { + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") + } + + self.cwd = cwd + self.packageName = name ?? cwd.basename + self.swiftCommandState = swiftCommandState + self.initMode = initMode + self.testLibraryOptions = testLibraryOptions + self.buildOptions = buildOptions + self.globalOptions = globalOptions + self.validatePackage = validatePackage + self.args = args + + let sourceResolver = DefaultTemplateSourceResolver() + self.templateSource = sourceResolver.resolveSource( + directory: directory, + url: url, + packageID: packageID + ) + + if templateSource != nil { + self.versionResolver = DependencyRequirementResolver( + exact: versionFlags.exact, + revision: versionFlags.revision, + branch: versionFlags.branch, + from: versionFlags.from, + upToNextMinorFrom: versionFlags.upToNextMinorFrom, + to: versionFlags.to + ) + } else { + self.versionResolver = nil + } + } + + func makeInitializer() throws -> PackageInitializer { + if let templateSource = templateSource, + let versionResolver = versionResolver, + let buildOptions = buildOptions, + let globalOptions = globalOptions, + let validatePackage = validatePackage { + + return TemplatePackageInitializer( + packageName: packageName, + cwd: cwd, + templateSource: templateSource, + templateName: initMode, + templateDirectory: nil, + templateURL: nil, + templatePackageID: nil, + versionResolver: versionResolver, + buildOptions: buildOptions, + globalOptions: globalOptions, + validatePackage: validatePackage, + args: args, + swiftCommandState: swiftCommandState + ) + } else { + return StandardPackageInitializer( + packageName: packageName, + initMode: initMode, + testLibraryOptions: testLibraryOptions, + cwd: cwd, + swiftCommandState: swiftCommandState + ) + } + } +} + + +struct VersionFlags { + let exact: Version? + let revision: String? + let branch: String? + let from: Version? + let upToNextMinorFrom: Version? + let to: Version? +} + + protocol TemplateSourceResolver { func resolveSource( directory: Basics.AbsolutePath?, diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index ac50286ff19..5c840cf30d5 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -106,7 +106,7 @@ struct ShowTemplates: AsyncSwiftCommand { templateSource = .git - } else if let packageID = self.templatePackageID { + } else if let _ = self.templatePackageID { // Download and resolve the Git-based template. resolvedTemplatePath = try await TemplatePathResolver( source: .registry, diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index d3d45d3050e..8e348d54d27 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -714,7 +714,7 @@ final class ArgumentTreeNode { let command: CommandInfoV0 var children: [ArgumentTreeNode] = [] - var arguments: [String: InitTemplatePackage.ArgumentResponse] = [:] + var arguments: [String: TemplatePromptingSystem.ArgumentResponse] = [:] init(command: CommandInfoV0) { self.command = command @@ -748,18 +748,18 @@ final class ArgumentTreeNode { static func promptForUniqueArguments( uniqueArguments: [String: ArgumentInfoV0] - ) -> [String: InitTemplatePackage.ArgumentResponse] { - var collected: [String: InitTemplatePackage.ArgumentResponse] = [:] + ) -> [String: TemplatePromptingSystem.ArgumentResponse] { + var collected: [String: TemplatePromptingSystem.ArgumentResponse] = [:] let argsToPrompt = Array(uniqueArguments.values) // Prompt for all unique arguments at once - _ = InitTemplatePackage.UserPrompter.prompt(for: argsToPrompt, collected: &collected) + _ = TemplatePromptingSystem.UserPrompter.prompt(for: argsToPrompt, collected: &collected) return collected } //Fill node arguments by assigning the prompted values for keys it requires - func fillArguments(with responses: [String: InitTemplatePackage.ArgumentResponse]) { + func fillArguments(with responses: [String: TemplatePromptingSystem.ArgumentResponse]) { if let args = command.arguments { for arg in args { if let resp = responses[arg.valueName ?? ""] { @@ -808,8 +808,8 @@ extension ArgumentTreeNode { /// Traverses all command paths and returns CLI paths along with their arguments func collectCommandPaths( currentPath: [String] = [], - currentArguments: [String: InitTemplatePackage.ArgumentResponse] = [:] - ) -> [([String], [String: InitTemplatePackage.ArgumentResponse])] { + currentArguments: [String: TemplatePromptingSystem.ArgumentResponse] = [:] + ) -> [([String], [String: TemplatePromptingSystem.ArgumentResponse])] { let newPath = currentPath + [command.commandName] var combinedArguments = currentArguments @@ -821,7 +821,7 @@ extension ArgumentTreeNode { return [(newPath, combinedArguments)] } - var results: [([String], [String: InitTemplatePackage.ArgumentResponse])] = [] + var results: [([String], [String: TemplatePromptingSystem.ArgumentResponse])] = [] for child in children { results += child.collectCommandPaths( currentPath: newPath, @@ -932,7 +932,7 @@ extension SwiftTestCommand { return intent.invocationVerb } throw ValidationError( - "More than one template was found in the package. Please use `--type` along with one of the available templates: \(templateNames.joined(separator: ", "))" + "More than one template was found in the package. Please use `--template-name` along with one of the available templates: \(templateNames.joined(separator: ", "))" ) } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift index 2896b0975a7..b3b18d4a782 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift @@ -94,4 +94,27 @@ struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { return .registry(id: id, requirement: requirement) } } + + + /// Errors thrown by `TemplatePathResolver` during initialization. + enum PackageDependencyBuilderError: LocalizedError, Equatable { + case missingGitURL + case missingGitRequirement + case missingRegistryIdentity + case missingRegistryRequirement + + var errorDescription: String? { + switch self { + case .missingGitURL: + return "Missing Git URL for git template." + case .missingGitRequirement: + return "Missing version requirement for template in git." + case .missingRegistryIdentity: + return "Missing registry package identity for template in registry." + case .missingRegistryRequirement: + return "Missing version requirement for template in registry ." + } + } + } + } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift index 23c44762d1f..7e5f8b0a781 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift @@ -1,23 +1,21 @@ import Basics -@_spi(SwiftPMInternal) import Workspace -import SPMBuildCore -import TSCBasic import Foundation import CoreCommands + struct TemplateInitializationDirectoryManager { let fileSystem: FileSystem - func createTemporaryDirectories() throws -> (Basics.AbsolutePath, Basics.AbsolutePath, Basics.AbsolutePath) { - let tempRoot = try fileSystem.tempDirectory.appending(component: UUID().uuidString) - let stagingPath = tempRoot.appending(component: "generated-package") - let cleanupPath = tempRoot.appending(component: "clean-up") - try fileSystem.createDirectory(tempRoot) - return (stagingPath, cleanupPath, tempRoot) + func createTemporaryDirectories() throws -> (stagingPath: Basics.AbsolutePath, cleanUpPath: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) { + let tempDir = try fileSystem.tempDirectory.appending(component: UUID().uuidString) + let stagingPath = tempDir.appending(component: "generated-package") + let cleanupPath = tempDir.appending(component: "clean-up") + try fileSystem.createDirectory(tempDir) + return (stagingPath, cleanupPath, tempDir) } func finalize( @@ -27,24 +25,63 @@ struct TemplateInitializationDirectoryManager { swiftCommandState: SwiftCommandState ) async throws { if fileSystem.exists(cwd) { - try fileSystem.removeFileTree(cwd) + do { + try fileSystem.removeFileTree(cwd) + } catch { + throw FileOperationError.failedToRemoveExistingDirectory(path: cwd, underlying: error) + } } try fileSystem.copy(from: stagingPath, to: cleanupPath) - _ = try await swiftCommandState.withTemporaryWorkspace(switchingTo: cleanupPath) { _, _ in + try await cleanBuildArtifacts(at: cleanupPath, swiftCommandState: swiftCommandState) + try fileSystem.copy(from: cleanupPath, to: cwd) + } + + func cleanBuildArtifacts(at path: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) async throws { + _ = try await swiftCommandState.withTemporaryWorkspace(switchingTo: path) { _, _ in try SwiftPackageCommand.Clean().run(swiftCommandState) } - try fileSystem.copy(from: cleanupPath, to: cwd) } func cleanupTemporary(templateSource: InitTemplatePackage.TemplateSource, path: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) throws { - switch templateSource { - case .git: - try? FileManager.default.removeItem(at: path.asURL) - case .registry: - try? FileManager.default.removeItem(at: path.parentDirectory.asURL) - default: - break + do { + switch templateSource { + case .git: + if FileManager.default.fileExists(atPath: path.pathString) { + try FileManager.default.removeItem(at: path.asURL) + } + case .registry: + if FileManager.default.fileExists(atPath: path.pathString) { + try FileManager.default.removeItem(at: path.asURL) + } + case .local: + break + } + try fileSystem.removeFileTree(tempDir) + } catch { + throw CleanupError.failedToCleanup(tempDir: tempDir, underlying: error) + } + } + + enum CleanupError: Error, CustomStringConvertible { + case failedToCleanup(tempDir: Basics.AbsolutePath, underlying: Error) + + var description: String { + switch self { + case .failedToCleanup(let tempDir, let error): + return "Failed to clean up temporary directory at \(tempDir): \(error.localizedDescription)" + } } - try? fileSystem.removeFileTree(tempDir) } + + enum FileOperationError: Error, CustomStringConvertible { + case failedToRemoveExistingDirectory(path: Basics.AbsolutePath, underlying: Error) + + var description: String { + switch self { + case .failedToRemoveExistingDirectory(let path, let underlying): + return "Failed to remove existing directory at \(path): \(underlying.localizedDescription)" + } + } + } + } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index 743a60c8cc9..ea030c04416 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -34,9 +34,9 @@ struct TemplatePackageInitializer: PackageInitializer { let swiftCommandState: SwiftCommandState func run() async throws { - let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem) try precheck() + // Resolve version requirements let sourceControlRequirement = try? versionResolver.resolveSourceControl() let registryRequirement = try? versionResolver.resolveRegistry() @@ -50,9 +50,10 @@ struct TemplatePackageInitializer: PackageInitializer { swiftCommandState: swiftCommandState ).resolve() + let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem) let (stagingPath, cleanupPath, tempDir) = try directoryManager.createTemporaryDirectories() - let initType = try await inferPackageType(from: resolvedTemplatePath) + let packageType = try await inferPackageType(from: resolvedTemplatePath) let builder = DefaultPackageDependencyBuilder( templateSource: templateSource, @@ -64,7 +65,10 @@ struct TemplatePackageInitializer: PackageInitializer { resolvedTemplatePath: resolvedTemplatePath ) - let templatePackage = try setUpPackage(builder: builder, packageType: initType, stagingPath: stagingPath) + + let templatePackage = try setUpPackage(builder: builder, packageType: packageType, stagingPath: stagingPath) + + swiftCommandState.observabilityScope.emit(debug: "Set up initial package: \(templatePackage.packageName)") try await TemplateBuildSupport.build( swiftCommandState: swiftCommandState, @@ -74,12 +78,12 @@ struct TemplatePackageInitializer: PackageInitializer { transitiveFolder: stagingPath ) - try await TemplatePluginManager( + try await TemplateInitializationPluginManager( swiftCommandState: swiftCommandState, template: templateName, scratchDirectory: stagingPath, args: args - ).run(templatePackage) + ).run() try await directoryManager.finalize(cwd: cwd, stagingPath: stagingPath, cleanupPath: cleanupPath, swiftCommandState: swiftCommandState) @@ -102,7 +106,7 @@ struct TemplatePackageInitializer: PackageInitializer { } if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { - throw ValidationError("The specified template path does not exist: \(dir.pathString)") + throw TemplatePackageInitializerError.templateDirectoryNotFound(dir.pathString) } } @@ -117,7 +121,7 @@ struct TemplatePackageInitializer: PackageInitializer { ) guard let manifest = rootManifests.values.first else { - throw InternalError("Invalid manifest in template at \(root.packages)") + throw TemplatePackageInitializerError.invalidManifestInTemplate(root.packages.description) } for target in manifest.targets { @@ -130,7 +134,7 @@ struct TemplatePackageInitializer: PackageInitializer { } } - throw ValidationError("Could not find template \(templateName ?? "")") + throw TemplatePackageInitializerError.templateNotFound(templateName ?? "") } } @@ -153,6 +157,24 @@ struct TemplatePackageInitializer: PackageInitializer { try templatePackage.setupTemplateManifest() return templatePackage } + + enum TemplatePackageInitializerError: Error, CustomStringConvertible { + case templateDirectoryNotFound(String) + case invalidManifestInTemplate(String) + case templateNotFound(String) + + var description: String { + switch self { + case .templateDirectoryNotFound(let path): + return "The specified template path does not exist: \(path)" + case .invalidManifestInTemplate(let path): + return "Invalid manifest found in template at \(path)." + case .templateNotFound(let templateName): + return "Could not find template \(templateName)." + } + } + } + } @@ -167,10 +189,10 @@ struct StandardPackageInitializer: PackageInitializer { func run() async throws { guard let initModeString = self.initMode else { - throw ValidationError("Specify a package type using the --type option.") + throw StandardPackageInitializerError.missingInitMode } guard let knownType = InitPackage.PackageType(rawValue: initModeString) else { - throw ValidationError("Package type \(initModeString) not supported") + throw StandardPackageInitializerError.unsupportedPackageType(initModeString) } // Configure testing libraries var supportedTestingLibraries = Set() @@ -194,5 +216,20 @@ struct StandardPackageInitializer: PackageInitializer { initPackage.progressReporter = { message in print(message) } try initPackage.writePackageStructure() } + + enum StandardPackageInitializerError: Error, CustomStringConvertible { + case missingInitMode + case unsupportedPackageType(String) + + var description: String { + switch self { + case .missingInitMode: + return "Specify a package type using the --type option." + case .unsupportedPackageType(let type): + return "Package type '\(type)' is not supported." + } + } + } + } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index 8e6064bd997..02883294ea8 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -58,24 +58,28 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// - Throws: `StringError` if multiple or no input fields are set, or if `to` is used without `from` or /// `upToNextMinorFrom`. func resolveSourceControl() throws -> PackageDependency.SourceControl.Requirement { - var requirements: [PackageDependency.SourceControl.Requirement] = [] - if let v = exact { requirements.append(.exact(v)) } - if let b = branch { requirements.append(.branch(b)) } - if let r = revision { requirements.append(.revision(r)) } - if let f = from { requirements.append(.range(.upToNextMajor(from: f))) } - if let u = upToNextMinorFrom { requirements.append(.range(.upToNextMinor(from: u))) } - - guard requirements.count == 1, let requirement = requirements.first else { - throw StringError("Specify exactly one source control version requirement.") + var specifiedRequirements: [PackageDependency.SourceControl.Requirement] = [] + if let v = exact { specifiedRequirements.append(.exact(v)) } + if let b = branch { specifiedRequirements.append(.branch(b)) } + if let r = revision { specifiedRequirements.append(.revision(r)) } + if let f = from { specifiedRequirements.append(.range(.upToNextMajor(from: f))) } + if let u = upToNextMinorFrom { specifiedRequirements.append(.range(.upToNextMinor(from: u))) } + + guard !specifiedRequirements.isEmpty else { + throw DependencyRequirementError.noRequirementSpecified } - if case .range(let range) = requirement, let upper = to { + guard specifiedRequirements.count == 1, let specifiedRequirements = specifiedRequirements.first else { + throw DependencyRequirementError.multipleRequirementsSpecified + } + + if case .range(let range) = specifiedRequirements, let upper = to { return .range(range.lowerBound ..< upper) } else if self.to != nil { - throw StringError("--to requires --from or --up-to-next-minor-from") + throw DependencyRequirementError.invalidToParameterWithoutFrom } - return requirement + return specifiedRequirements } /// Internal helper for resolving a registry-based requirement. @@ -84,23 +88,28 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// - Throws: `StringError` if more than one registry versioning input is provided or if `to` is used without a base /// range. func resolveRegistry() throws -> PackageDependency.Registry.Requirement { - var requirements: [PackageDependency.Registry.Requirement] = [] - if let v = exact { requirements.append(.exact(v)) } - if let f = from { requirements.append(.range(.upToNextMajor(from: f))) } - if let u = upToNextMinorFrom { requirements.append(.range(.upToNextMinor(from: u))) } + var specifiedRequirements: [PackageDependency.Registry.Requirement] = [] + + if let v = exact { specifiedRequirements.append(.exact(v)) } + if let f = from { specifiedRequirements.append(.range(.upToNextMajor(from: f))) } + if let u = upToNextMinorFrom { specifiedRequirements.append(.range(.upToNextMinor(from: u))) } + + guard !specifiedRequirements.isEmpty else { + throw DependencyRequirementError.noRequirementSpecified + } - guard requirements.count == 1, let requirement = requirements.first else { - throw StringError("Specify exactly one source control version requirement.") + guard specifiedRequirements.count == 1, let specifiedRequirements = specifiedRequirements.first else { + throw DependencyRequirementError.multipleRequirementsSpecified } - if case .range(let range) = requirement, let upper = to { + if case .range(let range) = specifiedRequirements, let upper = to { return .range(range.lowerBound ..< upper) } else if self.to != nil { - throw StringError("--to requires --from or --up-to-next-minor-from") + throw DependencyRequirementError.invalidToParameterWithoutFrom } - return requirement + return specifiedRequirements } } @@ -111,3 +120,21 @@ enum DependencyType { /// A registry dependency, typically resolved from a package registry. case registry } + +enum DependencyRequirementError: Error, CustomStringConvertible { + case multipleRequirementsSpecified + case noRequirementSpecified + case invalidToParameterWithoutFrom + + var description: String { + switch self { + case .multipleRequirementsSpecified: + return "Specify exactly one source control version requirement." + case .noRequirementSpecified: + return "No source control version requirement specified." + case .invalidToParameterWithoutFrom: + return "--to requires --from or --up-to-next-minor-from" + } + } +} + diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index 123398acc61..74565b1b2a7 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -74,19 +74,19 @@ struct TemplatePathResolver { switch source { case .local: guard let path = templateDirectory else { - throw StringError("Template path must be specified for local templates.") + throw TemplatePathResolverError.missingLocalTemplatePath } self.fetcher = LocalTemplateFetcher(path: path) case .git: guard let url = templateURL, let requirement = sourceControlRequirement else { - throw StringError("Missing Git URL or requirement for git template.") + throw TemplatePathResolverError.missingGitURLOrRequirement } self.fetcher = GitTemplateFetcher(source: url, requirement: requirement) case .registry: guard let identity = packageIdentity, let requirement = registryRequirement else { - throw StringError("Missing registry package identity or requirement.") + throw TemplatePathResolverError.missingRegistryIdentityOrRequirement } self.fetcher = RegistryTemplateFetcher( swiftCommandState: swiftCommandState, @@ -95,7 +95,7 @@ struct TemplatePathResolver { ) case .none: - throw StringError("Missing --template-type.") + throw TemplatePathResolverError.missingTemplateType } } @@ -106,6 +106,27 @@ struct TemplatePathResolver { func resolve() async throws -> Basics.AbsolutePath { try await self.fetcher.fetch() } + + /// Errors thrown by `TemplatePathResolver` during initialization. + enum TemplatePathResolverError: LocalizedError, Equatable { + case missingLocalTemplatePath + case missingGitURLOrRequirement + case missingRegistryIdentityOrRequirement + case missingTemplateType + + var errorDescription: String? { + switch self { + case .missingLocalTemplatePath: + return "Template path must be specified for local templates." + case .missingGitURLOrRequirement: + return "Missing Git URL or requirement for git template." + case .missingRegistryIdentityOrRequirement: + return "Missing registry package identity or requirement." + case .missingTemplateType: + return "Missing --template-type." + } + } + } } /// Fetcher implementation for local file system templates. @@ -144,43 +165,69 @@ struct GitTemplateFetcher: TemplateFetcher { /// Fetches a bare clone of the Git repository to the specified path. func fetch() async throws -> Basics.AbsolutePath { try withTemporaryDirectory(removeTreeOnDeinit: false) { tempDir in - - let url = SourceControlURL(source) - let repositorySpecifier = RepositorySpecifier(url: url) - let repositoryProvider = GitRepositoryProvider() - let bareCopyPath = tempDir.appending(component: "bare-copy") let workingCopyPath = tempDir.appending(component: "working-copy") - try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) - try self.validateDirectory(provider: repositoryProvider, at: bareCopyPath) + + try cloneBareRepository(into: bareCopyPath) + try validateBareRepository(at: bareCopyPath) try FileManager.default.createDirectory( atPath: workingCopyPath.pathString, withIntermediateDirectories: true ) - let repository = try repositoryProvider.createWorkingCopyFromBare( - repository: repositorySpecifier, - sourcePath: bareCopyPath, - at: workingCopyPath, - editable: true - ) - + let repository = try createWorkingCopy(fromBare: bareCopyPath, at: workingCopyPath) try FileManager.default.removeItem(at: bareCopyPath.asURL) - try self.checkout(repository: repository) + + try checkout(repository: repository) return workingCopyPath } } + /// Clones a bare git repository. + /// + /// - Throws: An error is thrown if fetching fails. + private func cloneBareRepository(into path: Basics.AbsolutePath) throws { + let url = SourceControlURL(source) + let repositorySpecifier = RepositorySpecifier(url: url) + let provider = GitRepositoryProvider() + do { + try provider.fetch(repository: repositorySpecifier, to: path) + } catch { + throw GitTemplateFetcherError.cloneFailed(source: source, underlyingError: error) + } + } + /// Validates that the directory contains a valid Git repository. - private func validateDirectory(provider: GitRepositoryProvider, at path: Basics.AbsolutePath) throws { + private func validateBareRepository(at path: Basics.AbsolutePath) throws { + let provider = GitRepositoryProvider() guard try provider.isValidDirectory(path) else { - throw InternalError("Invalid directory at \(path)") + throw GitTemplateFetcherError.invalidRepositoryDirectory(path: path) + } + } + + /// Creates a working copy from a bare directory. + /// + /// - Throws: An error. + private func createWorkingCopy(fromBare barePath: Basics.AbsolutePath, at workingCopyPath: Basics.AbsolutePath) throws -> WorkingCheckout { + let url = SourceControlURL(source) + let repositorySpecifier = RepositorySpecifier(url: url) + let provider = GitRepositoryProvider() + do { + return try provider.createWorkingCopyFromBare( + repository: repositorySpecifier, + sourcePath: barePath, + at: workingCopyPath, + editable: true + ) + } catch { + throw GitTemplateFetcherError.createWorkingCopyFailed(path: workingCopyPath, underlyingError: error) } } + /// Checks out the desired state (branch, tag, revision) in the working copy based on the requirement. /// /// - Throws: An error if no matching version is found in a version range, or if checkout fails. @@ -200,11 +247,35 @@ struct GitTemplateFetcher: TemplateFetcher { let versions = tags.compactMap { Version($0) } let filteredVersions = versions.filter { range.contains($0) } guard let latestVersion = filteredVersions.max() else { - throw InternalError("No tags found within the specified version range \(range)") + throw GitTemplateFetcherError.noMatchingTagInRange(range) } try repository.checkout(tag: latestVersion.description) } } + + enum GitTemplateFetcherError: Error, LocalizedError { + case cloneFailed(source: String, underlyingError: Error) + case invalidRepositoryDirectory(path: Basics.AbsolutePath) + case createWorkingCopyFailed(path: Basics.AbsolutePath, underlyingError: Error) + case checkoutFailed(requirement: PackageDependency.SourceControl.Requirement, underlyingError: Error) + case noMatchingTagInRange(Range) + + var errorDescription: String? { + switch self { + case .cloneFailed(let source, let error): + return "Failed to clone repository from '\(source)': \(error.localizedDescription)" + case .invalidRepositoryDirectory(let path): + return "Invalid Git repository at path: \(path.pathString)" + case .createWorkingCopyFailed(let path, let error): + return "Failed to create working copy at '\(path)': \(error.localizedDescription)" + case .checkoutFailed(let requirement, let error): + return "Failed to checkout using requirement '\(requirement)': \(error.localizedDescription)" + case .noMatchingTagInRange(let range): + return "No Git tags found within version range \(range)" + } + } + } + } /// Fetches a Swift package template from a package registry. @@ -249,11 +320,6 @@ struct RegistryTemplateFetcher: TemplateFetcher { let identity = PackageIdentity.plain(self.packageIdentity) - let version: Version = switch self.requirement { - case .exact(let ver): ver - case .range(let range): range.upperBound - } - let dest = tempDir.appending(component: self.packageIdentity) try await registryClient.downloadSourceArchive( package: identity, @@ -269,19 +335,48 @@ struct RegistryTemplateFetcher: TemplateFetcher { } } + /// Extract the version from the registry requirements + private var version: Version { + switch requirement { + case .exact(let v): return v + case .range(let r): return r.upperBound + } + } + + /// Resolves the registry configuration from shared SwiftPM configuration. /// /// - Returns: Registry configuration to use for fetching packages. - /// - Throws: If configuration files are missing or unreadable. + /// - Throws: If configurations are missing or unreadable. private static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace - .Configuration.Registries - { + .Configuration.Registries { let sharedFile = Workspace.DefaultLocations .registriesConfigurationFile(at: swiftCommandState.sharedConfigurationDirectory) - return try .init( - fileSystem: swiftCommandState.fileSystem, - localRegistriesFile: .none, - sharedRegistriesFile: sharedFile - ) + do { + return try .init( + fileSystem: swiftCommandState.fileSystem, + localRegistriesFile: .none, + sharedRegistriesFile: sharedFile + ) + } catch { + throw RegistryConfigError.failedToLoadConfiguration(file: sharedFile, underlyingError: error) + } } + + /// Errors that can occur while loading Swift package registry configuration. + enum RegistryConfigError: Error, LocalizedError { + /// Indicates the configuration file could not be loaded. + case failedToLoadConfiguration(file: Basics.AbsolutePath, underlyingError: Error) + + var errorDescription: String? { + switch self { + case .failedToLoadConfiguration(let file, let underlyingError): + return """ + Failed to load registry configuration from '\(file.pathString)': \ + \(underlyingError.localizedDescription) + """ + } + } + } + } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 6e81f388f4c..3fa4d4f443c 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -15,8 +15,27 @@ import TSCUtility import Foundation import PackageGraph +public protocol TemplatePluginManager { + func run() async throws + func loadTemplatePlugin() throws -> ResolvedModule + func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [[String]] + func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data + + var swiftCommandState: SwiftCommandState { get } + var template: String? { get } + var packageGraph: ModulesGraph { get } + var scratchDirectory: Basics.AbsolutePath { get } + var args: [String] { get } + + var EXPERIMENTAL_DUMP_HELP: [String] { get } +} + -struct TemplatePluginManager { +/// A utility for obtaining and running a template's plugin . +/// +/// `TemplateIntiializationPluginManager` encapsulates the logic needed to fetch, +/// and run templates' plugins given arguments, based on the template initialization workflow. +struct TemplateInitializationPluginManager: TemplatePluginManager { let swiftCommandState: SwiftCommandState let template: String? @@ -26,6 +45,15 @@ struct TemplatePluginManager { let args: [String] + let EXPERIMENTAL_DUMP_HELP: [String] = ["--", "--experimental-dump-help"] + + var rootPackage: ResolvedPackage { + guard let root = packageGraph.rootPackages.first else { + fatalError("No root package found in the package graph.") + } + return root + } + init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String]) async throws { self.swiftCommandState = swiftCommandState self.template = template @@ -36,56 +64,142 @@ struct TemplatePluginManager { try await swiftCommandState.loadPackageGraph() } } - //revisit for future refactoring - func run(_ initTemplatePackage: InitTemplatePackage) async throws { + /// Manages the logic of running a template and executing on the information provided by the JSON representation of a template's arguments. + /// + /// - Throws: + /// - `TemplatePluginError.executionFailed(underlying: error)` If there was an error during the execution of a template's plugin. + /// - `TemplatePluginError.failedToDecodeToolInfo(underlying: error)` If there is a change in representation between the JSON and the current version of the ToolInfoV0 struct + /// - `TemplatePluginError.execu` + + func run() async throws { + //Load the plugin corresponding to the template let commandLinePlugin = try loadTemplatePlugin() - let output = try await TemplatePluginRunner.run( - plugin: commandLinePlugin, - package: self.packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: ["--", "--experimental-dump-help"], - swiftCommandState: swiftCommandState - ) - let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let cliResponses = try initTemplatePackage.promptUser(command: toolInfo.command, arguments: args) + // Execute experimental-dump-help to get the JSON representing the template's decision tree + let output: Data + + do { + output = try await executeTemplatePlugin(commandLinePlugin, with: EXPERIMENTAL_DUMP_HELP) + } catch { + throw TemplatePluginError.executionFailed(underlying: error) + } + + //Decode the JSON into ArgumentParserToolInfo ToolInfoV0 struct + let toolInfo: ToolInfoV0 + do { + toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + } catch { + throw TemplatePluginError.failedToDecodeToolInfo(underlying: error) + } + + // Prompt the user for any information needed by the template + let cliResponses: [[String]] = try promptUserForTemplateArguments(using: toolInfo) + + // Execute the template to generate a user's project for response in cliResponses { - _ = try await TemplatePluginRunner.run( - plugin: commandLinePlugin, - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: response, - swiftCommandState: swiftCommandState - ) + do { + let _ = try await executeTemplatePlugin(commandLinePlugin, with: response) + } catch { + throw TemplatePluginError.executionFailed(underlying: error) + } } + } + /// Utilizes the prompting system defined by the struct to prompt user. + /// + /// - Parameters: + /// - toolInfo: The JSON representation of the template's decision tree. + /// + /// - Throws: + /// - Any other errors thrown during the prompting of the user. + /// + /// - Returns: A 2D array of the arguments given by the user, that will be consumed by the template during the project generation phase. + func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [[String]] { + return try TemplatePromptingSystem().promptUser(command: toolInfo.command, arguments: args) } - private func loadTemplatePlugin() throws -> ResolvedModule { - let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: self.packageGraph, limitedTo: nil) + /// Runs the plugin of a template given a set of arguments. + /// + /// - Parameters: + /// - plugin: The resolved module that corresponds to the plugin tied with the template executable. + /// - arguments: A 2D array of arguments that will be passed to the plugin + /// + /// - Throws: + /// - Any Errors thrown during the execution of the template's plugin given a 2D of arguments. + /// + /// - Returns: A data representation of the result of the execution of the template's plugin. + + func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { + return try await TemplatePluginRunner.run( + plugin: plugin, + package: rootPackage, + packageGraph: packageGraph, + arguments: arguments, + swiftCommandState: swiftCommandState + ) + } - guard let commandPlugin = matchingPlugins.first else { - guard let template = template - else { throw ValidationError("No templates were found in \(packageGraph.rootPackages.first!.path)") } //better error message + /// Loads the plugin that corresponds to the template's name. + /// + /// - Throws: + /// - `TempaltePluginError.noMatchingTemplate(name: String?)` if there are no plugins corresponding to the desired template. + /// - `TemplatePluginError.multipleMatchingTemplates(names: [String]` if the search returns more than one plugin given a desired template + /// + /// - Returns: A data representation of the result of the execution of the template's plugin. - throw ValidationError("No templates were found that match the name \(template)") - } + internal func loadTemplatePlugin() throws -> ResolvedModule { + let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: self.packageGraph, limitedTo: nil) - guard matchingPlugins.count == 1 else { - let templateNames = matchingPlugins.compactMap { module in - let plugin = module.underlying as! PluginModule - guard case .command(let intent, _) = plugin.capability else { return String?.none } + switch matchingPlugins.count { + case 0: + throw TemplatePluginError.noMatchingTemplate(name: self.template) + case 1: + return matchingPlugins[0] + default: + let names = matchingPlugins.compactMap { plugin in + (plugin.underlying as? PluginModule)?.capability.commandInvocationVerb + } + throw TemplatePluginError.multipleMatchingTemplates(names: names) + } + } - return intent.invocationVerb + enum TemplatePluginError: Error, CustomStringConvertible { + case noRootPackage + case noMatchingTemplate(name: String?) + case multipleMatchingTemplates(names: [String]) + case failedToDecodeToolInfo(underlying: Error) + case executionFailed(underlying: Error) + + var description: String { + switch self { + case .noRootPackage: + return "No root package found in the package graph." + case let .noMatchingTemplate(name): + let templateName = name ?? "" + return "No templates found matching '\(templateName)" + case let .multipleMatchingTemplates(names): + return """ + Multiple templates matched. Use `--type` to specify one of the following: \(names.joined(separator: ", ")) + """ + case let .failedToDecodeToolInfo(underlying): + return "Failed to decode template tool info: \(underlying.localizedDescription)" + case let .executionFailed(underlying): + return "Plugin execution failed: \(underlying.localizedDescription)" } - throw ValidationError( - "More than one template was found in the package. Please use `--type` along with one of the available templates: \(templateNames.joined(separator: ", "))" - ) } + } +} - return commandPlugin +private extension PluginCapability { + var commandInvocationVerb: String? { + guard case .command(let intent, _) = self else { return nil } + return intent.invocationVerb } } + + + +//struct TemplateTestingPluginManager: TemplatePluginManager diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 9073cbfda79..c3b0991de24 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -46,7 +46,7 @@ public final class InitTemplatePackage { let installedSwiftPMConfiguration: InstalledSwiftPMConfiguration /// The name of the package to create. - var packageName: String + public var packageName: String /// The path to the template files. var templatePath: Basics.AbsolutePath @@ -191,7 +191,14 @@ public final class InitTemplatePackage { verbose: false ) } +} + + +public final class TemplatePromptingSystem { + + + public init() {} /// Prompts the user for input based on the given command definition and arguments. /// /// This method collects responses for a command's arguments by first validating any user-provided @@ -588,6 +595,7 @@ public final class InitTemplatePackage { } } } + } /// An error enum representing various template-related errors. From 4bb255283275b1ab853fdc2f41997ff43f2f0d63 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 13 Aug 2025 13:41:47 -0400 Subject: [PATCH 079/225] refactoring templates + fixing bug where git and template id was not registered + readding resolution to template if only one template in package --- Sources/Commands/PackageCommands/Init.swift | 18 +- .../PackageCommands/ShowTemplates.swift | 179 ++++++++---------- ...ackageInitializationDirectoryManager.swift | 17 +- .../PackageInitializer.swift | 46 ++++- 4 files changed, 141 insertions(+), 119 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 0bc142f9c0f..31b4da6cc12 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -189,6 +189,9 @@ struct PackageInitConfiguration { let validatePackage: Bool? let args: [String] let versionResolver: DependencyRequirementResolver? + let directory: Basics.AbsolutePath? + let url: String? + let packageID: String? init( swiftCommandState: SwiftCommandState, @@ -217,6 +220,9 @@ struct PackageInitConfiguration { self.globalOptions = globalOptions self.validatePackage = validatePackage self.args = args + self.directory = directory + self.url = url + self.packageID = packageID let sourceResolver = DefaultTemplateSourceResolver() self.templateSource = sourceResolver.resolveSource( @@ -251,9 +257,9 @@ struct PackageInitConfiguration { cwd: cwd, templateSource: templateSource, templateName: initMode, - templateDirectory: nil, - templateURL: nil, - templatePackageID: nil, + templateDirectory: self.directory, + templateURL: self.url, + templatePackageID: self.packageID, versionResolver: versionResolver, buildOptions: buildOptions, globalOptions: globalOptions, @@ -274,7 +280,7 @@ struct PackageInitConfiguration { } -struct VersionFlags { +public struct VersionFlags { let exact: Version? let revision: String? let branch: String? @@ -292,15 +298,15 @@ protocol TemplateSourceResolver { ) -> InitTemplatePackage.TemplateSource? } -struct DefaultTemplateSourceResolver: TemplateSourceResolver { +public struct DefaultTemplateSourceResolver: TemplateSourceResolver { func resolveSource( directory: Basics.AbsolutePath?, url: String?, packageID: String? ) -> InitTemplatePackage.TemplateSource? { - if directory != nil { return .local } if url != nil { return .git } if packageID != nil { return .registry } + if directory != nil { return .local } return nil } } diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 5c840cf30d5..2049e4a62a9 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -74,6 +74,31 @@ struct ShowTemplates: AsyncSwiftCommand { func run(_ swiftCommandState: SwiftCommandState) async throws { + // precheck() needed, extremely similar to the Init precheck, can refactor possibly + + let cwd = swiftCommandState.fileSystem.currentWorkingDirectory + let source = try resolveSource(cwd: cwd) + let resolvedPath = try await resolveTemplatePath(using: swiftCommandState, source: source) + let templates = try await loadTemplates(from: resolvedPath, swiftCommandState: swiftCommandState) + try await displayTemplates(templates, at: resolvedPath, using: swiftCommandState) + try cleanupTemplate(source: source, path: resolvedPath, fileSystem: swiftCommandState.fileSystem) + } + + private func resolveSource(cwd: AbsolutePath?) throws -> InitTemplatePackage.TemplateSource { + guard let source = DefaultTemplateSourceResolver().resolveSource( + directory: cwd, + url: self.templateURL, + packageID: self.templatePackageID + ) else { + throw ValidationError("No template source specified. Provide --url or run in a valid package directory.") + } + return source + } + + + + private func resolveTemplatePath(using swiftCommandState: SwiftCommandState, source: InitTemplatePackage.TemplateSource) async throws -> Basics.AbsolutePath { + let requirementResolver = DependencyRequirementResolver( exact: exact, revision: revision, @@ -83,95 +108,68 @@ struct ShowTemplates: AsyncSwiftCommand { to: to ) - let registryRequirement: PackageDependency.Registry.Requirement? = - try? requirementResolver.resolveRegistry() - - let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = - try? requirementResolver.resolveSourceControl() - - var resolvedTemplatePath: Basics.AbsolutePath - - var templateSource: InitTemplatePackage.TemplateSource - if let templateURL = self.templateURL { - // Download and resolve the Git-based template. - resolvedTemplatePath = try await TemplatePathResolver( - source: .git, - templateDirectory: nil, - templateURL: templateURL, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - packageIdentity: self.templatePackageID, - swiftCommandState: swiftCommandState - ).resolve() - - templateSource = .git - - } else if let _ = self.templatePackageID { - // Download and resolve the Git-based template. - resolvedTemplatePath = try await TemplatePathResolver( - source: .registry, - templateDirectory: nil, - templateURL: nil, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - packageIdentity: self.templatePackageID, - swiftCommandState: swiftCommandState - ).resolve() - - templateSource = .registry - - } else { - // Use the current working directory. - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("No template URL provided and no current directory") - } + let registryRequirement = try? requirementResolver.resolveRegistry() + let sourceControlRequirement = try? requirementResolver.resolveSourceControl() + + return try await TemplatePathResolver( + source: source, + templateDirectory: swiftCommandState.fileSystem.currentWorkingDirectory, + templateURL: self.templateURL, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: self.templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() + } - resolvedTemplatePath = try await TemplatePathResolver( - source: .local, - templateDirectory: cwd, - templateURL: nil, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - packageIdentity: nil, - swiftCommandState: swiftCommandState - ).resolve() - - templateSource = .local + private func loadTemplates(from path: AbsolutePath, swiftCommandState: SwiftCommandState) async throws -> [Template] { + let graph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: path) { _, _ in + try await swiftCommandState.loadPackageGraph() } - // Clean up downloaded package after execution. - defer { - if templateSource == .git { - try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) - } else if templateSource == .registry { - let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL - try? FileManager.default.removeItem(at: parentDirectoryURL) - } + let rootPackages = graph.rootPackages.map(\.identity) + + return graph.allModules.filter(\.underlying.template).map { + Template(package: rootPackages.contains($0.packageIdentity) ? nil : $0.packageIdentity.description, name: $0.name) } + } - // Load the package graph. - let packageGraph = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in - try await swiftCommandState.loadPackageGraph() + private func getDescription(_ swiftCommandState: SwiftCommandState, template: String) async throws -> String { + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() + + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope + ) + guard let rootManifest = rootManifests.values.first else { + throw InternalError("invalid manifests at \(root.packages)") } - let rootPackages = packageGraph.rootPackages.map(\.identity) + let products = rootManifest.products + let targets = rootManifest.targets - // Extract executable modules marked as templates. - let templates = packageGraph.allModules.filter(\.underlying.template).map { module -> Template in - if !rootPackages.contains(module.packageIdentity) { - return Template(package: module.packageIdentity.description, name: module.name) - } else { - return Template(package: String?.none, name: module.name) - } + if let target = targets.first(where: { $0.name == template }), + let options = target.templateInitializationOptions, + case .packageInit(_, _, let description) = options { + return description } - // Display templates in the requested format. + throw InternalError( + "Could not find template \(template)" + ) + } + + private func displayTemplates( + _ templates: [Template], + at path: AbsolutePath, + using swiftCommandState: SwiftCommandState + ) async throws { switch self.format { case .flatlist: for template in templates.sorted(by: { $0.name < $1.name }) { - let description = try await swiftCommandState.withTemporaryWorkspace(switchingTo: resolvedTemplatePath) {_, _ in + let description = try await swiftCommandState.withTemporaryWorkspace(switchingTo: path) { _, _ in try await getDescription(swiftCommandState, template: template.name) } if let package = template.package { @@ -183,6 +181,7 @@ struct ShowTemplates: AsyncSwiftCommand { case .json: let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(templates) if let output = String(data: data, encoding: .utf8) { print(output) @@ -190,34 +189,14 @@ struct ShowTemplates: AsyncSwiftCommand { } } - private func getDescription(_ swiftCommandState: SwiftCommandState, template: String) async throws -> String { - let workspace = try swiftCommandState.getActiveWorkspace() - let root = try swiftCommandState.getWorkspaceRoot() + private func cleanupTemplate(source: InitTemplatePackage.TemplateSource, path: AbsolutePath, fileSystem: FileSystem) throws { + try TemplateInitializationDirectoryManager(fileSystem: fileSystem) + .cleanupTemporary(templateSource: source, path: path, temporaryDirectory: nil) + } + - let rootManifests = try await workspace.loadRootManifests( - packages: root.packages, - observabilityScope: swiftCommandState.observabilityScope - ) - guard let rootManifest = rootManifests.values.first else { - throw InternalError("invalid manifests at \(root.packages)") - } - let products = rootManifest.products - let targets = rootManifest.targets - for _ in products { - if let target: TargetDescription = targets.first(where: { $0.name == template }) { - if let options = target.templateInitializationOptions { - if case .packageInit(_, _, let description) = options { - return description - } - } - } - } - throw InternalError( - "Could not find template \(template)" - ) - } /// Represents a discovered template. struct Template: Codable { diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift index 7e5f8b0a781..96f84398b2f 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift @@ -7,7 +7,7 @@ import Foundation import CoreCommands -struct TemplateInitializationDirectoryManager { +public struct TemplateInitializationDirectoryManager { let fileSystem: FileSystem func createTemporaryDirectories() throws -> (stagingPath: Basics.AbsolutePath, cleanUpPath: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) { @@ -42,7 +42,7 @@ struct TemplateInitializationDirectoryManager { } } - func cleanupTemporary(templateSource: InitTemplatePackage.TemplateSource, path: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) throws { + public func cleanupTemporary(templateSource: InitTemplatePackage.TemplateSource, path: Basics.AbsolutePath, temporaryDirectory: Basics.AbsolutePath?) throws { do { switch templateSource { case .git: @@ -56,18 +56,23 @@ struct TemplateInitializationDirectoryManager { case .local: break } - try fileSystem.removeFileTree(tempDir) + + if let tempDir = temporaryDirectory { + try fileSystem.removeFileTree(tempDir) + } + } catch { - throw CleanupError.failedToCleanup(tempDir: tempDir, underlying: error) + throw CleanupError.failedToCleanup(temporaryDirectory: temporaryDirectory, underlying: error) } } enum CleanupError: Error, CustomStringConvertible { - case failedToCleanup(tempDir: Basics.AbsolutePath, underlying: Error) + case failedToCleanup(temporaryDirectory: Basics.AbsolutePath?, underlying: Error) var description: String { switch self { - case .failedToCleanup(let tempDir, let error): + case .failedToCleanup(let temporaryDirectory, let error): + let tempDir = temporaryDirectory?.pathString ?? "" return "Failed to clean up temporary directory at \(tempDir): \(error.localizedDescription)" } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index ea030c04416..d6aeae8bcfb 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -96,9 +96,10 @@ struct TemplatePackageInitializer: PackageInitializer { ) } - try directoryManager.cleanupTemporary(templateSource: templateSource, path: resolvedTemplatePath, tempDir: tempDir) + try directoryManager.cleanupTemporary(templateSource: templateSource, path: resolvedTemplatePath, temporaryDirectory: tempDir) } + //Will have to add checking for git + registry too private func precheck() throws { let manifest = cwd.appending(component: Manifest.filename) guard !swiftCommandState.fileSystem.exists(manifest) else { @@ -124,13 +125,17 @@ struct TemplatePackageInitializer: PackageInitializer { throw TemplatePackageInitializerError.invalidManifestInTemplate(root.packages.description) } + var targetName = templateName + + if targetName == nil { + targetName = try findTemplateName(from: manifest) + } + for target in manifest.targets { - if templateName == nil || target.name == templateName { - if let options = target.templateInitializationOptions { - if case .packageInit(let type, _, _) = options { - return try .init(from: type) - } - } + if target.name == targetName, + let options = target.templateInitializationOptions, + case .packageInit(let type, _, _) = options { + return try .init(from: type) } } @@ -138,6 +143,26 @@ struct TemplatePackageInitializer: PackageInitializer { } } + private func findTemplateName(from manifest: Manifest) throws -> String { + let templateTargets = manifest.targets.compactMap { target -> String? in + if let options = target.templateInitializationOptions, + case .packageInit = options { + return target.name + } + return nil + } + + switch templateTargets.count { + case 0: + throw TemplatePackageInitializerError.noTemplatesInManifest + case 1: + return templateTargets[0] + default: + throw TemplatePackageInitializerError.multipleTemplatesFound(templateTargets) + } + } + + private func setUpPackage( builder: DefaultPackageDependencyBuilder, packageType: InitPackage.PackageType, @@ -162,6 +187,8 @@ struct TemplatePackageInitializer: PackageInitializer { case templateDirectoryNotFound(String) case invalidManifestInTemplate(String) case templateNotFound(String) + case noTemplatesInManifest + case multipleTemplatesFound([String]) var description: String { switch self { @@ -171,6 +198,11 @@ struct TemplatePackageInitializer: PackageInitializer { return "Invalid manifest found in template at \(path)." case .templateNotFound(let templateName): return "Could not find template \(templateName)." + case .noTemplatesInManifest: + return "No templates with packageInit options were found in the manifest." + case .multipleTemplatesFound(let templates): + return "Multiple templates found: \(templates.joined(separator: ", ")). Please specify one using --template." + } } } From ff48313ee0557ec210a29ea7e56a607e8063f6b8 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 14 Aug 2025 14:46:11 -0400 Subject: [PATCH 080/225] refactoring test template + changed structure holding prompting answers --- Sources/Commands/SwiftTestCommand.swift | 623 ------------------ .../TestCommands/TestTemplateCommand.swift | 457 +++++++++++++ .../TemplatePluginManager.swift | 8 +- .../TemplateTesterManager.swift | 488 ++++++++++++++ 4 files changed, 947 insertions(+), 629 deletions(-) create mode 100644 Sources/Commands/TestCommands/TestTemplateCommand.swift create mode 100644 Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index 8e348d54d27..6a0b2be41ae 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -710,143 +710,6 @@ extension SwiftTestCommand { } } -final class ArgumentTreeNode { - let command: CommandInfoV0 - var children: [ArgumentTreeNode] = [] - - var arguments: [String: TemplatePromptingSystem.ArgumentResponse] = [:] - - init(command: CommandInfoV0) { - self.command = command - } - - static func build(from command: CommandInfoV0) -> ArgumentTreeNode { - let node = ArgumentTreeNode(command: command) - if let subcommands = command.subcommands { - node.children = subcommands.map { build(from: $0) } - } - return node - } - - func collectUniqueArguments() -> [String: ArgumentInfoV0] { - var dict: [String: ArgumentInfoV0] = [:] - if let args = command.arguments { - for arg in args { - let key = arg.preferredName?.name ?? arg.valueName ?? UUID().uuidString - dict[key] = arg - } - } - for child in children { - let childDict = child.collectUniqueArguments() - for (key, arg) in childDict { - dict[key] = arg - } - } - return dict - } - - - static func promptForUniqueArguments( - uniqueArguments: [String: ArgumentInfoV0] - ) -> [String: TemplatePromptingSystem.ArgumentResponse] { - var collected: [String: TemplatePromptingSystem.ArgumentResponse] = [:] - let argsToPrompt = Array(uniqueArguments.values) - - // Prompt for all unique arguments at once - _ = TemplatePromptingSystem.UserPrompter.prompt(for: argsToPrompt, collected: &collected) - - return collected - } - - //Fill node arguments by assigning the prompted values for keys it requires - func fillArguments(with responses: [String: TemplatePromptingSystem.ArgumentResponse]) { - if let args = command.arguments { - for arg in args { - if let resp = responses[arg.valueName ?? ""] { - arguments[arg.valueName ?? ""] = resp - } - } - } - // Recurse - for child in children { - child.fillArguments(with: responses) - } - } - - func printTree(level: Int = 0) { - let indent = String(repeating: " ", count: level) - print("\(indent)- Command: \(command.commandName)") - for (key, response) in arguments { - print("\(indent) - \(key): \(response.values)") - } - for child in children { - child.printTree(level: level + 1) - } - } - - func createCLITree(root: ArgumentTreeNode) -> [[ArgumentTreeNode]] { - // Base case: If it's a leaf node, return a path with only itself - if root.children.isEmpty { - return [[root]] - } - - var result: [[ArgumentTreeNode]] = [] - - // Recurse into children and prepend the current root to each path - for child in root.children { - let childPaths = createCLITree(root: child) - for path in childPaths { - result.append([root] + path) - } - } - - return result - } -} - -extension ArgumentTreeNode { - /// Traverses all command paths and returns CLI paths along with their arguments - func collectCommandPaths( - currentPath: [String] = [], - currentArguments: [String: TemplatePromptingSystem.ArgumentResponse] = [:] - ) -> [([String], [String: TemplatePromptingSystem.ArgumentResponse])] { - let newPath = currentPath + [command.commandName] - - var combinedArguments = currentArguments - for (key, value) in arguments { - combinedArguments[key] = value - } - - if children.isEmpty { - return [(newPath, combinedArguments)] - } - - var results: [([String], [String: TemplatePromptingSystem.ArgumentResponse])] = [] - for child in children { - results += child.collectCommandPaths( - currentPath: newPath, - currentArguments: combinedArguments - ) - } - - return results - } -} - -extension DispatchTimeInterval { - var seconds: TimeInterval { - switch self { - case .seconds(let s): return TimeInterval(s) - case .milliseconds(let ms): return TimeInterval(Double(ms) / 1000) - case .microseconds(let us): return TimeInterval(Double(us) / 1_000_000) - case .nanoseconds(let ns): return TimeInterval(Double(ns) / 1_000_000_000) - case .never: return 0 - @unknown default: return 0 - } - } -} - - extension SwiftTestCommand { struct Last: SwiftCommand { @OptionGroup(visibility: .hidden) @@ -860,492 +723,6 @@ extension SwiftTestCommand { } } - struct Template: AsyncSwiftCommand { - static let configuration = CommandConfiguration( - abstract: "Test the various outputs of a template" - ) - - @OptionGroup(visibility: .hidden) - var globalOptions: GlobalOptions - - @OptionGroup() - var sharedOptions: SharedOptions - - @Option(help: "Specify name of the template") - var templateName: String? - - @Option( - name: .customLong("output-path"), - help: "Specify the output path of the created templates.", - completion: .directory - ) - public var outputDirectory: AbsolutePath - - @OptionGroup(visibility: .hidden) - var buildOptions: BuildCommandOptions - - /// Predetermined arguments specified by the consumer. - @Argument( - help: "Predetermined arguments to pass to the template." - ) - var args: [String] = [] - - @Flag(help: "Dry-run to display argument tree") - var dryRun: Bool = false - - /// Output format for the templates result. - /// - /// Can be either `.matrix` (default) or `.json`. - @Option(help: "Set the output format.") - var format: ShowTestTemplateOutput = .matrix - - func run(_ swiftCommandState: SwiftCommandState) async throws { - let manifest = outputDirectory.appending(component: Manifest.filename) - let fileSystem = swiftCommandState.fileSystem - let directoryExists = fileSystem.exists(outputDirectory) - - if !directoryExists { - try FileManager.default.createDirectory( - at: outputDirectory.asURL, - withIntermediateDirectories: true - ) - } else { - if fileSystem.exists(manifest) { - throw ValidationError("Package.swift was found in \(outputDirectory).") - } - } - - // Load Package Graph - let packageGraph = try await swiftCommandState.loadPackageGraph() - - // Find matching plugin - let matchingPlugins = PluginCommand.findPlugins(matching: self.templateName, in: packageGraph, limitedTo: nil) - guard let commandPlugin = matchingPlugins.first else { - throw ValidationError("No templates were found that match the name \(self.templateName ?? "")") - } - - guard matchingPlugins.count == 1 else { - let templateNames = matchingPlugins.compactMap { module in - let plugin = module.underlying as! PluginModule - guard case .command(let intent, _) = plugin.capability else { return String?.none } - - return intent.invocationVerb - } - throw ValidationError( - "More than one template was found in the package. Please use `--template-name` along with one of the available templates: \(templateNames.joined(separator: ", "))" - ) - } - - let output = try await TemplatePluginRunner.run( - plugin: commandPlugin, - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: ["--", "--experimental-dump-help"], - swiftCommandState: swiftCommandState - ) - - let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let root = ArgumentTreeNode.build(from: toolInfo.command) - - let uniqueArguments = root.collectUniqueArguments() - let responses = ArgumentTreeNode.promptForUniqueArguments(uniqueArguments: uniqueArguments) - root.fillArguments(with: responses) - - if dryRun { - root.printTree() - return - } - - let cliArgumentPaths = root.createCLITree(root: root) - - func checkConditions(_ swiftCommandState: SwiftCommandState, template: String?) async throws -> InitPackage.PackageType { - let workspace = try swiftCommandState.getActiveWorkspace() - let root = try swiftCommandState.getWorkspaceRoot() - - let rootManifests = try await workspace.loadRootManifests( - packages: root.packages, - observabilityScope: swiftCommandState.observabilityScope - ) - guard let rootManifest = rootManifests.values.first else { - throw InternalError("Invalid manifests at \(root.packages)") - } - - let targets = rootManifest.targets - for target in targets { - if template == nil || target.name == template, - let options = target.templateInitializationOptions, - case .packageInit(let templateType, _, _) = options { - return try .init(from: templateType) - } - } - - throw ValidationError("Could not find \(template != nil ? "template \(template!)" : "any templates")") - } - - let initialPackageType: InitPackage.PackageType = try await checkConditions(swiftCommandState, template: templateName) - - var buildMatrix: [String: BuildInfo] = [:] - - for path in cliArgumentPaths { - let commandNames = path.map { $0.command.commandName } - let folderName = commandNames.joined(separator: "-") - let destinationAbsolutePath = outputDirectory.appending(component: folderName) - let destinationURL = destinationAbsolutePath.asURL - - print("\nGenerating \(folderName)") - try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true) - - - let buildInfo = try await testTemplateIntialization( - commandPlugin: commandPlugin, - swiftCommandState: swiftCommandState, - buildOptions: buildOptions, - destinationAbsolutePath: destinationAbsolutePath, - testingFolderName: folderName, - argumentPath: path, - initialPackageType: initialPackageType - ) - - buildMatrix[folderName] = buildInfo - - - } - - switch self.format { - case .matrix: - printBuildMatrix(buildMatrix) - case .json: - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - do { - let data = try encoder.encode(buildMatrix) - if let output = String(data: data, encoding: .utf8) { - print(output) - } - } catch { - print("Failed to encode JSON: \(error)") - } - } - - func printBuildMatrix(_ matrix: [String: BuildInfo]) { - let header = [ - "Argument Branch".padding(toLength: 30, withPad: " ", startingAt: 0), - "Gen Success".padding(toLength: 12, withPad: " ", startingAt: 0), - "Gen Time(s)".padding(toLength: 12, withPad: " ", startingAt: 0), - "Build Success".padding(toLength: 14, withPad: " ", startingAt: 0), - "Build Time(s)".padding(toLength: 14, withPad: " ", startingAt: 0), - "Log File" - ] - print(header.joined(separator: " ")) - - for (folder, info) in matrix { - let row = [ - folder.padding(toLength: 30, withPad: " ", startingAt: 0), - String(info.generationSuccess).padding(toLength: 12, withPad: " ", startingAt: 0), - String(format: "%.2f", info.generationDuration.seconds).padding(toLength: 12, withPad: " ", startingAt: 0), - String(info.buildSuccess).padding(toLength: 14, withPad: " ", startingAt: 0), - String(format: "%.2f", info.buildDuration.seconds).padding(toLength: 14, withPad: " ", startingAt: 0), - info.logFilePath ?? "-" - ] - print(row.joined(separator: " ")) - } - } - } - - /// Output format modes for the `ShowTemplates` command. - enum ShowTestTemplateOutput: String, RawRepresentable, CustomStringConvertible, ExpressibleByArgument, CaseIterable { - /// Output as a matrix. - case matrix - /// Output as a JSON array of template along with fields. - case json - - public init?(rawValue: String) { - switch rawValue.lowercased() { - case "matrix": - self = .matrix - case "json": - self = .json - default: - return nil - } - } - - public var description: String { - switch self { - case .matrix: "matrix" - case .json: "json" - } - } - } - - struct BuildInfo: Encodable { - var generationDuration: DispatchTimeInterval - var buildDuration: DispatchTimeInterval - var generationSuccess: Bool - var buildSuccess: Bool - var logFilePath: String? - - - public init(generationDuration: DispatchTimeInterval, buildDuration: DispatchTimeInterval, generationSuccess: Bool, buildSuccess: Bool, logFilePath: String? = nil) { - self.generationDuration = generationDuration - self.buildDuration = buildDuration - self.generationSuccess = generationSuccess - self.buildSuccess = buildSuccess - self.logFilePath = logFilePath - } - enum CodingKeys: String, CodingKey { - case generationDuration - case buildDuration - case generationSuccess - case buildSuccess - case logFilePath - } - - // Encoding - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(Self.dispatchTimeIntervalToSeconds(generationDuration), forKey: .generationDuration) - try container.encode(Self.dispatchTimeIntervalToSeconds(buildDuration), forKey: .buildDuration) - try container.encode(generationSuccess, forKey: .generationSuccess) - try container.encode(buildSuccess, forKey: .buildSuccess) - if logFilePath == nil { - try container.encodeNil(forKey: .logFilePath) - } else { - try container.encodeIfPresent(logFilePath, forKey: .logFilePath) - } - } - - // Helpers - private static func dispatchTimeIntervalToSeconds(_ interval: DispatchTimeInterval) -> Double { - switch interval { - case .seconds(let s): return Double(s) - case .milliseconds(let ms): return Double(ms) / 1000 - case .microseconds(let us): return Double(us) / 1_000_000 - case .nanoseconds(let ns): return Double(ns) / 1_000_000_000 - case .never: return -1 // or some sentinel value - @unknown default: return -1 - } - } - - private static func secondsToDispatchTimeInterval(_ seconds: Double) -> DispatchTimeInterval { - return .milliseconds(Int(seconds * 1000)) - } - - } - - private func testTemplateIntialization( - commandPlugin: ResolvedModule, - swiftCommandState: SwiftCommandState, - buildOptions: BuildCommandOptions, - destinationAbsolutePath: AbsolutePath, - testingFolderName: String, - argumentPath: [ArgumentTreeNode], - initialPackageType: InitPackage.PackageType - ) async throws -> BuildInfo { - - let generationStart = DispatchTime.now() - var generationDuration: DispatchTimeInterval = .never - var buildDuration: DispatchTimeInterval = .never - var generationSuccess = false - var buildSuccess = false - var logFilePath: String? = nil - - - var pluginOutput: String = "" - - do { - - let logPath = destinationAbsolutePath.appending("generation-output.log").pathString - - // Redirect stdout/stderr to file before starting generation - let (origOut, origErr) = try redirectStdoutAndStderr(to: logPath) - - defer { - restoreStdoutAndStderr(originalStdout: origOut, originalStderr: origErr) - } - - - let initTemplatePackage = try InitTemplatePackage( - name: testingFolderName, - initMode: .fileSystem(name: templateName, path: swiftCommandState.originalWorkingDirectory.pathString), - templatePath: swiftCommandState.originalWorkingDirectory, - fileSystem: swiftCommandState.fileSystem, - packageType: initialPackageType, - supportedTestingLibraries: [], - destinationPath: destinationAbsolutePath, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration - ) - - try initTemplatePackage.setupTemplateManifest() - - let generatedGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in - try await swiftCommandState.loadPackageGraph() - } - - try await TemplateBuildSupport.buildForTesting( - swiftCommandState: swiftCommandState, - buildOptions: buildOptions, - testingFolder: destinationAbsolutePath - ) - - for (index, node) in argumentPath.enumerated() { - let currentPath = index == 0 ? [] : argumentPath[1...index].map { $0.command.commandName } - let currentArgs = node.arguments.values.flatMap { $0.commandLineFragments } - let fullCommand = currentPath + currentArgs - - try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in - do { - let outputData = try await TemplatePluginRunner.run( - plugin: commandPlugin, - package: generatedGraph.rootPackages.first!, - packageGraph: generatedGraph, - arguments: fullCommand, - swiftCommandState: swiftCommandState - ) - pluginOutput = String(data: outputData, encoding: .utf8) ?? "[Invalid UTF-8 output]" - print(pluginOutput) - } - } - } - - generationDuration = generationStart.distance(to: .now()) - generationSuccess = true - - if generationSuccess { - try FileManager.default.removeItem(atPath: logPath) - } - - } catch { - generationDuration = generationStart.distance(to: .now()) - generationSuccess = false - - - let logPath = destinationAbsolutePath.appending("generation-output.log") - let outputPath = logPath.pathString - let capturedOutput = (try? String(contentsOfFile: outputPath)) ?? "" - - let unifiedLog = """ - Error: - -------------------------------- - \(error.localizedDescription) - - Plugin Output (before failure): - -------------------------------- - \(capturedOutput) - """ - - try? unifiedLog.write(to: logPath.asURL, atomically: true, encoding: .utf8) - logFilePath = logPath.pathString - - } - // Only start the build step if generation was successful - if generationSuccess { - let buildStart = DispatchTime.now() - do { - - let logPath = destinationAbsolutePath.appending("build-output.log").pathString - - // Redirect stdout/stderr to file before starting build - let (origOut, origErr) = try redirectStdoutAndStderr(to: logPath) - - defer { - restoreStdoutAndStderr(originalStdout: origOut, originalStderr: origErr) - } - - - try await TemplateBuildSupport.buildForTesting( - swiftCommandState: swiftCommandState, - buildOptions: buildOptions, - testingFolder: destinationAbsolutePath - ) - - buildDuration = buildStart.distance(to: .now()) - buildSuccess = true - - - if buildSuccess { - try FileManager.default.removeItem(atPath: logPath) - } - - - } catch { - buildDuration = buildStart.distance(to: .now()) - buildSuccess = false - - let logPath = destinationAbsolutePath.appending("build-output.log") - let outputPath = logPath.pathString - let capturedOutput = (try? String(contentsOfFile: outputPath)) ?? "" - - let unifiedLog = """ - Error: - -------------------------------- - \(error.localizedDescription) - - Build Output (before failure): - -------------------------------- - \(capturedOutput) - """ - - try? unifiedLog.write(to: logPath.asURL, atomically: true, encoding: .utf8) - logFilePath = logPath.pathString - - } - } - - return BuildInfo( - generationDuration: generationDuration, - buildDuration: buildDuration, - generationSuccess: generationSuccess, - buildSuccess: buildSuccess, - logFilePath: logFilePath - ) - } - - func writeLogToFile(_ content: String, to directory: AbsolutePath, named fileName: String) throws { - let fileURL = URL(fileURLWithPath: directory.pathString).appendingPathComponent(fileName) - try content.write(to: fileURL, atomically: true, encoding: .utf8) - } - - func redirectStdoutAndStderr(to path: String) throws -> (originalStdout: Int32, originalStderr: Int32) { - // Open file for writing (create/truncate) - guard let file = fopen(path, "w") else { - throw NSError(domain: "RedirectError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot open file for writing"]) - } - - let originalStdout = dup(STDOUT_FILENO) - let originalStderr = dup(STDERR_FILENO) - - dup2(fileno(file), STDOUT_FILENO) - dup2(fileno(file), STDERR_FILENO) - - fclose(file) - - return (originalStdout, originalStderr) - } - - func restoreStdoutAndStderr(originalStdout: Int32, originalStderr: Int32) { - fflush(stdout) - fflush(stderr) - - if dup2(originalStdout, STDOUT_FILENO) == -1 { - perror("dup2 stdout restore failed") - } - if dup2(originalStderr, STDERR_FILENO) == -1 { - perror("dup2 stderr restore failed") - } - - fflush(stdout) - fflush(stderr) - - if close(originalStdout) == -1 { - perror("close originalStdout failed") - } - if close(originalStderr) == -1 { - perror("close originalStderr failed") - } - } - } - struct List: AsyncSwiftCommand { static let configuration = CommandConfiguration( abstract: "Lists test methods in specifier format" diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift new file mode 100644 index 00000000000..2c0fd2d3cc3 --- /dev/null +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -0,0 +1,457 @@ +import ArgumentParser +import ArgumentParserToolInfo + +@_spi(SwiftPMInternal) +import Basics + +import _Concurrency + +@_spi(SwiftPMInternal) +import CoreCommands + +import Dispatch +import Foundation +import PackageGraph + +@_spi(SwiftPMInternal) +import PackageModel + +import SPMBuildCore +import TSCUtility + +import func TSCLibc.exit +import Workspace + +import class Basics.AsyncProcess +import struct TSCBasic.ByteString +import struct TSCBasic.FileSystemError +import enum TSCBasic.JSON +import var TSCBasic.stdoutStream +import class TSCBasic.SynchronizedQueue +import class TSCBasic.Thread + + + +extension DispatchTimeInterval { + var seconds: TimeInterval { + switch self { + case .seconds(let s): return TimeInterval(s) + case .milliseconds(let ms): return TimeInterval(Double(ms) / 1000) + case .microseconds(let us): return TimeInterval(Double(us) / 1_000_000) + case .nanoseconds(let ns): return TimeInterval(Double(ns) / 1_000_000_000) + case .never: return 0 + @unknown default: return 0 + } + } +} + + +//DEAL WITH THIS LATER +public struct TemplateTestingDirectoryManager { + let fileSystem: FileSystem + + //revisit + func createTemporaryDirectories(directories: Set) throws -> [Basics.AbsolutePath] { + + var result: [Basics.AbsolutePath] = [] + for directory in directories { + let dirPath = try fileSystem.tempDirectory.appending(component: directory) + try fileSystem.createDirectory(dirPath) + result.append(dirPath) + } + + return result + } + + func createOutputDirectory(outputDirectoryPath: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) throws { + let manifest = outputDirectoryPath.appending(component: Manifest.filename) + let fileSystem = swiftCommandState.fileSystem + let directoryExists = fileSystem.exists(outputDirectoryPath) + + if !directoryExists { + try FileManager.default.createDirectory( + at: outputDirectoryPath.asURL, + withIntermediateDirectories: true + ) + } else { + if fileSystem.exists(manifest) { + throw ValidationError("Package.swift was found in \(outputDirectoryPath).") + } + } + + } +} + + +extension SwiftTestCommand { + struct Template: AsyncSwiftCommand { + static let configuration = CommandConfiguration( + abstract: "Test the various outputs of a template" + ) + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + @OptionGroup() + var sharedOptions: SharedOptions + + @Option(help: "Specify name of the template") + var templateName: String? + + @Option( + name: .customLong("output-path"), + help: "Specify the output path of the created templates.", + completion: .directory + ) + public var outputDirectory: AbsolutePath + + @OptionGroup(visibility: .hidden) + var buildOptions: BuildCommandOptions + + /// Predetermined arguments specified by the consumer. + @Argument( + help: "Predetermined arguments to pass to the template." + ) + var args: [String] = [] + + @Flag(help: "Dry-run to display argument tree") + var dryRun: Bool = false + + /// Output format for the templates result. + /// + /// Can be either `.matrix` (default) or `.json`. + @Option(help: "Set the output format.") + var format: ShowTestTemplateOutput = .matrix + + + func run(_ swiftCommandState: SwiftCommandState) async throws { + + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw ValidationError("Could not determine current working directory.") + } + + let directoryManager = TemplateTestingDirectoryManager(fileSystem: swiftCommandState.fileSystem) + try directoryManager.createOutputDirectory(outputDirectoryPath: outputDirectory, swiftCommandState: swiftCommandState) + + let pluginManager = try await TemplateTesterPluginManager( + swiftCommandState: swiftCommandState, + template: templateName, + scratchDirectory: cwd, + args: args + ) + + let commandPlugin = try pluginManager.loadTemplatePlugin() + let commandLineFragments = try await pluginManager.run() + let packageType = try await inferPackageType(swiftCommandState: swiftCommandState, from: cwd) + + + var buildMatrix: [String: BuildInfo] = [:] + + for commandLine in commandLineFragments { + + let folderName = commandLine.fullPathKey + + buildMatrix[folderName] = try await testDecisionTreeBranch(folderName: folderName, commandLine: commandLine, swiftCommandState: swiftCommandState, packageType: packageType, commandPlugin: commandPlugin) + + } + + switch self.format { + case .matrix: + printBuildMatrix(buildMatrix) + case .json: + printJSONMatrix(buildMatrix) + } + } + + private func testDecisionTreeBranch(folderName: String, commandLine: CommandPath, swiftCommandState: SwiftCommandState, packageType: InitPackage.PackageType, commandPlugin: ResolvedModule) async throws -> BuildInfo { + let destinationPath = outputDirectory.appending(component: folderName) + + swiftCommandState.observabilityScope.emit(debug: "Generating \(folderName)") + try FileManager.default.createDirectory(at: destinationPath.asURL, withIntermediateDirectories: true) + + return try await testTemplateInitialization( + commandPlugin: commandPlugin, + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + destinationAbsolutePath: destinationPath, + testingFolderName: folderName, + argumentPath: commandLine, + initialPackageType: packageType + ) + } + + private func printBuildMatrix(_ matrix: [String: BuildInfo]) { + let header = [ + "Argument Branch".padding(toLength: 30, withPad: " ", startingAt: 0), + "Gen Success".padding(toLength: 12, withPad: " ", startingAt: 0), + "Gen Time(s)".padding(toLength: 12, withPad: " ", startingAt: 0), + "Build Success".padding(toLength: 14, withPad: " ", startingAt: 0), + "Build Time(s)".padding(toLength: 14, withPad: " ", startingAt: 0), + "Log File" + ] + print(header.joined(separator: " ")) + + for (folder, info) in matrix { + let row = [ + folder.padding(toLength: 30, withPad: " ", startingAt: 0), + String(info.generationSuccess).padding(toLength: 12, withPad: " ", startingAt: 0), + String(format: "%.2f", info.generationDuration.seconds).padding(toLength: 12, withPad: " ", startingAt: 0), + String(info.buildSuccess).padding(toLength: 14, withPad: " ", startingAt: 0), + String(format: "%.2f", info.buildDuration.seconds).padding(toLength: 14, withPad: " ", startingAt: 0), + info.logFilePath ?? "-" + ] + print(row.joined(separator: " ")) + } + } + + private func printJSONMatrix(_ matrix: [String: BuildInfo]) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + do { + let data = try encoder.encode(matrix) + if let output = String(data: data, encoding: .utf8) { + print(output) + } + } catch { + print("Failed to encode JSON: \(error)") + } + + } + + private func inferPackageType(swiftCommandState: SwiftCommandState, from templatePath: Basics.AbsolutePath) async throws -> InitPackage.PackageType { + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() + + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope + ) + + guard let manifest = rootManifests.values.first else { + throw ValidationError("") + } + + var targetName = templateName + + if targetName == nil { + targetName = try findTemplateName(from: manifest) + } + + for target in manifest.targets { + if target.name == targetName, + let options = target.templateInitializationOptions, + case .packageInit(let type, _, _) = options { + return try .init(from: type) + } + } + + throw ValidationError("") + } + + + private func findTemplateName(from manifest: Manifest) throws -> String { + let templateTargets = manifest.targets.compactMap { target -> String? in + if let options = target.templateInitializationOptions, + case .packageInit = options { + return target.name + } + return nil + } + + switch templateTargets.count { + case 0: + throw ValidationError("") + case 1: + return templateTargets[0] + default: + throw ValidationError("") + } + } + + + private func testTemplateInitialization( + commandPlugin: ResolvedModule, + swiftCommandState: SwiftCommandState, + buildOptions: BuildCommandOptions, + destinationAbsolutePath: AbsolutePath, + testingFolderName: String, + argumentPath: CommandPath, + initialPackageType: InitPackage.PackageType + ) async throws -> BuildInfo { + + let startGen = DispatchTime.now() + var genSuccess = false + var buildSuccess = false + var genDuration: DispatchTimeInterval = .never + var buildDuration: DispatchTimeInterval = .never + var logPath: String? = nil + + do { + let log = destinationAbsolutePath.appending("generation-output.log").pathString + let (origOut, origErr) = try redirectStdoutAndStderr(to: log) + defer { restoreStdoutAndStderr(originalStdout: origOut, originalStderr: origErr) } + + let initTemplate = try InitTemplatePackage( + name: testingFolderName, + initMode: .fileSystem(name: templateName, path: swiftCommandState.originalWorkingDirectory.pathString), + templatePath: swiftCommandState.originalWorkingDirectory, + fileSystem: swiftCommandState.fileSystem, + packageType: initialPackageType, + supportedTestingLibraries: [], + destinationPath: destinationAbsolutePath, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration + ) + + try initTemplate.setupTemplateManifest() + + let graph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in + try await swiftCommandState.loadPackageGraph() + } + + for (index, command) in argumentPath.commandChain.enumerated() { + let commandArgs = command.arguments.flatMap { $0.commandLineFragments } + let fullCommand = (index == 0) ? [] : Array(argumentPath.commandChain.prefix(index + 1).map(\.commandName)) + commandArgs + + print("Running plugin with args:", fullCommand) + + try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in + _ = try await TemplatePluginRunner.run( + plugin: commandPlugin, + package: graph.rootPackages.first!, + packageGraph: graph, + arguments: fullCommand, + swiftCommandState: swiftCommandState + ) + } + } + + genDuration = startGen.distance(to: .now()) + genSuccess = true + try FileManager.default.removeItem(atPath: log) + } catch { + genDuration = startGen.distance(to: .now()) + genSuccess = false + + let errorLog = destinationAbsolutePath.appending("generation-output.log") + logPath = try? captureAndWriteError( + to: errorLog, + error: error, + context: "Plugin Output (before failure)" + ) + } + + // Build step + if genSuccess { + let buildStart = DispatchTime.now() + do { + let log = destinationAbsolutePath.appending("build-output.log").pathString + let (origOut, origErr) = try redirectStdoutAndStderr(to: log) + defer { restoreStdoutAndStderr(originalStdout: origOut, originalStderr: origErr) } + + try await TemplateBuildSupport.buildForTesting( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + testingFolder: destinationAbsolutePath + ) + + buildDuration = buildStart.distance(to: .now()) + buildSuccess = true + try FileManager.default.removeItem(atPath: log) + } catch { + buildDuration = buildStart.distance(to: .now()) + buildSuccess = false + + let errorLog = destinationAbsolutePath.appending("build-output.log") + logPath = try? captureAndWriteError( + to: errorLog, + error: error, + context: "Build Output (before failure)" + ) + } + } + + return BuildInfo( + generationDuration: genDuration, + buildDuration: buildDuration, + generationSuccess: genSuccess, + buildSuccess: buildSuccess, + logFilePath: logPath + ) + } + + private func captureAndWriteError(to path: AbsolutePath, error: Error, context: String) throws -> String { + let existingOutput = (try? String(contentsOf: path.asURL)) ?? "" + let logContent = + """ + Error: + -------------------------------- + \(error.localizedDescription) + + \(context): + -------------------------------- + \(existingOutput) + """ + try logContent.write(to: path.asURL, atomically: true, encoding: .utf8) + return path.pathString + } + + private func redirectStdoutAndStderr(to path: String) throws -> (originalStdout: Int32, originalStderr: Int32) { + guard let file = fopen(path, "w") else { + throw NSError(domain: "RedirectError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot open file for writing"]) + } + + let originalStdout = dup(STDOUT_FILENO) + let originalStderr = dup(STDERR_FILENO) + dup2(fileno(file), STDOUT_FILENO) + dup2(fileno(file), STDERR_FILENO) + fclose(file) + return (originalStdout, originalStderr) + } + + private func restoreStdoutAndStderr(originalStdout: Int32, originalStderr: Int32) { + fflush(stdout) + fflush(stderr) + + dup2(originalStdout, STDOUT_FILENO) + dup2(originalStderr, STDERR_FILENO) + close(originalStdout) + close(originalStderr) + } + + enum ShowTestTemplateOutput: String, RawRepresentable, CustomStringConvertible, ExpressibleByArgument, CaseIterable { + case matrix + case json + + public var description: String { rawValue } + } + + + struct BuildInfo: Encodable { + var generationDuration: DispatchTimeInterval + var buildDuration: DispatchTimeInterval + var generationSuccess: Bool + var buildSuccess: Bool + var logFilePath: String? + + enum CodingKeys: String, CodingKey { + case generationDuration, buildDuration, generationSuccess, buildSuccess, logFilePath + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(generationDuration.seconds, forKey: .generationDuration) + try container.encode(buildDuration.seconds, forKey: .buildDuration) + try container.encode(generationSuccess, forKey: .generationSuccess) + try container.encode(buildSuccess, forKey: .buildSuccess) + try container.encodeIfPresent(logFilePath, forKey: .logFilePath) + } + } + } +} + +private extension String { + func padded(_ toLength: Int) -> String { + self.padding(toLength: toLength, withPad: " ", startingAt: 0) + } +} + diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 3fa4d4f443c..5db8218f6fd 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -16,9 +16,9 @@ import Foundation import PackageGraph public protocol TemplatePluginManager { - func run() async throws + func loadTemplatePlugin() throws -> ResolvedModule - func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [[String]] + //func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [[String]] func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data var swiftCommandState: SwiftCommandState { get } @@ -199,7 +199,3 @@ private extension PluginCapability { return intent.invocationVerb } } - - - -//struct TemplateTestingPluginManager: TemplatePluginManager diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift new file mode 100644 index 00000000000..565679053a5 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -0,0 +1,488 @@ + +import ArgumentParser +import ArgumentParserToolInfo + +import Basics + +@_spi(SwiftPMInternal) +import CoreCommands + +import PackageModel +import Workspace +import SPMBuildCore +import TSCBasic +import TSCUtility +import Foundation +import PackageGraph + +/// A utility for obtaining and running a template's plugin . +/// +/// `TemplateTesterPluginManager` encapsulates the logic needed to fetch, +/// and run templates' plugins given arguments, based on the template initialization workflow. +public struct TemplateTesterPluginManager: TemplatePluginManager { + public let swiftCommandState: SwiftCommandState + public let template: String? + + public let packageGraph: ModulesGraph + + public let scratchDirectory: Basics.AbsolutePath + + public let args: [String] + + public let EXPERIMENTAL_DUMP_HELP: [String] = ["--", "--experimental-dump-help"] + + var rootPackage: ResolvedPackage { + guard let root = packageGraph.rootPackages.first else { + fatalError("No root package found in the package graph.") + } + return root + } + + init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String]) async throws { + self.swiftCommandState = swiftCommandState + self.template = template + self.scratchDirectory = scratchDirectory + self.args = args + + self.packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: scratchDirectory) { _, _ in + try await swiftCommandState.loadPackageGraph() + } + } + + /// Manages the logic of running a template and executing on the information provided by the JSON representation of a template's arguments. + /// + /// - Throws: + /// - `TemplatePluginError.executionFailed(underlying: error)` If there was an error during the execution of a template's plugin. + /// - `TemplatePluginError.failedToDecodeToolInfo(underlying: error)` If there is a change in representation between the JSON and the current version of the ToolInfoV0 struct + /// - `TemplatePluginError.execute` + + func run() async throws -> [CommandPath] { + //Load the plugin corresponding to the template + + let commandLinePlugin = try loadTemplatePlugin() + + // Execute experimental-dump-help to get the JSON representing the template's decision tree + let output: Data + + do { + output = try await executeTemplatePlugin(commandLinePlugin, with: EXPERIMENTAL_DUMP_HELP) + } catch { + throw TemplatePluginError.executionFailed(underlying: error) + } + + //Decode the JSON into ArgumentParserToolInfo ToolInfoV0 struct + let toolInfo: ToolInfoV0 + + do { + toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + } catch { + throw TemplatePluginError.failedToDecodeToolInfo(underlying: error) + } + + // Prompt the user for any information needed by the template + return try promptUserForTemplateArguments(using: toolInfo) + } + + + + + + /// Utilizes the prompting system defined by the struct to prompt user. + /// + /// - Parameters: + /// - toolInfo: The JSON representation of the template's decision tree. + /// + /// - Throws: + /// - Any other errors thrown during the prompting of the user. + /// + /// - Returns: A 2D array of the arguments given by the user, that will be consumed by the template during the project generation phase. + func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [CommandPath] { + return try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command) + } + + + /// Runs the plugin of a template given a set of arguments. + /// + /// - Parameters: + /// - plugin: The resolved module that corresponds to the plugin tied with the template executable. + /// - arguments: A 2D array of arguments that will be passed to the plugin + /// + /// - Throws: + /// - Any Errors thrown during the execution of the template's plugin given a 2D of arguments. + /// + /// - Returns: A data representation of the result of the execution of the template's plugin. + + public func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { + return try await TemplatePluginRunner.run( + plugin: plugin, + package: rootPackage, + packageGraph: packageGraph, + arguments: arguments, + swiftCommandState: swiftCommandState + ) + } + + /// Loads the plugin that corresponds to the template's name. + /// + /// - Throws: + /// - `TempaltePluginError.noMatchingTemplate(name: String?)` if there are no plugins corresponding to the desired template. + /// - `TemplatePluginError.multipleMatchingTemplates(names: [String]` if the search returns more than one plugin given a desired template + /// + /// - Returns: A data representation of the result of the execution of the template's plugin. + + public func loadTemplatePlugin() throws -> ResolvedModule { + + let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: self.packageGraph, limitedTo: nil) + + switch matchingPlugins.count { + case 0: + throw TemplatePluginError.noMatchingTemplate(name: self.template) + case 1: + return matchingPlugins[0] + default: + let names = matchingPlugins.compactMap { plugin in + (plugin.underlying as? PluginModule)?.capability.commandInvocationVerb + } + throw TemplatePluginError.multipleMatchingTemplates(names: names) + } + } + + enum TemplatePluginError: Error, CustomStringConvertible { + case noRootPackage + case noMatchingTemplate(name: String?) + case multipleMatchingTemplates(names: [String]) + case failedToDecodeToolInfo(underlying: Error) + case executionFailed(underlying: Error) + + var description: String { + switch self { + case .noRootPackage: + return "No root package found in the package graph." + case let .noMatchingTemplate(name): + let templateName = name ?? "" + return "No templates found matching '\(templateName)" + case let .multipleMatchingTemplates(names): + return """ + Multiple templates matched. Use `--type` to specify one of the following: \(names.joined(separator: ", ")) + """ + case let .failedToDecodeToolInfo(underlying): + return "Failed to decode template tool info: \(underlying.localizedDescription)" + case let .executionFailed(underlying): + return "Plugin execution failed: \(underlying.localizedDescription)" + } + } + } +} + + + +public struct CommandPath { + public let fullPathKey: String + public let commandChain: [CommandComponent] +} + +public struct CommandComponent { + let commandName: String + let arguments: [TemplateTestPromptingSystem.ArgumentResponse] +} + + + +public class TemplateTestPromptingSystem { + + + + public init() {} + /// Prompts the user for input based on the given command definition and arguments. + /// + /// This method collects responses for a command's arguments by first validating any user-provided + /// arguments (`arguments`) against the command's defined parameters. Any required arguments that are + /// missing will be interactively prompted from the user. + /// + /// If the command has subcommands, the method will attempt to detect a subcommand from any leftover + /// arguments. If no subcommand is found, the user is interactively prompted to select one. This process + /// is recursive: each subcommand is treated as a new command and processed accordingly. + /// + /// When building each CLI command line, only arguments defined for the current command level are included— + /// inherited arguments from previous levels are excluded to avoid duplication. + /// + /// - Parameters: + /// - command: The top-level or current `CommandInfoV0` to prompt for. + /// - arguments: The list of pre-supplied command-line arguments to match against defined arguments. + /// - subcommandTrail: An internal list of command names to build the final CLI path (used recursively). + /// - inheritedResponses: Argument responses collected from parent commands that should be passed down. + /// + /// - Returns: A list of command line invocations (`[[String]]`), each representing a full CLI command. + /// Each entry includes only arguments relevant to the specific command or subcommand level. + /// + /// - Throws: An error if argument parsing or user prompting fails. + + + + // resolve arguments at this level + // append arguments to the current path + // if subcommands exist, then for each subcommand, pass the function again, where we deepCopy a path + // if not, then jointhe command names of all the paths, and append CommandPath() + + public func generateCommandPaths(rootCommand: CommandInfoV0) throws -> [CommandPath] { + var paths: [CommandPath] = [] + var visitedArgs = Set() + + try dfs(command: rootCommand, path: [], visitedArgs: &visitedArgs, paths: &paths) + + return paths + } + + func dfs(command: CommandInfoV0, path: [CommandComponent], visitedArgs: inout Set, paths: inout [CommandPath]) throws{ + + let allArgs = try convertArguments(from: command) + + let currentArgs = allArgs.filter { arg in + !visitedArgs.contains(where: {$0.argument.valueName == arg.valueName}) + } + + + var collected: [String: ArgumentResponse] = [:] + let resolvedArgs = UserPrompter.prompt(for: currentArgs, collected: &collected) + + resolvedArgs.forEach { visitedArgs.insert($0) } + + let currentComponent = CommandComponent( + commandName: command.commandName, arguments: resolvedArgs + ) + + var newPath = path + + newPath.append(currentComponent) + + if let subcommands = getSubCommand(from: command) { + for sub in subcommands { + try dfs(command: sub, path: newPath, visitedArgs: &visitedArgs, paths: &paths) + } + } else { + let fullPathKey = joinCommandNames(newPath) + let commandPath = CommandPath( + fullPathKey: fullPathKey, commandChain: newPath + ) + + paths.append(commandPath) + } + + func joinCommandNames(_ path: [CommandComponent]) -> String { + path.map { $0.commandName }.joined(separator: "-") + } + + + } + + + + + /// Retrieves the list of subcommands for a given command, excluding common utility commands. + /// + /// This method checks whether the given command contains any subcommands. If so, it filters + /// out the `"help"` subcommand (often auto-generated or reserved), and returns the remaining + /// subcommands. + /// + /// - Parameter command: The `CommandInfoV0` instance representing the current command. + /// + /// - Returns: An array of `CommandInfoV0` representing valid subcommands, or `nil` if no subcommands exist. + func getSubCommand(from command: CommandInfoV0) -> [CommandInfoV0]? { + guard let subcommands = command.subcommands else { return nil } + + let filteredSubcommands = subcommands.filter { $0.commandName.lowercased() != "help" } + + guard !filteredSubcommands.isEmpty else { return nil } + + return filteredSubcommands + } + + /// Converts the command information into an array of argument metadata. + /// + /// - Parameter command: The command info object. + /// - Returns: An array of argument info objects. + /// - Throws: `TemplateError.noArguments` if the command has no arguments. + + func convertArguments(from command: CommandInfoV0) throws -> [ArgumentInfoV0] { + guard let rawArgs = command.arguments else { + throw TemplateError.noArguments + } + return rawArgs + } + + /// A helper struct to prompt the user for input values for command arguments. + + public enum UserPrompter { + /// Prompts the user for input for each argument, handling flags, options, and positional arguments. + /// + /// - Parameter arguments: The list of argument metadata to prompt for. + /// - Returns: An array of `ArgumentResponse` representing the user's input. + + public static func prompt( + for arguments: [ArgumentInfoV0], + collected: inout [String: ArgumentResponse] + ) -> [ArgumentResponse] { + return arguments + .filter { $0.valueName != "help" && $0.shouldDisplay } + .compactMap { arg in + let key = arg.preferredName?.name ?? arg.valueName ?? UUID().uuidString + + if let existing = collected[key] { + print("Using previous value for '\(key)': \(existing.values.joined(separator: ", "))") + return existing + } + + let defaultText = arg.defaultValue.map { " (default: \($0))" } ?? "" + let allValuesText = (arg.allValues?.isEmpty == false) ? + " [\(arg.allValues!.joined(separator: ", "))]" : "" + let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(defaultText):" + + var values: [String] = [] + + switch arg.kind { + case .flag: + let confirmed = promptForConfirmation( + prompt: promptMessage, + defaultBehavior: arg.defaultValue?.lowercased() == "true" + ) + values = [confirmed ? "true" : "false"] + + case .option, .positional: + print(promptMessage) + + if arg.isRepeating { + while let input = readLine(), !input.isEmpty { + if let allowed = arg.allValues, !allowed.contains(input) { + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + continue + } + values.append(input) + } + if values.isEmpty, let def = arg.defaultValue { + values = [def] + } + } else { + let input = readLine() + if let input, !input.isEmpty { + if let allowed = arg.allValues, !allowed.contains(input) { + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + exit(1) + } + values = [input] + } else if let def = arg.defaultValue { + values = [def] + } else if arg.isOptional == false { + fatalError("Required argument '\(arg.valueName ?? "")' not provided.") + } + } + } + + let response = ArgumentResponse(argument: arg, values: values) + collected[key] = response + return response + } + + } + } + + /// Prompts the user for a yes/no confirmation. + /// + /// - Parameters: + /// - prompt: The prompt message to display. + /// - defaultBehavior: The default value if the user provides no input. + /// - Returns: `true` if the user confirmed, otherwise `false`. + + private static func promptForConfirmation(prompt: String, defaultBehavior: Bool?) -> Bool { + let suffix = defaultBehavior == true ? " [Y/n]" : defaultBehavior == false ? " [y/N]" : " [y/n]" + print(prompt + suffix, terminator: " ") + guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + return defaultBehavior ?? false + } + + switch input { + case "y", "yes": return true + case "n", "no": return false + default: return defaultBehavior ?? false + } + } + + /// Represents a user's response to an argument prompt. + + public struct ArgumentResponse: Hashable { + /// The argument metadata. + let argument: ArgumentInfoV0 + + /// The values provided by the user. + public let values: [String] + + /// Returns the command line fragments representing this argument and its values. + public var commandLineFragments: [String] { + guard let name = argument.valueName else { + return self.values + } + + switch self.argument.kind { + case .flag: + return self.values.first == "true" ? ["--\(name)"] : [] + case .option: + return self.values.flatMap { ["--\(name)", $0] } + case .positional: + return self.values + } + } + } + + +} + +/// An error enum representing various template-related errors. +private enum TemplateError: Swift.Error { + /// The provided path is invalid or does not exist. + case invalidPath + + /// A manifest file already exists in the target directory. + case manifestAlreadyExists + + /// The template has no arguments to prompt for. + + case noArguments + case invalidArgument(name: String) + case unexpectedArgument(name: String) + case unexpectedNamedArgument(name: String) + case missingValueForOption(name: String) + case invalidValue(argument: String, invalidValues: [String], allowed: [String]) +} + +extension TemplateError: CustomStringConvertible { + /// A readable description of the error + var description: String { + switch self { + case .manifestAlreadyExists: + "a manifest file already exists in this directory" + case .invalidPath: + "Path does not exist, or is invalid." + case .noArguments: + "Template has no arguments" + case .invalidArgument(name: let name): + "Invalid argument name: \(name)" + case .unexpectedArgument(name: let name): + "Unexpected argument: \(name)" + case .unexpectedNamedArgument(name: let name): + "Unexpected named argument: \(name)" + case .missingValueForOption(name: let name): + "Missing value for option: \(name)" + case .invalidValue(argument: let argument, invalidValues: let invalidValues, allowed: let allowed): + "Invalid value \(argument). Valid values are: \(allowed.joined(separator: ", ")). \(invalidValues.isEmpty ? "" : "Also, \(invalidValues.joined(separator: ", ")) are not valid.")" + } + } +} + + + +private extension PluginCapability { + var commandInvocationVerb: String? { + guard case .command(let intent, _) = self else { return nil } + return intent.invocationVerb + } +} + From 26b7027a819924a739209f2a337385672eb75a63 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 14 Aug 2025 15:23:08 -0400 Subject: [PATCH 081/225] refactored directory manager to encapsulate shared logic between init and test --- .../TestCommands/TestTemplateCommand.swift | 39 +-------- ...ackageInitializationDirectoryManager.swift | 86 ++++++++----------- .../PackageInitializer.swift | 2 +- .../TemplateTestDirectoryManager.swift | 39 +++++++++ .../Workspace/TemplateDirectoryManager.swift | 57 ++++++++++++ 5 files changed, 135 insertions(+), 88 deletions(-) create mode 100644 Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift create mode 100644 Sources/Workspace/TemplateDirectoryManager.swift diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 2c0fd2d3cc3..f4e0e972e63 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -46,43 +46,6 @@ extension DispatchTimeInterval { } -//DEAL WITH THIS LATER -public struct TemplateTestingDirectoryManager { - let fileSystem: FileSystem - - //revisit - func createTemporaryDirectories(directories: Set) throws -> [Basics.AbsolutePath] { - - var result: [Basics.AbsolutePath] = [] - for directory in directories { - let dirPath = try fileSystem.tempDirectory.appending(component: directory) - try fileSystem.createDirectory(dirPath) - result.append(dirPath) - } - - return result - } - - func createOutputDirectory(outputDirectoryPath: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) throws { - let manifest = outputDirectoryPath.appending(component: Manifest.filename) - let fileSystem = swiftCommandState.fileSystem - let directoryExists = fileSystem.exists(outputDirectoryPath) - - if !directoryExists { - try FileManager.default.createDirectory( - at: outputDirectoryPath.asURL, - withIntermediateDirectories: true - ) - } else { - if fileSystem.exists(manifest) { - throw ValidationError("Package.swift was found in \(outputDirectoryPath).") - } - } - - } -} - - extension SwiftTestCommand { struct Template: AsyncSwiftCommand { static let configuration = CommandConfiguration( @@ -130,7 +93,7 @@ extension SwiftTestCommand { throw ValidationError("Could not determine current working directory.") } - let directoryManager = TemplateTestingDirectoryManager(fileSystem: swiftCommandState.fileSystem) + let directoryManager = TemplateTestingDirectoryManager(fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) try directoryManager.createOutputDirectory(outputDirectoryPath: outputDirectory, swiftCommandState: swiftCommandState) let pluginManager = try await TemplateTesterPluginManager( diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift index 96f84398b2f..5cb910175bf 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift @@ -1,39 +1,51 @@ - import Basics - - import Workspace import Foundation import CoreCommands +import Basics +import CoreCommands +import Foundation +import PackageModel + public struct TemplateInitializationDirectoryManager { + let observabilityScope: ObservabilityScope let fileSystem: FileSystem + let helper: TemporaryDirectoryHelper - func createTemporaryDirectories() throws -> (stagingPath: Basics.AbsolutePath, cleanUpPath: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) { - let tempDir = try fileSystem.tempDirectory.appending(component: UUID().uuidString) - let stagingPath = tempDir.appending(component: "generated-package") - let cleanupPath = tempDir.appending(component: "clean-up") - try fileSystem.createDirectory(tempDir) - return (stagingPath, cleanupPath, tempDir) + public init(fileSystem: FileSystem, observabilityScope: ObservabilityScope) { + self.fileSystem = fileSystem + self.helper = TemporaryDirectoryHelper(fileSystem: fileSystem) + self.observabilityScope = observabilityScope } - func finalize( + public func createTemporaryDirectories() throws -> (stagingPath: Basics.AbsolutePath, cleanupPath: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) { + let tempDir = try helper.createTemporaryDirectory() + let dirs = try helper.createSubdirectories(in: tempDir, names: ["generated-package", "clean-up"]) + + return (dirs[0], dirs[1], tempDir) + } + + public func finalize( cwd: Basics.AbsolutePath, stagingPath: Basics.AbsolutePath, cleanupPath: Basics.AbsolutePath, swiftCommandState: SwiftCommandState ) async throws { - if fileSystem.exists(cwd) { - do { - try fileSystem.removeFileTree(cwd) - } catch { - throw FileOperationError.failedToRemoveExistingDirectory(path: cwd, underlying: error) - } + do { + try helper.removeDirectoryIfExists(cwd) + } catch { + observabilityScope.emit( + error: DirectoryManagerError.failedToRemoveDirectory(path: cwd, underlying: error), + underlyingError: error + ) + throw DirectoryManagerError.failedToRemoveDirectory(path: cwd, underlying: error) } - try fileSystem.copy(from: stagingPath, to: cleanupPath) + + try helper.copyDirectory(from: stagingPath, to: cleanupPath) try await cleanBuildArtifacts(at: cleanupPath, swiftCommandState: swiftCommandState) - try fileSystem.copy(from: cleanupPath, to: cwd) + try helper.copyDirectory(from: cleanupPath, to: cwd) } func cleanBuildArtifacts(at path: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) async throws { @@ -45,11 +57,7 @@ public struct TemplateInitializationDirectoryManager { public func cleanupTemporary(templateSource: InitTemplatePackage.TemplateSource, path: Basics.AbsolutePath, temporaryDirectory: Basics.AbsolutePath?) throws { do { switch templateSource { - case .git: - if FileManager.default.fileExists(atPath: path.pathString) { - try FileManager.default.removeItem(at: path.asURL) - } - case .registry: + case .git, .registry: if FileManager.default.fileExists(atPath: path.pathString) { try FileManager.default.removeItem(at: path.asURL) } @@ -58,35 +66,15 @@ public struct TemplateInitializationDirectoryManager { } if let tempDir = temporaryDirectory { - try fileSystem.removeFileTree(tempDir) + try helper.removeDirectoryIfExists(tempDir) } } catch { - throw CleanupError.failedToCleanup(temporaryDirectory: temporaryDirectory, underlying: error) - } - } - - enum CleanupError: Error, CustomStringConvertible { - case failedToCleanup(temporaryDirectory: Basics.AbsolutePath?, underlying: Error) - - var description: String { - switch self { - case .failedToCleanup(let temporaryDirectory, let error): - let tempDir = temporaryDirectory?.pathString ?? "" - return "Failed to clean up temporary directory at \(tempDir): \(error.localizedDescription)" - } + observabilityScope.emit( + error: DirectoryManagerError.cleanupFailed(path: temporaryDirectory, underlying: error), + underlyingError: error + ) + throw DirectoryManagerError.cleanupFailed(path: temporaryDirectory, underlying: error) } } - - enum FileOperationError: Error, CustomStringConvertible { - case failedToRemoveExistingDirectory(path: Basics.AbsolutePath, underlying: Error) - - var description: String { - switch self { - case .failedToRemoveExistingDirectory(let path, let underlying): - return "Failed to remove existing directory at \(path): \(underlying.localizedDescription)" - } - } - } - } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index d6aeae8bcfb..d0a20ece22b 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -50,7 +50,7 @@ struct TemplatePackageInitializer: PackageInitializer { swiftCommandState: swiftCommandState ).resolve() - let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem) + let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) let (stagingPath, cleanupPath, tempDir) = try directoryManager.createTemporaryDirectories() let packageType = try await inferPackageType(from: resolvedTemplatePath) diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift new file mode 100644 index 00000000000..2cb9bd2642f --- /dev/null +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift @@ -0,0 +1,39 @@ +import Basics +import CoreCommands +import Foundation +import Workspace +import PackageModel + +public struct TemplateTestingDirectoryManager { + let fileSystem: FileSystem + let helper: TemporaryDirectoryHelper + let observabilityScope: ObservabilityScope + + public init(fileSystem: FileSystem, observabilityScope: ObservabilityScope) { + self.fileSystem = fileSystem + self.helper = TemporaryDirectoryHelper(fileSystem: fileSystem) + self.observabilityScope = observabilityScope + } + + public func createTemporaryDirectories(directories: Set) throws -> [Basics.AbsolutePath] { + let tempDir = try helper.createTemporaryDirectory() + return try helper.createSubdirectories(in: tempDir, names: Array(directories)) + } + + public func createOutputDirectory(outputDirectoryPath: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) throws { + let manifestPath = outputDirectoryPath.appending(component: Manifest.filename) + let fs = swiftCommandState.fileSystem + + if !helper.directoryExists(outputDirectoryPath) { + try FileManager.default.createDirectory( + at: outputDirectoryPath.asURL, + withIntermediateDirectories: true + ) + } else if fs.exists(manifestPath) { + observabilityScope.emit( + error: DirectoryManagerError.foundManifestFile(path: outputDirectoryPath) + ) + throw DirectoryManagerError.foundManifestFile(path: outputDirectoryPath) + } + } +} diff --git a/Sources/Workspace/TemplateDirectoryManager.swift b/Sources/Workspace/TemplateDirectoryManager.swift new file mode 100644 index 00000000000..f8dab6c66f3 --- /dev/null +++ b/Sources/Workspace/TemplateDirectoryManager.swift @@ -0,0 +1,57 @@ +import Basics +import Foundation + +public struct TemporaryDirectoryHelper { + let fileSystem: FileSystem + + public init(fileSystem: FileSystem) { + self.fileSystem = fileSystem + } + + public func createTemporaryDirectory(named name: String? = nil) throws -> Basics.AbsolutePath { + let dirName = name ?? UUID().uuidString + let dirPath = try fileSystem.tempDirectory.appending(component: dirName) + try fileSystem.createDirectory(dirPath) + return dirPath + } + + public func createSubdirectories(in parent: Basics.AbsolutePath, names: [String]) throws -> [Basics.AbsolutePath] { + return try names.map { name in + let path = parent.appending(component: name) + try fileSystem.createDirectory(path) + return path + } + } + + public func directoryExists(_ path: Basics.AbsolutePath) -> Bool { + return fileSystem.exists(path) + } + + public func removeDirectoryIfExists(_ path: Basics.AbsolutePath) throws { + if fileSystem.exists(path) { + try fileSystem.removeFileTree(path) + } + } + + public func copyDirectory(from: Basics.AbsolutePath, to: Basics.AbsolutePath) throws { + try fileSystem.copy(from: from, to: to) + } +} + +public enum DirectoryManagerError: Error, CustomStringConvertible { + case failedToRemoveDirectory(path: Basics.AbsolutePath, underlying: Error) + case foundManifestFile(path: Basics.AbsolutePath) + case cleanupFailed(path: Basics.AbsolutePath?, underlying: Error) + + public var description: String { + switch self { + case .failedToRemoveDirectory(let path, let error): + return "Failed to remove directory at \(path): \(error.localizedDescription)" + case .foundManifestFile(let path): + return "Package.swift was found in \(path)." + case .cleanupFailed(let path, let error): + let dir = path?.pathString ?? "" + return "Failed to clean up directory at \(dir): \(error.localizedDescription)" + } + } +} From 7a0b17f6d3a6132cb29fe5bbcb9c1bef89aac9d9 Mon Sep 17 00:00:00 2001 From: John Bute Date: Fri, 15 Aug 2025 07:59:30 -0400 Subject: [PATCH 082/225] refactored TemplateManager based on shared logic between init + test --- .../PackageCommands/ShowTemplates.swift | 10 +- .../TemplatePluginCoordinator.swift | 99 ++++++++++++ .../TemplatePluginManager.swift | 117 ++------------ .../TemplateTesterManager.swift | 152 +++--------------- .../InitPackage.swift | 0 .../InitTemplatePackage.swift | 0 .../TemplateDirectoryManager.swift | 0 7 files changed, 138 insertions(+), 240 deletions(-) create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift rename Sources/Workspace/{ => TemplateWorkspaceUtilities}/InitPackage.swift (100%) rename Sources/Workspace/{ => TemplateWorkspaceUtilities}/InitTemplatePackage.swift (100%) rename Sources/Workspace/{ => TemplateWorkspaceUtilities}/TemplateDirectoryManager.swift (100%) diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 2049e4a62a9..7cccb3e7444 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -81,7 +81,7 @@ struct ShowTemplates: AsyncSwiftCommand { let resolvedPath = try await resolveTemplatePath(using: swiftCommandState, source: source) let templates = try await loadTemplates(from: resolvedPath, swiftCommandState: swiftCommandState) try await displayTemplates(templates, at: resolvedPath, using: swiftCommandState) - try cleanupTemplate(source: source, path: resolvedPath, fileSystem: swiftCommandState.fileSystem) + try cleanupTemplate(source: source, path: resolvedPath, fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) } private func resolveSource(cwd: AbsolutePath?) throws -> InitTemplatePackage.TemplateSource { @@ -127,9 +127,9 @@ struct ShowTemplates: AsyncSwiftCommand { try await swiftCommandState.loadPackageGraph() } - let rootPackages = graph.rootPackages.map(\.identity) + let rootPackages = graph.rootPackages.map{ $0.identity } - return graph.allModules.filter(\.underlying.template).map { + return graph.allModules.filter({$0.underlying.template}).map { Template(package: rootPackages.contains($0.packageIdentity) ? nil : $0.packageIdentity.description, name: $0.name) } } @@ -189,8 +189,8 @@ struct ShowTemplates: AsyncSwiftCommand { } } - private func cleanupTemplate(source: InitTemplatePackage.TemplateSource, path: AbsolutePath, fileSystem: FileSystem) throws { - try TemplateInitializationDirectoryManager(fileSystem: fileSystem) + private func cleanupTemplate(source: InitTemplatePackage.TemplateSource, path: AbsolutePath, fileSystem: FileSystem, observabilityScope: ObservabilityScope) throws { + try TemplateInitializationDirectoryManager(fileSystem: fileSystem, observabilityScope: observabilityScope) .cleanupTemporary(templateSource: source, path: path, temporaryDirectory: nil) } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift new file mode 100644 index 00000000000..d45496ba94d --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift @@ -0,0 +1,99 @@ + +import ArgumentParser +import ArgumentParserToolInfo + +import Basics + +@_spi(SwiftPMInternal) +import CoreCommands + +import PackageModel +import Workspace +import SPMBuildCore +import TSCBasic +import TSCUtility +import Foundation +import PackageGraph + + +struct TemplatePluginCoordinator { + let swiftCommandState: SwiftCommandState + let scratchDirectory: Basics.AbsolutePath + let template: String? + let args: [String] + + let EXPERIMENTAL_DUMP_HELP = ["--", "--experimental-dump-help"] + + func loadPackageGraph() async throws -> ModulesGraph { + try await swiftCommandState.withTemporaryWorkspace(switchingTo: scratchDirectory) { _, _ in + try await swiftCommandState.loadPackageGraph() + } + } + + /// Loads the plugin that corresponds to the template's name. + /// + /// - Throws: + /// - `PluginError.noMatchingTemplate(name: String?)` if there are no plugins corresponding to the desired template. + /// - `PluginError.multipleMatchingTemplates(names: [String]` if the search returns more than one plugin given a desired template + /// + /// - Returns: A data representation of the result of the execution of the template's plugin. + func loadTemplatePlugin(from packageGraph: ModulesGraph) throws -> ResolvedModule { + let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: packageGraph, limitedTo: nil) + switch matchingPlugins.count { + case 0: + throw PluginError.noMatchingTemplate(name: self.template) + case 1: + return matchingPlugins[0] + default: + let names = matchingPlugins.compactMap { plugin in + (plugin.underlying as? PluginModule)?.capability.commandInvocationVerb + } + throw PluginError.multipleMatchingTemplates(names: names) + } + } + + /// Manages the logic of dumping the JSON representation of a template's decision tree. + /// + /// - Throws: + /// - `TemplatePluginError.failedToDecodeToolInfo(underlying: error)` If there is a change in representation between the JSON and the current version of the ToolInfoV0 struct + + func dumpToolInfo(using plugin: ResolvedModule, from packageGraph: ModulesGraph, rootPackage: ResolvedPackage) async throws -> ToolInfoV0 { + let output = try await TemplatePluginRunner.run( + plugin: plugin, + package: rootPackage, + packageGraph: packageGraph, + arguments: EXPERIMENTAL_DUMP_HELP, + swiftCommandState: swiftCommandState + ) + + do { + return try JSONDecoder().decode(ToolInfoV0.self, from: output) + } catch { + throw PluginError.failedToDecodeToolInfo(underlying: error) + } + } + + enum PluginError: Error, CustomStringConvertible { + case noMatchingTemplate(name: String?) + case multipleMatchingTemplates(names: [String]) + case failedToDecodeToolInfo(underlying: Error) + + var description: String { + switch self { + case let .noMatchingTemplate(name): + "No templates found matching '\(name ?? "")'" + case let .multipleMatchingTemplates(names): + "Multiple templates matched: \(names.joined(separator: ", "))" + case let .failedToDecodeToolInfo(underlying): + "Failed to decode tool info: \(underlying.localizedDescription)" + } + } + } +} + +private extension PluginCapability { + var commandInvocationVerb: String? { + guard case .command(let intent, _) = self else { return nil } + return intent.invocationVerb + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 5db8218f6fd..113a9168d6e 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -1,36 +1,18 @@ - -import ArgumentParser import ArgumentParserToolInfo import Basics -@_spi(SwiftPMInternal) import CoreCommands -import PackageModel import Workspace -import SPMBuildCore -import TSCBasic -import TSCUtility import Foundation import PackageGraph public protocol TemplatePluginManager { - func loadTemplatePlugin() throws -> ResolvedModule - //func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [[String]] func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data - - var swiftCommandState: SwiftCommandState { get } - var template: String? { get } - var packageGraph: ModulesGraph { get } - var scratchDirectory: Basics.AbsolutePath { get } - var args: [String] { get } - - var EXPERIMENTAL_DUMP_HELP: [String] { get } } - /// A utility for obtaining and running a template's plugin . /// /// `TemplateIntiializationPluginManager` encapsulates the logic needed to fetch, @@ -38,14 +20,11 @@ public protocol TemplatePluginManager { struct TemplateInitializationPluginManager: TemplatePluginManager { let swiftCommandState: SwiftCommandState let template: String? - - let packageGraph: ModulesGraph - let scratchDirectory: Basics.AbsolutePath - let args: [String] + let packageGraph: ModulesGraph + let coordinator: TemplatePluginCoordinator - let EXPERIMENTAL_DUMP_HELP: [String] = ["--", "--experimental-dump-help"] var rootPackage: ResolvedPackage { guard let root = packageGraph.rootPackages.first else { @@ -55,14 +34,19 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { } init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String]) async throws { + let coordinator = TemplatePluginCoordinator( + swiftCommandState: swiftCommandState, + scratchDirectory: scratchDirectory, + template: template, + args: args + ) + + self.packageGraph = try await coordinator.loadPackageGraph() self.swiftCommandState = swiftCommandState self.template = template self.scratchDirectory = scratchDirectory self.args = args - - self.packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: scratchDirectory) { _, _ in - try await swiftCommandState.loadPackageGraph() - } + self.coordinator = coordinator } /// Manages the logic of running a template and executing on the information provided by the JSON representation of a template's arguments. @@ -73,37 +57,13 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// - `TemplatePluginError.execu` func run() async throws { - //Load the plugin corresponding to the template - let commandLinePlugin = try loadTemplatePlugin() + let plugin = try coordinator.loadTemplatePlugin(from: packageGraph) + let toolInfo = try await coordinator.dumpToolInfo(using: plugin, from: packageGraph, rootPackage: rootPackage) - // Execute experimental-dump-help to get the JSON representing the template's decision tree - let output: Data - - do { - output = try await executeTemplatePlugin(commandLinePlugin, with: EXPERIMENTAL_DUMP_HELP) - } catch { - throw TemplatePluginError.executionFailed(underlying: error) - } - - //Decode the JSON into ArgumentParserToolInfo ToolInfoV0 struct - let toolInfo: ToolInfoV0 - - do { - toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - } catch { - throw TemplatePluginError.failedToDecodeToolInfo(underlying: error) - } - - // Prompt the user for any information needed by the template let cliResponses: [[String]] = try promptUserForTemplateArguments(using: toolInfo) - // Execute the template to generate a user's project for response in cliResponses { - do { - let _ = try await executeTemplatePlugin(commandLinePlugin, with: response) - } catch { - throw TemplatePluginError.executionFailed(underlying: error) - } + _ = try await executeTemplatePlugin(plugin, with: response) } } @@ -150,52 +110,7 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// /// - Returns: A data representation of the result of the execution of the template's plugin. - internal func loadTemplatePlugin() throws -> ResolvedModule { - let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: self.packageGraph, limitedTo: nil) - - switch matchingPlugins.count { - case 0: - throw TemplatePluginError.noMatchingTemplate(name: self.template) - case 1: - return matchingPlugins[0] - default: - let names = matchingPlugins.compactMap { plugin in - (plugin.underlying as? PluginModule)?.capability.commandInvocationVerb - } - throw TemplatePluginError.multipleMatchingTemplates(names: names) - } - } - - enum TemplatePluginError: Error, CustomStringConvertible { - case noRootPackage - case noMatchingTemplate(name: String?) - case multipleMatchingTemplates(names: [String]) - case failedToDecodeToolInfo(underlying: Error) - case executionFailed(underlying: Error) - - var description: String { - switch self { - case .noRootPackage: - return "No root package found in the package graph." - case let .noMatchingTemplate(name): - let templateName = name ?? "" - return "No templates found matching '\(templateName)" - case let .multipleMatchingTemplates(names): - return """ - Multiple templates matched. Use `--type` to specify one of the following: \(names.joined(separator: ", ")) - """ - case let .failedToDecodeToolInfo(underlying): - return "Failed to decode template tool info: \(underlying.localizedDescription)" - case let .executionFailed(underlying): - return "Plugin execution failed: \(underlying.localizedDescription)" - } - } - } -} - -private extension PluginCapability { - var commandInvocationVerb: String? { - guard case .command(let intent, _) = self else { return nil } - return intent.invocationVerb + func loadTemplatePlugin() throws -> ResolvedModule { + try coordinator.loadTemplatePlugin(from: packageGraph) } } diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index 565679053a5..3860cc8baff 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -1,17 +1,10 @@ - -import ArgumentParser import ArgumentParserToolInfo import Basics -@_spi(SwiftPMInternal) import CoreCommands -import PackageModel import Workspace -import SPMBuildCore -import TSCBasic -import TSCUtility import Foundation import PackageGraph @@ -22,98 +15,47 @@ import PackageGraph public struct TemplateTesterPluginManager: TemplatePluginManager { public let swiftCommandState: SwiftCommandState public let template: String? - - public let packageGraph: ModulesGraph - public let scratchDirectory: Basics.AbsolutePath - public let args: [String] + public let packageGraph: ModulesGraph + let coordinator: TemplatePluginCoordinator - public let EXPERIMENTAL_DUMP_HELP: [String] = ["--", "--experimental-dump-help"] - - var rootPackage: ResolvedPackage { + public var rootPackage: ResolvedPackage { guard let root = packageGraph.rootPackages.first else { - fatalError("No root package found in the package graph.") + fatalError("No root package found.") } return root } init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String]) async throws { + let coordinator = TemplatePluginCoordinator( + swiftCommandState: swiftCommandState, + scratchDirectory: scratchDirectory, + template: template, + args: args + ) + + self.packageGraph = try await coordinator.loadPackageGraph() self.swiftCommandState = swiftCommandState self.template = template self.scratchDirectory = scratchDirectory self.args = args - - self.packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: scratchDirectory) { _, _ in - try await swiftCommandState.loadPackageGraph() - } + self.coordinator = coordinator } - /// Manages the logic of running a template and executing on the information provided by the JSON representation of a template's arguments. - /// - /// - Throws: - /// - `TemplatePluginError.executionFailed(underlying: error)` If there was an error during the execution of a template's plugin. - /// - `TemplatePluginError.failedToDecodeToolInfo(underlying: error)` If there is a change in representation between the JSON and the current version of the ToolInfoV0 struct - /// - `TemplatePluginError.execute` - func run() async throws -> [CommandPath] { - //Load the plugin corresponding to the template + let plugin = try coordinator.loadTemplatePlugin(from: packageGraph) + let toolInfo = try await coordinator.dumpToolInfo(using: plugin, from: packageGraph, rootPackage: rootPackage) - let commandLinePlugin = try loadTemplatePlugin() - - // Execute experimental-dump-help to get the JSON representing the template's decision tree - let output: Data - - do { - output = try await executeTemplatePlugin(commandLinePlugin, with: EXPERIMENTAL_DUMP_HELP) - } catch { - throw TemplatePluginError.executionFailed(underlying: error) - } - - //Decode the JSON into ArgumentParserToolInfo ToolInfoV0 struct - let toolInfo: ToolInfoV0 - - do { - toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - } catch { - throw TemplatePluginError.failedToDecodeToolInfo(underlying: error) - } - - // Prompt the user for any information needed by the template return try promptUserForTemplateArguments(using: toolInfo) } - - - - - /// Utilizes the prompting system defined by the struct to prompt user. - /// - /// - Parameters: - /// - toolInfo: The JSON representation of the template's decision tree. - /// - /// - Throws: - /// - Any other errors thrown during the prompting of the user. - /// - /// - Returns: A 2D array of the arguments given by the user, that will be consumed by the template during the project generation phase. func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [CommandPath] { - return try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command) + try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command) } - - /// Runs the plugin of a template given a set of arguments. - /// - /// - Parameters: - /// - plugin: The resolved module that corresponds to the plugin tied with the template executable. - /// - arguments: A 2D array of arguments that will be passed to the plugin - /// - /// - Throws: - /// - Any Errors thrown during the execution of the template's plugin given a 2D of arguments. - /// - /// - Returns: A data representation of the result of the execution of the template's plugin. - public func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { - return try await TemplatePluginRunner.run( + try await TemplatePluginRunner.run( plugin: plugin, package: rootPackage, packageGraph: packageGraph, @@ -122,60 +64,12 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { ) } - /// Loads the plugin that corresponds to the template's name. - /// - /// - Throws: - /// - `TempaltePluginError.noMatchingTemplate(name: String?)` if there are no plugins corresponding to the desired template. - /// - `TemplatePluginError.multipleMatchingTemplates(names: [String]` if the search returns more than one plugin given a desired template - /// - /// - Returns: A data representation of the result of the execution of the template's plugin. - public func loadTemplatePlugin() throws -> ResolvedModule { - - let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: self.packageGraph, limitedTo: nil) - - switch matchingPlugins.count { - case 0: - throw TemplatePluginError.noMatchingTemplate(name: self.template) - case 1: - return matchingPlugins[0] - default: - let names = matchingPlugins.compactMap { plugin in - (plugin.underlying as? PluginModule)?.capability.commandInvocationVerb - } - throw TemplatePluginError.multipleMatchingTemplates(names: names) - } - } - - enum TemplatePluginError: Error, CustomStringConvertible { - case noRootPackage - case noMatchingTemplate(name: String?) - case multipleMatchingTemplates(names: [String]) - case failedToDecodeToolInfo(underlying: Error) - case executionFailed(underlying: Error) - - var description: String { - switch self { - case .noRootPackage: - return "No root package found in the package graph." - case let .noMatchingTemplate(name): - let templateName = name ?? "" - return "No templates found matching '\(templateName)" - case let .multipleMatchingTemplates(names): - return """ - Multiple templates matched. Use `--type` to specify one of the following: \(names.joined(separator: ", ")) - """ - case let .failedToDecodeToolInfo(underlying): - return "Failed to decode template tool info: \(underlying.localizedDescription)" - case let .executionFailed(underlying): - return "Plugin execution failed: \(underlying.localizedDescription)" - } - } + try coordinator.loadTemplatePlugin(from: packageGraph) } } - public struct CommandPath { public let fullPathKey: String public let commandChain: [CommandComponent] @@ -476,13 +370,3 @@ extension TemplateError: CustomStringConvertible { } } } - - - -private extension PluginCapability { - var commandInvocationVerb: String? { - guard case .command(let intent, _) = self else { return nil } - return intent.invocationVerb - } -} - diff --git a/Sources/Workspace/InitPackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift similarity index 100% rename from Sources/Workspace/InitPackage.swift rename to Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift similarity index 100% rename from Sources/Workspace/InitTemplatePackage.swift rename to Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift diff --git a/Sources/Workspace/TemplateDirectoryManager.swift b/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift similarity index 100% rename from Sources/Workspace/TemplateDirectoryManager.swift rename to Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift From 9b51068e0ed96880fd19680f2bee881736f857b4 Mon Sep 17 00:00:00 2001 From: John Bute Date: Fri, 22 Aug 2025 10:55:42 -0400 Subject: [PATCH 083/225] added end-to-end test for testing initializing a package from local template + fixed logic with handling of directories --- .../ExecutableTemplate/Package.swift | 23 +++++++ .../ExecutableTemplatePlugin.swift | 54 +++++++++++++++ .../ExecutableTemplate/Template.swift | 68 +++++++++++++++++++ ...ackageInitializationDirectoryManager.swift | 14 +--- .../TemplateDirectoryManager.swift | 9 ++- Tests/CommandsTests/PackageCommandTests.swift | 22 ++++++ 6 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift create mode 100644 Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Plugins/ExecutableTemplate/ExecutableTemplatePlugin.swift create mode 100644 Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Templates/ExecutableTemplate/Template.swift diff --git a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift new file mode 100644 index 00000000000..d2399bd40c3 --- /dev/null +++ b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version:999.0.0 +import PackageDescription + + +let package = Package( + name: "SimpleTemplateExample", + products: + .template(name: "ExecutableTemplate"), + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2"), + ], + targets: .template( + name: "ExecutableTemplate", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "SystemPackage", package: "swift-system"), + ], + + initialPackageType: .executable, + description: "This is a simple template that uses Swift string interpolation." + ) +) diff --git a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Plugins/ExecutableTemplate/ExecutableTemplatePlugin.swift b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Plugins/ExecutableTemplate/ExecutableTemplatePlugin.swift new file mode 100644 index 00000000000..63aa3ed64c6 --- /dev/null +++ b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Plugins/ExecutableTemplate/ExecutableTemplatePlugin.swift @@ -0,0 +1,54 @@ +// +// plugin.swift +// TemplateWorkflow +// +// Created by John Bute on 2025-04-14. +// +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct TemplatePlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws { + let tool = try context.tool(named: "ExecutableTemplate") + let packageDirectory = context.package.directoryURL.path + let process = Process() + let stderrPipe = Pipe() + + process.executableURL = URL(filePath: tool.url.path()) + process.arguments = ["--pkg-dir", packageDirectory] + arguments.filter { $0 != "--" } + process.standardError = stderrPipe + + try process.run() + process.waitUntilExit() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderrOutput = String(data: stderrData, encoding: .utf8) ?? "" + + if process.terminationStatus != 0 { + throw PluginError.executionFailed(code: process.terminationStatus, stderrOutput: stderrOutput) + } + + } + enum PluginError: Error, CustomStringConvertible { + case executionFailed(code: Int32, stderrOutput: String) + + var description: String { + switch self { + case .executionFailed(let code, let stderrOutput): + return """ + + Plugin subprocess failed with exit code \(code). + + Output: + \(stderrOutput) + + """ + } + } + } +} diff --git a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Templates/ExecutableTemplate/Template.swift b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Templates/ExecutableTemplate/Template.swift new file mode 100644 index 00000000000..68f3fc1efb4 --- /dev/null +++ b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Templates/ExecutableTemplate/Template.swift @@ -0,0 +1,68 @@ +import ArgumentParser +import Foundation +import SystemPackage + +extension FilePath { + static func / (left: FilePath, right: String) -> FilePath { + left.appending(right) + } +} + +extension String { + func write(toFile: FilePath) throws { + try self.write(toFile: toFile.string, atomically: true, encoding: .utf8) + } +} + +//basic structure of a template that uses string interpolation +@main +struct HelloTemplateTool: ParsableCommand { + + @OptionGroup(visibility: .hidden) + var packageOptions: PkgDir + + //swift argument parser needed to expose arguments to template generator + @Option(help: "The name of your app") + var name: String + + @Flag(help: "Include a README?") + var includeReadme: Bool = false + + //entrypoint of the template executable, that generates just a main.swift and a readme.md + func run() throws { + + guard let pkgDir = packageOptions.pkgDir else { + throw ValidationError("No --pkg-dir was provided.") + } + + let fs = FileManager.default + + let packageDir = FilePath(pkgDir) + + let mainFile = packageDir / "Sources" / name / "main.swift" + + try fs.createDirectory(atPath: mainFile.removingLastComponent().string, withIntermediateDirectories: true) + + try """ + // This is the entry point to your command-line app + print("Hello, \(name)!") + + """.write(toFile: mainFile) + + if includeReadme { + try """ + # \(name) + This is a new Swift app! + """.write(toFile: packageDir / "README.md") + } + + print("Project generated at \(packageDir)") + } +} + + +// MARK: - Shared option commands that are used to show inheritances of arguments and flags +struct PkgDir: ParsableArguments { + @Option(help: .hidden) + var pkgDir: String? +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift index 5cb910175bf..213c5372d7c 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift @@ -33,19 +33,9 @@ public struct TemplateInitializationDirectoryManager { cleanupPath: Basics.AbsolutePath, swiftCommandState: SwiftCommandState ) async throws { - do { - try helper.removeDirectoryIfExists(cwd) - } catch { - observabilityScope.emit( - error: DirectoryManagerError.failedToRemoveDirectory(path: cwd, underlying: error), - underlyingError: error - ) - throw DirectoryManagerError.failedToRemoveDirectory(path: cwd, underlying: error) - } - - try helper.copyDirectory(from: stagingPath, to: cleanupPath) + try helper.copyDirectoryContents(from: stagingPath, to: cleanupPath) try await cleanBuildArtifacts(at: cleanupPath, swiftCommandState: swiftCommandState) - try helper.copyDirectory(from: cleanupPath, to: cwd) + try helper.copyDirectoryContents(from: cleanupPath, to: cwd) } func cleanBuildArtifacts(at path: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) async throws { diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift b/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift index f8dab6c66f3..69e32e2f5fd 100644 --- a/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift +++ b/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift @@ -33,8 +33,13 @@ public struct TemporaryDirectoryHelper { } } - public func copyDirectory(from: Basics.AbsolutePath, to: Basics.AbsolutePath) throws { - try fileSystem.copy(from: from, to: to) + public func copyDirectoryContents(from sourceDir: AbsolutePath, to destinationDir: AbsolutePath) throws { + let contents = try fileSystem.getDirectoryContents(sourceDir) + for entry in contents { + let source = sourceDir.appending(component: entry) + let destination = destinationDir.appending(component: entry) + try fileSystem.copy(from: source, to: destination) + } } } diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index 524f2306e47..5cb1111aa46 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -911,6 +911,28 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase { } } + func testInitLocalTemplate() async throws { + + try await fixture(name: "Miscellaneous/InitTemplates") { fixturePath in + let packageRoot = fixturePath.appending("ExecutableTemplate") + let destinationPath = fixturePath.appending("Foo") + try localFileSystem.createDirectory(destinationPath) + + _ = try await execute([ + "--package-path", destinationPath.pathString, + "init", "--type", "ExecutableTemplate", + "--path", packageRoot.pathString, + "--", "--name", "foo", "--include-readme" + ]) + + let manifest = destinationPath.appending("Package.swift") + let readMe = destinationPath.appending("README.md") + XCTAssertFileExists(manifest) + XCTAssertFileExists(readMe) + XCTAssertTrue(localFileSystem.exists(destinationPath.appending("Sources").appending("foo"))) + } + } + // Helper function to arbitrarily assert on manifest content func assertManifest(_ packagePath: AbsolutePath, _ callback: (String) throws -> Void) throws { let manifestPath = packagePath.appending("Package.swift") From 341a21fab9ec0673d6c0702650172f184263932d Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 26 Aug 2025 10:55:51 -0400 Subject: [PATCH 084/225] reducing PR size --- README.md | 4 +- Sources/Basics/SourceControlURL.swift | 4 - Sources/Commands/PackageCommands/Init.swift | 9 - .../PackageCommands/PluginCommand.swift | 2 +- .../PackageCommands/ShowTemplates.swift | 1 - Sources/Commands/SwiftBuildCommand.swift | 3 - Sources/Commands/SwiftTestCommand.swift | 1 - Sources/PackageMetadata/PackageMetadata.swift | 16 +- .../PackageModel/Module/BinaryModule.swift | 2 +- Sources/PackageRegistry/RegistryClient.swift | 2 +- .../PackageRegistryCommand+Discover.swift | 101 ----------- .../PackageRegistryCommand+Get.swift | 164 ------------------ .../PackageRegistryCommand.swift | 10 +- Sources/SourceControl/GitRepository.swift | 15 -- .../InitPackage.swift | 1 + .../Workspace/Workspace+Dependencies.swift | 87 +--------- 16 files changed, 9 insertions(+), 413 deletions(-) delete mode 100644 Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift delete mode 100644 Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift diff --git a/README.md b/README.md index dbb900d55a8..457d0869d5b 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,13 @@ Now you can go to an empty directory and use an example template to make a packa There's also a template maker that will help you to write your own template. Here's how you can generate your own template: ``` -/.build/debug/swift-package init --template TemplateMaker --template-type git --template-url git@github.pie.apple.com:christie-mcgee/template.git +/.build/debug/swift-package init --type TemplateMaker --template-type git --template-url git@github.pie.apple.com:christie-mcgee/template.git ``` Once you've customized your template then you can test it from an empty directory: ``` -/.build/debug/swift-package init --template MyTemplate --template-type local --template-path +/.build/debug/swift-package init --type MyTemplate --template-type local --template-path ``` ## About SwiftPM diff --git a/Sources/Basics/SourceControlURL.swift b/Sources/Basics/SourceControlURL.swift index 70bb1df9cee..398c2f89ddf 100644 --- a/Sources/Basics/SourceControlURL.swift +++ b/Sources/Basics/SourceControlURL.swift @@ -23,10 +23,6 @@ public struct SourceControlURL: Codable, Equatable, Hashable, Sendable { self.urlString = urlString } - public init(argument: String) { - self.urlString = argument - } - public init(_ url: URL) { self.urlString = url.absoluteString } diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 31b4da6cc12..f02569f003f 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -11,7 +11,6 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ArgumentParserToolInfo import Basics @@ -21,10 +20,7 @@ import CoreCommands import PackageModel import Workspace import SPMBuildCore -import TSCBasic import TSCUtility -import Foundation -import PackageGraph extension SwiftPackageCommand { @@ -54,11 +50,6 @@ extension SwiftPackageCommand { """)) var initMode: String? - //if --type is mentioned with one of the seven above, then normal initialization - // if --type is mentioned along with a templateSource, its a template (no matter what) - // if-type is not mentioned with no templatesoURCE, then defaults to library - // if --type is not mentioned and templateSource is not nil, then there is only one template in package - /// Which testing libraries to use (and any related options.) @OptionGroup(visibility: .hidden) var testLibraryOptions: TestLibraryOptions diff --git a/Sources/Commands/PackageCommands/PluginCommand.swift b/Sources/Commands/PackageCommands/PluginCommand.swift index 422fe3a64fc..4957bfc5fcc 100644 --- a/Sources/Commands/PackageCommands/PluginCommand.swift +++ b/Sources/Commands/PackageCommands/PluginCommand.swift @@ -363,7 +363,7 @@ struct PluginCommand: AsyncSwiftCommand { let allowNetworkConnectionsCopy = allowNetworkConnections let buildEnvironment = buildParameters.buildEnvironment - _ = try await pluginTarget.invoke( + let _ = try await pluginTarget.invoke( action: .performCommand(package: package, arguments: arguments), buildEnvironment: buildEnvironment, scriptRunner: pluginScriptRunner, diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 7cccb3e7444..e51ed7530e9 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -147,7 +147,6 @@ struct ShowTemplates: AsyncSwiftCommand { throw InternalError("invalid manifests at \(root.packages)") } - let products = rootManifest.products let targets = rootManifest.targets if let target = targets.first(where: { $0.name == template }), diff --git a/Sources/Commands/SwiftBuildCommand.swift b/Sources/Commands/SwiftBuildCommand.swift index bd2ba81a9e0..8bc8e1aaccb 100644 --- a/Sources/Commands/SwiftBuildCommand.swift +++ b/Sources/Commands/SwiftBuildCommand.swift @@ -103,15 +103,12 @@ struct BuildCommandOptions: ParsableArguments { @Option(help: "Build the specified product.") var product: String? - //John-to-revisit - /* /// Testing library options. /// /// These options are no longer used but are needed by older versions of the /// Swift VSCode plugin. They will be removed in a future update. @OptionGroup(visibility: .private) var testLibraryOptions: TestLibraryOptions - */ /// Specifies the traits to build. @OptionGroup(visibility: .hidden) diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index 6a0b2be41ae..8346a5e315f 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -11,7 +11,6 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ArgumentParserToolInfo @_spi(SwiftPMInternal) import Basics diff --git a/Sources/PackageMetadata/PackageMetadata.swift b/Sources/PackageMetadata/PackageMetadata.swift index 12d5833c4de..531430a09dd 100644 --- a/Sources/PackageMetadata/PackageMetadata.swift +++ b/Sources/PackageMetadata/PackageMetadata.swift @@ -24,20 +24,6 @@ import struct Foundation.URL import struct TSCUtility.Version public struct Package { - - public struct Template: Sendable { - public let name: String - public let description: String? - //public let permissions: [String]? TODO ADD - public let arguments: [TemplateArguments]? - } - - public struct TemplateArguments: Sendable { - public let name: String - public let description: String? - public let isRequired: Bool? - } - public enum Source { case indexAndCollections(collections: [PackageCollectionsModel.CollectionIdentifier], indexes: [URL]) case registry(url: URL) @@ -103,7 +89,7 @@ public struct Package { publishedAt: Date? = nil, signingEntity: SigningEntity? = nil, latestVersion: Version? = nil, - source: Source, + source: Source ) { self.identity = identity self.location = location diff --git a/Sources/PackageModel/Module/BinaryModule.swift b/Sources/PackageModel/Module/BinaryModule.swift index fd986ab9aec..8ea3148a830 100644 --- a/Sources/PackageModel/Module/BinaryModule.swift +++ b/Sources/PackageModel/Module/BinaryModule.swift @@ -50,7 +50,7 @@ public final class BinaryModule: Module { buildSettingsDescription: [], pluginUsages: [], usesUnsafeFlags: false, - template: false // TODO: determine whether binary modules can be templates or not + template: false ) } diff --git a/Sources/PackageRegistry/RegistryClient.swift b/Sources/PackageRegistry/RegistryClient.swift index 150ff4faaa7..2eec40a5453 100644 --- a/Sources/PackageRegistry/RegistryClient.swift +++ b/Sources/PackageRegistry/RegistryClient.swift @@ -2156,7 +2156,7 @@ extension RegistryClient { licenseURL: String? = nil, readmeURL: String? = nil, repositoryURLs: [String]? = nil, - originalPublicationTime: Date? = nil, + originalPublicationTime: Date? = nil ) { self.author = author self.description = description diff --git a/Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift b/Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift deleted file mode 100644 index f6f0137342e..00000000000 --- a/Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift +++ /dev/null @@ -1,101 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift open source project -// -// Copyright (c) 2023 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 Commands -import CoreCommands -import Foundation -import PackageModel -import PackageFingerprint -import PackageRegistry -import PackageSigning -import Workspace - -#if USE_IMPL_ONLY_IMPORTS -@_implementationOnly import X509 // FIXME: need this import or else SwiftSigningIdentity initializer fails -#else -import X509 -#endif - -import struct TSCBasic.ByteString -import struct TSCBasic.RegEx -import struct TSCBasic.SHA256 - -import struct TSCUtility.Version - -extension PackageRegistryCommand { - struct Discover: AsyncSwiftCommand { - static let configuration = CommandConfiguration( - abstract: "Get a package registry entry." - ) - - @OptionGroup(visibility: .hidden) - var globalOptions: GlobalOptions - - @Argument(help: .init("URL pointing towards package identifiers", valueName: "scm-url")) - var url: SourceControlURL - - @Flag(name: .customLong("allow-insecure-http"), help: "Allow using a non-HTTPS registry URL.") - var allowInsecureHTTP: Bool = false - - @Option(name: [.customLong("url"), .customLong("registry-url")], help: "Override registry URL.") - var registryURL: URL? - - func run(_ swiftCommandState: SwiftCommandState) async throws { - let packageDirectory = try resolvePackageDirectory(swiftCommandState) - let authorizationProvider = try resolveAuthorizationProvider(swiftCommandState) - - let registryClient = RegistryClient( - configuration: try getRegistriesConfig(swiftCommandState, global: false).configuration, - fingerprintStorage: .none, - fingerprintCheckingMode: .strict, - skipSignatureValidation: false, - signingEntityStorage: .none, - signingEntityCheckingMode: .strict, - authorizationProvider: authorizationProvider, - delegate: .none, - checksumAlgorithm: SHA256() - ) - - let set = try await registryClient.lookupIdentities(scmURL: url, observabilityScope: swiftCommandState.observabilityScope) - - if set.isEmpty { - throw ValidationError.invalidLookupURL(url) - } - - print(set) - } - - private func resolvePackageDirectory(_ swiftCommandState: SwiftCommandState) throws -> AbsolutePath { - let directory = try self.globalOptions.locations.packageDirectory - ?? swiftCommandState.getPackageRoot() - - guard localFileSystem.isDirectory(directory) else { - throw StringError("No package found at '\(directory)'.") - } - - return directory - } - - private func resolveAuthorizationProvider(_ swiftCommandState: SwiftCommandState) throws -> AuthorizationProvider { - guard let provider = try swiftCommandState.getRegistryAuthorizationProvider() else { - throw ValidationError.unknownCredentialStore - } - - return provider - } - } -} - - -extension SourceControlURL: ExpressibleByArgument {} diff --git a/Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift b/Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift deleted file mode 100644 index a785075ef4e..00000000000 --- a/Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift +++ /dev/null @@ -1,164 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift open source project -// -// Copyright (c) 2023 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 Commands -import CoreCommands -import Foundation -import PackageModel -import PackageFingerprint -import PackageRegistry -import PackageSigning -import Workspace - -#if USE_IMPL_ONLY_IMPORTS -@_implementationOnly import X509 // FIXME: need this import or else SwiftSigningIdentity initializer fails -#else -import X509 -#endif - -import struct TSCBasic.ByteString -import struct TSCBasic.RegEx -import struct TSCBasic.SHA256 - -import struct TSCUtility.Version - -extension PackageRegistryCommand { - struct Get: AsyncSwiftCommand { - static let configuration = CommandConfiguration( - abstract: "Get a package registry entry." - ) - - @OptionGroup(visibility: .hidden) - var globalOptions: GlobalOptions - - @Argument(help: .init("The package identifier.", valueName: "package-id")) - var packageIdentity: PackageIdentity - - @Option(help: .init("The package release version being queried.", valueName: "package-version")) - var packageVersion: Version? - - @Flag(help: .init("Fetch the Package.swift manifest of the registry entry", valueName: "manifest")) - var manifest: Bool = false - - @Option(help: .init("Swift tools version of the manifest", valueName: "custom-tools-version")) - var customToolsVersion: String? - - @Flag(name: .customLong("allow-insecure-http"), help: "Allow using a non-HTTPS registry URL.") - var allowInsecureHTTP: Bool = false - - @Option(name: [.customLong("url"), .customLong("registry-url")], help: "Override registry URL.") - var registryURL: URL? - - func run(_ swiftCommandState: SwiftCommandState) async throws { - let packageDirectory = try resolvePackageDirectory(swiftCommandState) - let registryURL = try resolveRegistryURL(swiftCommandState) - let authorizationProvider = try resolveAuthorizationProvider(swiftCommandState) - - let registryClient = RegistryClient( - configuration: try getRegistriesConfig(swiftCommandState, global: false).configuration, - fingerprintStorage: .none, - fingerprintCheckingMode: .strict, - skipSignatureValidation: false, - signingEntityStorage: .none, - signingEntityCheckingMode: .strict, - authorizationProvider: authorizationProvider, - delegate: .none, - checksumAlgorithm: SHA256() - ) - - try await fetchRegistryData(using: registryClient, swiftCommandState: swiftCommandState) - } - - private func resolvePackageDirectory(_ swiftCommandState: SwiftCommandState) throws -> AbsolutePath { - let directory = try self.globalOptions.locations.packageDirectory - ?? swiftCommandState.getPackageRoot() - - guard localFileSystem.isDirectory(directory) else { - throw StringError("No package found at '\(directory)'.") - } - - return directory - } - - private func resolveRegistryURL(_ swiftCommandState: SwiftCommandState) throws -> URL { - let config = try getRegistriesConfig(swiftCommandState, global: false).configuration - guard let identity = self.packageIdentity.registry else { - throw ValidationError.invalidPackageIdentity(self.packageIdentity) - } - - guard let url = self.registryURL ?? config.registry(for: identity.scope)?.url else { - throw ValidationError.unknownRegistry - } - - let allowHTTP = try self.allowInsecureHTTP && (config.authentication(for: url) == nil) - try url.validateRegistryURL(allowHTTP: allowHTTP) - - return url - } - - private func resolveAuthorizationProvider(_ swiftCommandState: SwiftCommandState) throws -> AuthorizationProvider { - guard let provider = try swiftCommandState.getRegistryAuthorizationProvider() else { - throw ValidationError.unknownCredentialStore - } - - return provider - } - - private func fetchToolsVersion() -> ToolsVersion? { - return customToolsVersion.flatMap { ToolsVersion(string: $0) } - } - - private func fetchRegistryData( - using client: RegistryClient, - swiftCommandState: SwiftCommandState - ) async throws { - let scope = swiftCommandState.observabilityScope - - if manifest { - guard let version = packageVersion else { - throw ValidationError.noPackageVersion(packageIdentity) - } - - let toolsVersion = fetchToolsVersion() - let content = try await client.getManifestContent( - package: self.packageIdentity, - version: version, - customToolsVersion: toolsVersion, - observabilityScope: scope - ) - - print(content) - return - } - - if let version = packageVersion { - let metadata = try await client.getPackageVersionMetadata( - package: self.packageIdentity, - version: version, - fileSystem: localFileSystem, - observabilityScope: scope - ) - - print(metadata) - } else { - let metadata = try await client.getPackageMetadata( - package: self.packageIdentity, - observabilityScope: scope - ) - - print(metadata) - } - } - } -} diff --git a/Sources/PackageRegistryCommand/PackageRegistryCommand.swift b/Sources/PackageRegistryCommand/PackageRegistryCommand.swift index 516ed6947a4..1858a16bcd3 100644 --- a/Sources/PackageRegistryCommand/PackageRegistryCommand.swift +++ b/Sources/PackageRegistryCommand/PackageRegistryCommand.swift @@ -31,9 +31,7 @@ public struct PackageRegistryCommand: AsyncParsableCommand { Unset.self, Login.self, Logout.self, - Publish.self, - Get.self, - Discover.self + Publish.self ], helpNames: [.short, .long, .customLong("help", withSingleDash: true)] ) @@ -143,8 +141,6 @@ public struct PackageRegistryCommand: AsyncParsableCommand { case unknownCredentialStore case invalidCredentialStore(Error) case credentialLengthLimitExceeded(Int) - case noPackageVersion(PackageIdentity) - case invalidLookupURL(SourceControlURL) } static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace.Configuration.Registries { @@ -203,10 +199,6 @@ extension PackageRegistryCommand.ValidationError: CustomStringConvertible { return "credential store is invalid: \(error.interpolationDescription)" case .credentialLengthLimitExceeded(let limit): return "password or access token must be \(limit) characters or less" - case .noPackageVersion(let identity): - return "no package version found for '\(identity)'" - case .invalidLookupURL(let url): - return "no package identifier was found in URL: \(url)" } } } diff --git a/Sources/SourceControl/GitRepository.swift b/Sources/SourceControl/GitRepository.swift index c436067ceb4..7ec04352c8e 100644 --- a/Sources/SourceControl/GitRepository.swift +++ b/Sources/SourceControl/GitRepository.swift @@ -154,8 +154,6 @@ public struct GitRepositoryProvider: RepositoryProvider, Cancellable { ) } - - private func clone( _ repository: RepositorySpecifier, _ origin: String, @@ -234,10 +232,6 @@ public struct GitRepositoryProvider: RepositoryProvider, Cancellable { ) throws -> WorkingCheckout { if editable { - // For editable clones, i.e. the user is expected to directly work on them, first we create - // a clone from our cache of repositories and then we replace the remote to the one originally - // present in the bare repository. - try self.clone( repository, sourcePath.pathString, @@ -254,15 +248,6 @@ public struct GitRepositoryProvider: RepositoryProvider, Cancellable { // FIXME: This is unfortunate that we have to fetch to update remote's data. try clone.fetch() } else { - // Clone using a shared object store with the canonical copy. - // - // We currently expect using shared storage here to be safe because we - // only ever expect to attempt to use the working copy to materialize a - // revision we selected in response to dependency resolution, and if we - // re-resolve such that the objects in this repository changed, we would - // only ever expect to get back a revision that remains present in the - // object storage. - try self.clone( repository, sourcePath.pathString, diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift index 14c7d447352..ace53b74a24 100644 --- a/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift +++ b/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift @@ -56,6 +56,7 @@ public final class InitPackage { case buildToolPlugin = "build-tool-plugin" case commandPlugin = "command-plugin" case macro = "macro" + public var description: String { return rawValue } diff --git a/Sources/Workspace/Workspace+Dependencies.swift b/Sources/Workspace/Workspace+Dependencies.swift index 3fc9272af6d..c7e0fd944e0 100644 --- a/Sources/Workspace/Workspace+Dependencies.swift +++ b/Sources/Workspace/Workspace+Dependencies.swift @@ -51,7 +51,7 @@ import struct PackageModel.TraitDescription import enum PackageModel.TraitConfiguration import class PackageModel.Manifest -public extension Workspace { +extension Workspace { enum ResolvedFileStrategy { case lockFile case update(forceResolution: Bool) @@ -819,91 +819,6 @@ public extension Workspace { } } - /* - func resolveTemplatePackage( - templateDirectory: AbsolutePath? = nil, - - templateURL: SourceControlURL? = nil, - templatePackageID: PackageIdentity? = nil, - observabilityScope: ObservabilityScope, - revisionParsed: String?, - branchParsed: String?, - exactVersion: Version?, - fromParsed: String?, - toParsed: String?, - upToNextMinorParsed: String? - - ) async throws -> AbsolutePath { - if let path = templateDirectory { - // Local filesystem path - let packageRef = PackageReference.root(identity: .init(path: path), path: path) - let dependency = try ManagedDependency.fileSystem(packageRef: packageRef) - - guard case .fileSystem(let resolvedPath) = dependency.state else { - throw InternalError("invalid file system package state") - } - - await self.state.add(dependency: dependency) - try await self.state.save() - return resolvedPath - - } else if let url = templateURL { - // Git URL - let packageRef = PackageReference.remoteSourceControl(identity: PackageIdentity(url: url), url: url) - - let requirement: PackageStateChange.Requirement - if let revision = revisionParsed { - if let branch = branchParsed { - requirement = .revision(.init(identifier:revision), branch: branch) - } else { - requirement = .revision(.init(identifier: revision), branch: nil) - } - } else if let version = exactVersion { - requirement = .version(version) - } else { - throw InternalError("No usable Git version/revision/branch provided") - } - - return try await self.updateDependency( - package: packageRef, - requirement: requirement, - productFilter: .everything, - observabilityScope: observabilityScope - ) - - } else if let packageID = templatePackageID { - // Registry package - let identity = packageID - let packageRef = PackageReference.registry(identity: identity) - - let requirement: PackageStateChange.Requirement - if let exact = exactVersion { - requirement = .version(exact) - } else if let from = fromParsed, let to = toParsed { - // Not supported in updateDependency – adjust logic if needed - throw InternalError("Version range constraints are not supported here") - } else if let upToMinor = upToNextMinorParsed { - // SwiftPM normally supports this – you may need to expand updateDependency to support it - throw InternalError("upToNextMinorFrom not currently supported") - } else { - throw InternalError("No usable Registry version provided") - } - - return try await self.updateDependency( - package: packageRef, - requirement: requirement, - productFilter: .everything, - observabilityScope: observabilityScope - ) - - } else { - throw InternalError("No template source provided (path, url, or package-id)") - } - } - - */ - - public enum ResolutionPrecomputationResult: Equatable { case required(reason: WorkspaceResolveReason) case notRequired From d84681cfe7cda0f5514381792038fa8b4f5c04f4 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 26 Aug 2025 13:06:24 -0400 Subject: [PATCH 085/225] added test coverage to showExecutables to make sure template does not appear in output --- .../ShowExecutables/app/Package.swift | 14 +++++++++----- .../TemplateExample/TemplateExample.swift | 19 +++++++++++++++++++ .../app/{ => Sources/dealer}/main.swift | 0 .../app/Templates/TemplateExample/main.swift | 1 + .../deck-of-playing-cards/Package.swift | 2 +- Tests/CommandsTests/PackageCommandTests.swift | 1 + 6 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 Fixtures/Miscellaneous/ShowExecutables/app/Plugins/TemplateExample/TemplateExample.swift rename Fixtures/Miscellaneous/ShowExecutables/app/{ => Sources/dealer}/main.swift (100%) create mode 100644 Fixtures/Miscellaneous/ShowExecutables/app/Templates/TemplateExample/main.swift diff --git a/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift b/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift index 389ab1be39c..8c636cb4845 100644 --- a/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.10 +// swift-tools-version:999.0 import PackageDescription let package = Package( @@ -6,7 +6,7 @@ let package = Package( products: [ .executable( name: "dealer", - targets: ["Dealer"] + targets: ["dealer"] ), ], dependencies: [ @@ -14,8 +14,12 @@ let package = Package( ], targets: [ .executableTarget( - name: "Dealer", - path: "./" + name: "dealer", ), - ] + ] + .template( + name: "TemplateExample", + dependencies: [], + initialPackageType: .executable, + description: "Make your own Swift package template." + ), ) diff --git a/Fixtures/Miscellaneous/ShowExecutables/app/Plugins/TemplateExample/TemplateExample.swift b/Fixtures/Miscellaneous/ShowExecutables/app/Plugins/TemplateExample/TemplateExample.swift new file mode 100644 index 00000000000..9b8864e877c --- /dev/null +++ b/Fixtures/Miscellaneous/ShowExecutables/app/Plugins/TemplateExample/TemplateExample.swift @@ -0,0 +1,19 @@ + +import Foundation + +import PackagePlugin + +@main +struct TemplateExamplePlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws { + let tool = try context.tool(named: "TemplateExample") + process.executableURL = URL(filePath: tool.url.path()) + process.arguments = arguments.filter { $0 != "--" } + + try process.run() + process.waitUntilExit() + } +} diff --git a/Fixtures/Miscellaneous/ShowExecutables/app/main.swift b/Fixtures/Miscellaneous/ShowExecutables/app/Sources/dealer/main.swift similarity index 100% rename from Fixtures/Miscellaneous/ShowExecutables/app/main.swift rename to Fixtures/Miscellaneous/ShowExecutables/app/Sources/dealer/main.swift diff --git a/Fixtures/Miscellaneous/ShowExecutables/app/Templates/TemplateExample/main.swift b/Fixtures/Miscellaneous/ShowExecutables/app/Templates/TemplateExample/main.swift new file mode 100644 index 00000000000..b2459149e57 --- /dev/null +++ b/Fixtures/Miscellaneous/ShowExecutables/app/Templates/TemplateExample/main.swift @@ -0,0 +1 @@ +print("I'm the template") diff --git a/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift b/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift index 0c23f679535..3dea7337eb5 100644 --- a/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift +++ b/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.10 +// swift-tools-version:999.0.0 import PackageDescription let package = Package( diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index 5cb1111aa46..0d735b9ddbe 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -608,6 +608,7 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase { let (textOutput, _) = try await self.execute(["show-executables", "--format=flatlist"], packagePath: packageRoot) XCTAssert(textOutput.contains("dealer\n")) XCTAssert(textOutput.contains("deck (deck-of-playing-cards)\n")) + XCTAssertFalse(textOutput.contains("TemplateExample")) let (jsonOutput, _) = try await self.execute(["show-executables", "--format=json"], packagePath: packageRoot) let json = try JSON(bytes: ByteString(encodingAsUTF8: jsonOutput)) From 501a1381ea773a324f82d715f60a4c74015f07e0 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 26 Aug 2025 17:13:34 -0400 Subject: [PATCH 086/225] added more tests, updated showtemplates test --- .../ShowExecutables/app/Package.swift | 2 +- .../ShowTemplates/app/Package.swift | 31 +- .../plugin.swift | 2 +- .../app/Sources/dealer/main.swift | 1 + .../Template.swift | 0 .../RequirementResolver.swift | 41 +- Tests/CommandsTests/PackageCommandTests.swift | 21 +- Tests/CommandsTests/TemplateTests.swift | 378 ++++++++++++++++++ 8 files changed, 436 insertions(+), 40 deletions(-) rename Fixtures/Miscellaneous/ShowTemplates/app/Plugins/{dooPlugin => GenerateFromTemplatePlugin}/plugin.swift (89%) create mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Sources/dealer/main.swift rename Fixtures/Miscellaneous/ShowTemplates/app/Templates/{doo => GenerateFromTemplate}/Template.swift (100%) create mode 100644 Tests/CommandsTests/TemplateTests.swift diff --git a/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift b/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift index 8c636cb4845..29a83e676a1 100644 --- a/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift @@ -8,7 +8,7 @@ let package = Package( name: "dealer", targets: ["dealer"] ), - ], + ] + .template(name: "TemplateExample"), dependencies: [ .package(path: "../deck-of-playing-cards"), ], diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift index 7680cba36ec..5be9ac6a666 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -2,27 +2,32 @@ import PackageDescription let package = Package( - name: "Dealer", - products: Product.template(name: "doo"), + name: "GenerateFromTemplate", + products: [ + .executable( + name: "dealer", + targets: ["dealer"] + ), + ] + .template(name: "GenerateFromTemplate"), dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2") - ], - targets: Target.template( - name: "doo", + targets: [ + .executableTarget( + name: "dealer", + ), + ] + .template( + name: "GenerateFromTemplate", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "SystemPackage", package: "swift-system") - ], - templateInitializationOptions: .packageInit( - templateType: .executable, - templatePermissions: [ - .allowNetworkConnections(scope: .local(ports: [1200]), reason: "why not") - ], - description: "A template that generates a starter executable package" - ) + initialPackageType: .executable, + templatePermissions: [ + .allowNetworkConnections(scope: .local(ports: [1200]), reason: "") + ], + description: "A template that generates a starter executable package" ) ) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/dooPlugin/plugin.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateFromTemplatePlugin/plugin.swift similarity index 89% rename from Fixtures/Miscellaneous/ShowTemplates/app/Plugins/dooPlugin/plugin.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateFromTemplatePlugin/plugin.swift index 00950ba3014..b74943ccbd4 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/dooPlugin/plugin.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateFromTemplatePlugin/plugin.swift @@ -17,7 +17,7 @@ struct TemplatePlugin: CommandPlugin { context: PluginContext, arguments: [String] ) async throws { - let tool = try context.tool(named: "doo") + let tool = try context.tool(named: "GenerateFromTemplate") let process = Process() process.executableURL = URL(filePath: tool.url.path()) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Sources/dealer/main.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Sources/dealer/main.swift new file mode 100644 index 00000000000..6e592945d1b --- /dev/null +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Sources/dealer/main.swift @@ -0,0 +1 @@ +print("I am a dealer") diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/doo/Template.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateFromTemplate/Template.swift similarity index 100% rename from Fixtures/Miscellaneous/ShowTemplates/app/Templates/doo/Template.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateFromTemplate/Template.swift diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index 02883294ea8..43e941dda8d 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -18,7 +18,7 @@ import TSCUtility /// based on versioning input (e.g., version, branch, or revision). protocol DependencyRequirementResolving { func resolveSourceControl() throws -> PackageDependency.SourceControl.Requirement - func resolveRegistry() throws -> PackageDependency.Registry.Requirement + func resolveRegistry() throws -> PackageDependency.Registry.Requirement? } @@ -58,7 +58,9 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// - Throws: `StringError` if multiple or no input fields are set, or if `to` is used without `from` or /// `upToNextMinorFrom`. func resolveSourceControl() throws -> PackageDependency.SourceControl.Requirement { + var specifiedRequirements: [PackageDependency.SourceControl.Requirement] = [] + if let v = exact { specifiedRequirements.append(.exact(v)) } if let b = branch { specifiedRequirements.append(.branch(b)) } if let r = revision { specifiedRequirements.append(.revision(r)) } @@ -73,10 +75,16 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { throw DependencyRequirementError.multipleRequirementsSpecified } - if case .range(let range) = specifiedRequirements, let upper = to { - return .range(range.lowerBound ..< upper) - } else if self.to != nil { - throw DependencyRequirementError.invalidToParameterWithoutFrom + if case .range(let range) = specifiedRequirements { + if let to { + return .range(range.lowerBound ..< to) + } else { + return .range(range) + } + } else { + if self.to != nil { + throw DependencyRequirementError.invalidToParameterWithoutFrom + } } return specifiedRequirements @@ -87,7 +95,10 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// - Returns: A valid `PackageDependency.Registry.Requirement`. /// - Throws: `StringError` if more than one registry versioning input is provided or if `to` is used without a base /// range. - func resolveRegistry() throws -> PackageDependency.Registry.Requirement { + func resolveRegistry() throws -> PackageDependency.Registry.Requirement? { + if exact == nil, from == nil, upToNextMinorFrom == nil, to == nil { + return nil + } var specifiedRequirements: [PackageDependency.Registry.Requirement] = [] @@ -103,10 +114,16 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { throw DependencyRequirementError.multipleRequirementsSpecified } - if case .range(let range) = specifiedRequirements, let upper = to { - return .range(range.lowerBound ..< upper) - } else if self.to != nil { - throw DependencyRequirementError.invalidToParameterWithoutFrom + if case .range(let range) = specifiedRequirements { + if let to { + return .range(range.lowerBound ..< to) + } else { + return .range(range) + } + } else { + if self.to != nil { + throw DependencyRequirementError.invalidToParameterWithoutFrom + } } return specifiedRequirements @@ -129,9 +146,9 @@ enum DependencyRequirementError: Error, CustomStringConvertible { var description: String { switch self { case .multipleRequirementsSpecified: - return "Specify exactly one source control version requirement." + return "Specify exactly version requirement." case .noRequirementSpecified: - return "No source control version requirement specified." + return "No exact or lower bound version requirement specified." case .invalidToParameterWithoutFrom: return "--to requires --from or --up-to-next-minor-from" } diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index 0d735b9ddbe..4e294953540 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -638,33 +638,28 @@ class PackageCommandTestCase: CommandsBuildProviderTestCase { } } - func testShowTemplates() async throws { //john-to-revisit + func testShowTemplates() async throws { try await fixture(name: "Miscellaneous/ShowTemplates") { fixturePath in let packageRoot = fixturePath.appending("app") let (textOutput, _) = try await self.execute(["show-templates", "--format=flatlist"], packagePath: packageRoot) - XCTAssert(textOutput.contains("GenerateStuff\n")) - XCTAssert(textOutput.contains("GenerateThings\n")) + XCTAssert(textOutput.contains("GenerateFromTemplate")) + let (jsonOutput, _) = try await self.execute(["show-templates", "--format=json"], packagePath: packageRoot) let json = try JSON(bytes: ByteString(encodingAsUTF8: jsonOutput)) guard case let .array(contents) = json else { XCTFail("unexpected result"); return } - XCTAssertEqual(2, contents.count) + XCTAssertEqual(1, contents.count) guard case let first = contents.first else { XCTFail("unexpected result"); return } - guard case let .dictionary(generateStuff) = first else { XCTFail("unexpected result"); return } - guard case let .string(generateStuffName)? = generateStuff["name"] else { XCTFail("unexpected result"); return } - XCTAssertEqual(generateStuffName, "GenerateThings") - if case let .string(package)? = generateStuff["package"] { + guard case let .dictionary(generateFromTemplate) = first else { XCTFail("unexpected result"); return } + guard case let .string(generateFromTemplateName)? = generateFromTemplate["name"] else { XCTFail("unexpected result"); return } + XCTAssertEqual(generateFromTemplateName, "GenerateFromTemplate") + if case let .string(package)? = generateFromTemplate["package"] { XCTFail("unexpected package for dealer (should be unset): \(package)") return } - - guard case let last = contents.last else { XCTFail("unexpected result"); return } - guard case let .dictionary(generateThings) = last else { XCTFail("unexpected result"); return } - guard case let .string(generateThingsName)? = generateThings["name"] else { XCTFail("unexpected result"); return } - XCTAssertEqual(generateThingsName, "GenerateStuff") } } diff --git a/Tests/CommandsTests/TemplateTests.swift b/Tests/CommandsTests/TemplateTests.swift new file mode 100644 index 00000000000..7dd5e33d115 --- /dev/null +++ b/Tests/CommandsTests/TemplateTests.swift @@ -0,0 +1,378 @@ +//===----------------------------------------------------------------------===// +// +// 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 +@testable import CoreCommands +@testable import Commands +@testable import PackageModel + +import Foundation + +@_spi(DontAdoptOutsideOfSwiftPMExposedForBenchmarksAndTestsOnly) +import PackageGraph +import TSCUtility +import PackageLoading +import SourceControl +import SPMBuildCore +import _InternalTestSupport +import Workspace +import Testing + +import struct TSCBasic.ByteString +import class TSCBasic.BufferedOutputByteStream +import enum TSCBasic.JSON +import class Basics.AsyncProcess + + +@Suite("Template Tests") struct TestTemplates { + + + //maybe add tags + @Test func resolveSourceTests() { + + let resolver = DefaultTemplateSourceResolver() + + let nilSource = resolver.resolveSource( + directory: nil, url: nil, packageID: nil + ) + #expect(nilSource == nil) + + let localSource = resolver.resolveSource( + directory: AbsolutePath("/fake/path/to/template"), url: nil, packageID: nil + ) + #expect(localSource == .local) + + let packageIDSource = resolver.resolveSource( + directory: AbsolutePath("/fake/path/to/template"), url: nil, packageID: "foo.bar" + ) + #expect(packageIDSource == .registry) + + let gitSource = resolver.resolveSource( + directory: AbsolutePath("/fake/path/to/template"), url: "https://github.com/foo/bar", packageID: "foo.bar" + ) + #expect(gitSource == .git) + + } + + @Test func resolveRegistryDependencyTests() throws { + + let lowerBoundVersion = Version(stringLiteral: "1.2.0") + let higherBoundVersion = Version(stringLiteral: "3.0.0") + + // if exact, from, upToNextMinorFrom and to are nil, then should return nil + let nilRegistryDependency = try DependencyRequirementResolver( + exact: nil, + revision: "revision", + branch: "branch", + from: nil, + upToNextMinorFrom: nil, + to: nil + ).resolveRegistry() + + #expect(nilRegistryDependency == nil) + + // test exact specification + let exactRegistryDependency = try DependencyRequirementResolver( + exact: lowerBoundVersion, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: nil + ).resolveRegistry() + + #expect(exactRegistryDependency == PackageDependency.Registry.Requirement.exact(lowerBoundVersion)) + + + // test from to + let fromToRegistryDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: nil, + to: higherBoundVersion + ).resolveRegistry() + + #expect(fromToRegistryDependency == PackageDependency.Registry.Requirement.range(lowerBoundVersion ..< higherBoundVersion)) + + // test up-to-next-minor-from and to + let upToNextMinorFromToRegistryDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: lowerBoundVersion, + to: higherBoundVersion + ).resolveRegistry() + + #expect(upToNextMinorFromToRegistryDependency == PackageDependency.Registry.Requirement.range(lowerBoundVersion ..< higherBoundVersion)) + + // test just from + let fromRegistryDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: nil, + to: nil + ).resolveRegistry() + + #expect(fromRegistryDependency == PackageDependency.Registry.Requirement.range(.upToNextMajor(from: lowerBoundVersion))) + + // test just up-to-next-minor-from + let upToNextMinorFromRegistryDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: lowerBoundVersion, + to: nil + ).resolveRegistry() + + #expect(upToNextMinorFromRegistryDependency == PackageDependency.Registry.Requirement.range(.upToNextMinor(from: lowerBoundVersion))) + + + #expect(throws: DependencyRequirementError.multipleRequirementsSpecified.self) { + try DependencyRequirementResolver( + exact: lowerBoundVersion, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: lowerBoundVersion, + to: nil + ).resolveRegistry() + } + + #expect(throws: DependencyRequirementError.noRequirementSpecified.self) { + try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: lowerBoundVersion + ).resolveRegistry() + } + + #expect(throws: DependencyRequirementError.invalidToParameterWithoutFrom.self) { + try DependencyRequirementResolver( + exact: lowerBoundVersion, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: higherBoundVersion + ).resolveRegistry() + } + } + + // TODO: should we add edge cases to < from and from == from + @Test func resolveSourceControlDependencyTests() throws { + + let lowerBoundVersion = Version(stringLiteral: "1.2.0") + let higherBoundVersion = Version(stringLiteral: "3.0.0") + + let branchSourceControlDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: "master", + from: nil, + upToNextMinorFrom: nil, + to: nil + ).resolveSourceControl() + + #expect(branchSourceControlDependency == PackageDependency.SourceControl.Requirement.branch("master")) + + let revisionSourceControlDependency = try DependencyRequirementResolver( + exact: nil, + revision: "dae86e", + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: nil + ).resolveSourceControl() + + #expect(revisionSourceControlDependency == PackageDependency.SourceControl.Requirement.revision("dae86e")) + + // test exact specification + let exactSourceControlDependency = try DependencyRequirementResolver( + exact: lowerBoundVersion, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: nil + ).resolveSourceControl() + + #expect(exactSourceControlDependency == PackageDependency.SourceControl.Requirement.exact(lowerBoundVersion)) + + // test from to + let fromToSourceControlDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: nil, + to: higherBoundVersion + ).resolveSourceControl() + + #expect(fromToSourceControlDependency == PackageDependency.SourceControl.Requirement.range(lowerBoundVersion ..< higherBoundVersion)) + + // test up-to-next-minor-from and to + let upToNextMinorFromToSourceControlDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: lowerBoundVersion, + to: higherBoundVersion + ).resolveSourceControl() + + #expect(upToNextMinorFromToSourceControlDependency == PackageDependency.SourceControl.Requirement.range(lowerBoundVersion ..< higherBoundVersion)) + + // test just from + let fromSourceControlDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: nil, + to: nil + ).resolveSourceControl() + + #expect(fromSourceControlDependency == PackageDependency.SourceControl.Requirement.range(.upToNextMajor(from: lowerBoundVersion))) + + // test just up-to-next-minor-from + let upToNextMinorFromSourceControlDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: lowerBoundVersion, + to: nil + ).resolveSourceControl() + + #expect(upToNextMinorFromSourceControlDependency == PackageDependency.SourceControl.Requirement.range(.upToNextMinor(from: lowerBoundVersion))) + + + #expect(throws: DependencyRequirementError.multipleRequirementsSpecified.self) { + try DependencyRequirementResolver( + exact: lowerBoundVersion, + revision: "dae86e", + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: lowerBoundVersion, + to: nil + ).resolveSourceControl() + } + + #expect(throws: DependencyRequirementError.noRequirementSpecified.self) { + try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: lowerBoundVersion + ).resolveSourceControl() + } + + #expect(throws: DependencyRequirementError.invalidToParameterWithoutFrom.self) { + try DependencyRequirementResolver( + exact: lowerBoundVersion, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: higherBoundVersion + ).resolveSourceControl() + } + } + + // test local + // test git + // test registry + + @Test func localTemplatePathResolver() async throws { + let mockTemplatePath = AbsolutePath("/fake/path/to/template") + let options = try GlobalOptions.parse([]) + + let tool = try SwiftCommandState.makeMockState(options: options) + + let path = try await TemplatePathResolver( + source: .local, + templateDirectory: mockTemplatePath, + templateURL: nil, + sourceControlRequirement: nil, + registryRequirement: nil, + packageIdentity: nil, + swiftCommandState: tool + ).resolve() + + #expect(path == mockTemplatePath) + } + + // Need to add traits of not running on windows, and CI + @Test func gitTemplatePathResolver() async throws { + + try await testWithTemporaryDirectory { path in + + let sourceControlRequirement = PackageDependency.SourceControl.Requirement.branch("main") + let options = try GlobalOptions.parse([]) + + let tool = try SwiftCommandState.makeMockState(options: options) + + let templateRepoPath = path.appending(component: "template-repo") + let sourceControlURL = SourceControlURL(stringLiteral: templateRepoPath.pathString) + let templateRepoURL = sourceControlURL.url + try! makeDirectories(templateRepoPath) + initGitRepo(templateRepoPath, tag: "1.2.3") + + let resolver = try TemplatePathResolver( + source: .git, + templateDirectory: nil, + templateURL: templateRepoURL?.absoluteString, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: nil, + packageIdentity: nil, + swiftCommandState: tool + ) + let path = try await resolver.resolve() + #expect(try localFileSystem.exists(path.appending(component: "file.swift")), "Template was not fetched correctly") + } + } + + @Test func packageRegistryTemplatePathResolver() async throws { + //TODO: im too lazy right now + } + + //should we clean up after?? + @Test func initDirectoryManagerCreateTempDirs() throws { + + let options = try GlobalOptions.parse([]) + + let tool = try SwiftCommandState.makeMockState(options: options) + + let (stagingPath, cleanupPath, tempDir) = try TemplateInitializationDirectoryManager(fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope).createTemporaryDirectories() + + + #expect(stagingPath.parentDirectory == tempDir) + #expect(cleanupPath.parentDirectory == tempDir) + + #expect(stagingPath.basename == "generated-package") + #expect(cleanupPath.basename == "clean-up") + + #expect(tool.fileSystem.exists(stagingPath)) + #expect(tool.fileSystem.exists(cleanupPath)) + } +} From f5e679d7a67530679c8e7a50c90e990e9e808c9a Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 27 Aug 2025 16:42:52 -0400 Subject: [PATCH 087/225] added more test coverage --- .../generated-package/.gitignore | 8 ++ .../generated-package/Package.swift | 14 +++ .../generated-package/Sources/main.swift | 4 + .../InferPackageType/Package.swift | 73 ++++++++++++ .../main.swift | 13 ++ .../initialTypeCommandPluginPlugin/main.swift | 13 ++ .../Plugins/initialTypeEmptyPlugin/main.swift | 13 ++ .../initialTypeExecutablePlugin/main.swift | 13 ++ .../initialTypeLibraryPlugin/main.swift | 13 ++ .../Plugins/initialTypeMacroPlugin/main.swift | 13 ++ .../Plugins/initialTypeToolPlugin/main.swift | 13 ++ .../initialTypeBuildToolPlugin/main.swift | 1 + .../initialTypeCommandPlugin/main.swift | 1 + .../Templates/initialTypeEmpty/main.swift | 1 + .../initialTypeExecutable/main.swift | 1 + .../Templates/initialTypeLibrary/main.swift | 1 + .../Templates/initialTypeMacro/main.swift | 1 + .../Templates/initialTypeTool/main.swift | 1 + .../PackageInitializer.swift | 11 +- Tests/CommandsTests/TemplateTests.swift | 111 ++++++++++++++++++ 20 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore create mode 100644 Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Package.swift create mode 100644 Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Sources/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Package.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeBuildToolPluginPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeCommandPluginPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeEmptyPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeExecutablePlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeLibraryPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeMacroPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeToolPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeBuildToolPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeCommandPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeEmpty/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeExecutable/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeLibrary/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeMacro/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeTool/main.swift diff --git a/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore new file mode 100644 index 00000000000..0023a534063 --- /dev/null +++ b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Package.swift b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Package.swift new file mode 100644 index 00000000000..c8c67f66aad --- /dev/null +++ b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Package.swift @@ -0,0 +1,14 @@ +// swift-tools-version: 6.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "generated-package", + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .executableTarget( + name: "generated-package"), + ] +) diff --git a/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Sources/main.swift b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Sources/main.swift new file mode 100644 index 00000000000..44e20d5acc4 --- /dev/null +++ b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Sources/main.swift @@ -0,0 +1,4 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +print("Hello, world!") diff --git a/Fixtures/Miscellaneous/InferPackageType/Package.swift b/Fixtures/Miscellaneous/InferPackageType/Package.swift new file mode 100644 index 00000000000..714cf42da70 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Package.swift @@ -0,0 +1,73 @@ +// swift-tools-version:999.0.0 +import PackageDescription + +let initialLibrary: [Target] = .template( + name: "initialTypeLibrary", + dependencies: [], + initialPackageType: .library, + description: "" + ) + + +let initialExecutable: [Target] = .template( + name: "initialTypeExecutable", + dependencies: [], + initialPackageType: .executable, + description: "" + ) + + +let initialTool: [Target] = .template( + name: "initialTypeTool", + dependencies: [], + initialPackageType: .tool, + description: "" + ) + + +let initialBuildToolPlugin: [Target] = .template( + name: "initialTypeBuildToolPlugin", + dependencies: [], + initialPackageType: .buildToolPlugin, + description: "" + ) + + +let initialCommandPlugin: [Target] = .template( + name: "initialTypeCommandPlugin", + dependencies: [], + initialPackageType: .commandPlugin, + description: "" + ) + + +let initialMacro: [Target] = .template( + name: "initialTypeMacro", + dependencies: [], + initialPackageType: .`macro`, + description: "" + ) + + + +let initialEmpty: [Target] = .template( + name: "initialTypeEmpty", + dependencies: [], + initialPackageType: .empty, + description: "" + ) + +var products: [Product] = .template(name: "initialTypeLibrary") + +products += .template(name: "initialTypeExecutable") +products += .template(name: "initialTypeTool") +products += .template(name: "initialTypeBuildToolPlugin") +products += .template(name: "initialTypeCommandPlugin") +products += .template(name: "initialTypeMacro") +products += .template(name: "initialTypeEmpty") + +let package = Package( + name: "InferPackageType", + products: products, + targets: initialLibrary + initialExecutable + initialTool + initialBuildToolPlugin + initialCommandPlugin + initialMacro + initialEmpty +) diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeBuildToolPluginPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeBuildToolPluginPlugin/main.swift new file mode 100644 index 00000000000..aa73dd6b8ae --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeBuildToolPluginPlugin/main.swift @@ -0,0 +1,13 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct FooPlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws {} +} + diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeCommandPluginPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeCommandPluginPlugin/main.swift new file mode 100644 index 00000000000..aa73dd6b8ae --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeCommandPluginPlugin/main.swift @@ -0,0 +1,13 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct FooPlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws {} +} + diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeEmptyPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeEmptyPlugin/main.swift new file mode 100644 index 00000000000..aa73dd6b8ae --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeEmptyPlugin/main.swift @@ -0,0 +1,13 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct FooPlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws {} +} + diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeExecutablePlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeExecutablePlugin/main.swift new file mode 100644 index 00000000000..aa73dd6b8ae --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeExecutablePlugin/main.swift @@ -0,0 +1,13 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct FooPlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws {} +} + diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeLibraryPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeLibraryPlugin/main.swift new file mode 100644 index 00000000000..aa73dd6b8ae --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeLibraryPlugin/main.swift @@ -0,0 +1,13 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct FooPlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws {} +} + diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeMacroPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeMacroPlugin/main.swift new file mode 100644 index 00000000000..aa73dd6b8ae --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeMacroPlugin/main.swift @@ -0,0 +1,13 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct FooPlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws {} +} + diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeToolPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeToolPlugin/main.swift new file mode 100644 index 00000000000..aa73dd6b8ae --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeToolPlugin/main.swift @@ -0,0 +1,13 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct FooPlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws {} +} + diff --git a/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeBuildToolPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeBuildToolPlugin/main.swift new file mode 100644 index 00000000000..3d3d53a1627 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeBuildToolPlugin/main.swift @@ -0,0 +1 @@ +print("foo") diff --git a/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeCommandPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeCommandPlugin/main.swift new file mode 100644 index 00000000000..3d3d53a1627 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeCommandPlugin/main.swift @@ -0,0 +1 @@ +print("foo") diff --git a/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeEmpty/main.swift b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeEmpty/main.swift new file mode 100644 index 00000000000..3d3d53a1627 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeEmpty/main.swift @@ -0,0 +1 @@ +print("foo") diff --git a/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeExecutable/main.swift b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeExecutable/main.swift new file mode 100644 index 00000000000..3d3d53a1627 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeExecutable/main.swift @@ -0,0 +1 @@ +print("foo") diff --git a/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeLibrary/main.swift b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeLibrary/main.swift new file mode 100644 index 00000000000..3d3d53a1627 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeLibrary/main.swift @@ -0,0 +1 @@ +print("foo") diff --git a/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeMacro/main.swift b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeMacro/main.swift new file mode 100644 index 00000000000..3d3d53a1627 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeMacro/main.swift @@ -0,0 +1 @@ +print("foo") diff --git a/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeTool/main.swift b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeTool/main.swift new file mode 100644 index 00000000000..3d3d53a1627 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeTool/main.swift @@ -0,0 +1 @@ +print("foo") diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index d0a20ece22b..c1cb78c4a67 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -53,7 +53,7 @@ struct TemplatePackageInitializer: PackageInitializer { let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) let (stagingPath, cleanupPath, tempDir) = try directoryManager.createTemporaryDirectories() - let packageType = try await inferPackageType(from: resolvedTemplatePath) + let packageType = try await TemplatePackageInitializer.inferPackageType(from: resolvedTemplatePath, templateName: templateName, swiftCommandState: swiftCommandState) let builder = DefaultPackageDependencyBuilder( templateSource: templateSource, @@ -111,11 +111,8 @@ struct TemplatePackageInitializer: PackageInitializer { } } - private func inferPackageType(from templatePath: Basics.AbsolutePath) async throws -> InitPackage.PackageType { - try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { _, _ in - let workspace = try swiftCommandState.getActiveWorkspace() - let root = try swiftCommandState.getWorkspaceRoot() - + static func inferPackageType(from templatePath: Basics.AbsolutePath, templateName: String?, swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType { + try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { workspace, root in let rootManifests = try await workspace.loadRootManifests( packages: root.packages, observabilityScope: swiftCommandState.observabilityScope @@ -143,7 +140,7 @@ struct TemplatePackageInitializer: PackageInitializer { } } - private func findTemplateName(from manifest: Manifest) throws -> String { + static func findTemplateName(from manifest: Manifest) throws -> String { let templateTargets = manifest.targets.compactMap { target -> String? in if let options = target.templateInitializationOptions, case .packageInit = options { diff --git a/Tests/CommandsTests/TemplateTests.swift b/Tests/CommandsTests/TemplateTests.swift index 7dd5e33d115..0c902cb5416 100644 --- a/Tests/CommandsTests/TemplateTests.swift +++ b/Tests/CommandsTests/TemplateTests.swift @@ -11,6 +11,8 @@ //===----------------------------------------------------------------------===// import Basics + +@_spi(SwiftPMInternal) @testable import CoreCommands @testable import Commands @testable import PackageModel @@ -18,6 +20,7 @@ import Basics import Foundation @_spi(DontAdoptOutsideOfSwiftPMExposedForBenchmarksAndTestsOnly) + import PackageGraph import TSCUtility import PackageLoading @@ -27,6 +30,7 @@ import _InternalTestSupport import Workspace import Testing + import struct TSCBasic.ByteString import class TSCBasic.BufferedOutputByteStream import enum TSCBasic.JSON @@ -375,4 +379,111 @@ import class Basics.AsyncProcess #expect(tool.fileSystem.exists(stagingPath)) #expect(tool.fileSystem.exists(cleanupPath)) } + + @Test func initDirectoryManagerFinalize() async throws { + + try await fixture(name: "Miscellaneous/DirectoryManagerFinalize", createGitRepo: false) { fixturePath in + let stagingPath = fixturePath.appending("generated-package") + let cleanupPath = fixturePath.appending("clean-up") + let cwd = fixturePath.appending("cwd") + + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + // Build it. TODO: CHANGE THE XCTAsserts build to the swift testing helper function instead + await XCTAssertBuilds(stagingPath) + + + let stagingBuildPath = stagingPath.appending(".build") + let binFile = stagingBuildPath.appending(components: try UserToolchain.default.targetTriple.platformBuildPathComponent, "debug", "generated-package") + #expect(localFileSystem.exists(binFile)) + #expect(localFileSystem.isDirectory(stagingBuildPath)) + + + try await TemplateInitializationDirectoryManager( + fileSystem: tool.fileSystem, + observabilityScope: tool.observabilityScope + ).finalize(cwd: cwd, stagingPath: stagingPath, cleanupPath: cleanupPath, swiftCommandState: tool) + + let cwdBuildPath = cwd.appending(".build") + let cwdBinaryFile = cwdBuildPath.appending(components: try UserToolchain.default.targetTriple.platformBuildPathComponent, "debug", "generated-package") + + // Postcondition checks + #expect(localFileSystem.exists(cwd), "cwd should exist after finalize") + #expect(localFileSystem.exists(cwdBinaryFile) == false, "Binary should have been cleaned before copying to cwd") + } + } + + @Test func initPackageInitializer() throws { + + let globalOptions = try GlobalOptions.parse([]) + let testLibraryOptions = try TestLibraryOptions.parse([]) + let buildOptions = try BuildCommandOptions.parse([]) + let directoryPath = AbsolutePath("/") + let tool = try SwiftCommandState.makeMockState(options: globalOptions) + + + let templatePackageInitializer = try PackageInitConfiguration( + swiftCommandState: tool, + name: "foo", + initMode: "template", + testLibraryOptions: testLibraryOptions, + buildOptions: buildOptions, + globalOptions: globalOptions, + validatePackage: true, + args: ["--foobar foo"], + directory: directoryPath, + url: nil, + packageID: "foo.bar", + versionFlags: VersionFlags(exact: nil, revision: nil, branch: "master", from: nil, upToNextMinorFrom: nil ,to: nil) + ).makeInitializer() + + #expect(templatePackageInitializer is TemplatePackageInitializer) + + + let standardPackageInitalizer = try PackageInitConfiguration( + swiftCommandState: tool, + name: "foo", + initMode: "template", + testLibraryOptions: testLibraryOptions, + buildOptions: buildOptions, + globalOptions: globalOptions, + validatePackage: true, + args: ["--foobar foo"], + directory: nil, + url: nil, + packageID: nil, + versionFlags: VersionFlags(exact: nil, revision: nil, branch: "master", from: nil, upToNextMinorFrom: nil ,to: nil) + ).makeInitializer() + + #expect(standardPackageInitalizer is StandardPackageInitializer) + } + + //tests: + // infer package type + // set up the template package + + //TODO: Fix here, as mocking swiftCommandState resolves to linux triple, but if testing on Darwin, runs into precondition error. + /* + @Test func inferInitialPackageType() async throws { + + try await fixture(name: "Miscellaneous/InferPackageType") { fixturePath in + + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + + let libraryType = try await TemplatePackageInitializer.inferPackageType(from: fixturePath, templateName: "initialTypeLibrary", swiftCommandState: tool) + + + #expect(libraryType.rawValue == "library") + } + + } + */ } + + + + + From 02c103507fdbf904797b98fb454541acdcbc308b Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 27 Aug 2025 17:14:01 -0400 Subject: [PATCH 088/225] added observability scope to templatepathresolver + ssh error message for special case where authentication is needed --- .../TemplatePathResolver.swift | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index 74565b1b2a7..a5e57a35699 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -74,18 +74,21 @@ struct TemplatePathResolver { switch source { case .local: guard let path = templateDirectory else { + swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingLocalTemplatePath) throw TemplatePathResolverError.missingLocalTemplatePath } self.fetcher = LocalTemplateFetcher(path: path) case .git: guard let url = templateURL, let requirement = sourceControlRequirement else { + swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingGitURLOrRequirement) throw TemplatePathResolverError.missingGitURLOrRequirement } - self.fetcher = GitTemplateFetcher(source: url, requirement: requirement) + self.fetcher = GitTemplateFetcher(source: url, requirement: requirement, swiftCommandState: swiftCommandState) case .registry: guard let identity = packageIdentity, let requirement = registryRequirement else { + swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingRegistryIdentityOrRequirement) throw TemplatePathResolverError.missingRegistryIdentityOrRequirement } self.fetcher = RegistryTemplateFetcher( @@ -95,6 +98,7 @@ struct TemplatePathResolver { ) case .none: + swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingTemplateType) throw TemplatePathResolverError.missingTemplateType } } @@ -151,12 +155,14 @@ struct LocalTemplateFetcher: TemplateFetcher { /// The template is cloned into a temporary directory, checked out, and returned. struct GitTemplateFetcher: TemplateFetcher { + /// The Git URL of the remote repository. let source: String /// The source control requirement used to determine which version/branch/revision to check out. let requirement: PackageDependency.SourceControl.Requirement + let swiftCommandState: SwiftCommandState /// Fetches the repository and returns the path to the checked-out working copy. /// /// - Returns: A path to the directory containing the fetched template. @@ -196,14 +202,27 @@ struct GitTemplateFetcher: TemplateFetcher { do { try provider.fetch(repository: repositorySpecifier, to: path) } catch { + if isSSHPermissionError(error) { + swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.sshAuthenticationRequired(source: source)) + throw GitTemplateFetcherError.sshAuthenticationRequired(source: source) + } + swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.cloneFailed(source: source, underlyingError: error)) throw GitTemplateFetcherError.cloneFailed(source: source, underlyingError: error) } } + private func isSSHPermissionError(_ error: Error) -> Bool { + let errorString = String(describing: error).lowercased() + return errorString.contains("permission denied") && + errorString.contains("publickey") && + source.hasPrefix("git@") + } + /// Validates that the directory contains a valid Git repository. private func validateBareRepository(at path: Basics.AbsolutePath) throws { let provider = GitRepositoryProvider() guard try provider.isValidDirectory(path) else { + swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.invalidRepositoryDirectory(path: path)) throw GitTemplateFetcherError.invalidRepositoryDirectory(path: path) } } @@ -223,6 +242,7 @@ struct GitTemplateFetcher: TemplateFetcher { editable: true ) } catch { + swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.createWorkingCopyFailed(path: workingCopyPath, underlyingError: error)) throw GitTemplateFetcherError.createWorkingCopyFailed(path: workingCopyPath, underlyingError: error) } } @@ -247,6 +267,7 @@ struct GitTemplateFetcher: TemplateFetcher { let versions = tags.compactMap { Version($0) } let filteredVersions = versions.filter { range.contains($0) } guard let latestVersion = filteredVersions.max() else { + swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.noMatchingTagInRange(range)) throw GitTemplateFetcherError.noMatchingTagInRange(range) } try repository.checkout(tag: latestVersion.description) @@ -259,11 +280,12 @@ struct GitTemplateFetcher: TemplateFetcher { case createWorkingCopyFailed(path: Basics.AbsolutePath, underlyingError: Error) case checkoutFailed(requirement: PackageDependency.SourceControl.Requirement, underlyingError: Error) case noMatchingTagInRange(Range) + case sshAuthenticationRequired(source: String) var errorDescription: String? { switch self { case .cloneFailed(let source, let error): - return "Failed to clone repository from '\(source)': \(error.localizedDescription)" + return "Failed to clone repository from '\(source)': \(error)" case .invalidRepositoryDirectory(let path): return "Invalid Git repository at path: \(path.pathString)" case .createWorkingCopyFailed(let path, let error): @@ -272,6 +294,8 @@ struct GitTemplateFetcher: TemplateFetcher { return "Failed to checkout using requirement '\(requirement)': \(error.localizedDescription)" case .noMatchingTagInRange(let range): return "No Git tags found within version range \(range)" + case .sshAuthenticationRequired(let source): + return "SSH authentication required for '\(source)'.\nEnsure SSH agent is running and key is loaded:\n\nhttps://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent" } } } @@ -359,6 +383,7 @@ struct RegistryTemplateFetcher: TemplateFetcher { sharedRegistriesFile: sharedFile ) } catch { + swiftCommandState.observabilityScope.emit(RegistryConfigError.failedToLoadConfiguration(file: sharedFile, underlyingError: error)) throw RegistryConfigError.failedToLoadConfiguration(file: sharedFile, underlyingError: error) } } From 6ed7455d1fb9567752d99df1bba862f4401109f8 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 2 Sep 2025 11:10:49 -0400 Subject: [PATCH 089/225] solved bug where test templates would not work as current directory was erroneous if ran in different context --- .../TestCommands/TestTemplateCommand.swift | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index f4e0e972e63..3a6771cf055 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -105,6 +105,11 @@ extension SwiftTestCommand { let commandPlugin = try pluginManager.loadTemplatePlugin() let commandLineFragments = try await pluginManager.run() + + if dryRun { + print(commandLineFragments) + return + } let packageType = try await inferPackageType(swiftCommandState: swiftCommandState, from: cwd) @@ -114,7 +119,7 @@ extension SwiftTestCommand { let folderName = commandLine.fullPathKey - buildMatrix[folderName] = try await testDecisionTreeBranch(folderName: folderName, commandLine: commandLine, swiftCommandState: swiftCommandState, packageType: packageType, commandPlugin: commandPlugin) + buildMatrix[folderName] = try await testDecisionTreeBranch(folderName: folderName, commandLine: commandLine.commandChain, swiftCommandState: swiftCommandState, packageType: packageType, commandPlugin: commandPlugin, cwd: cwd) } @@ -126,7 +131,7 @@ extension SwiftTestCommand { } } - private func testDecisionTreeBranch(folderName: String, commandLine: CommandPath, swiftCommandState: SwiftCommandState, packageType: InitPackage.PackageType, commandPlugin: ResolvedModule) async throws -> BuildInfo { + private func testDecisionTreeBranch(folderName: String, commandLine: [CommandComponent], swiftCommandState: SwiftCommandState, packageType: InitPackage.PackageType, commandPlugin: ResolvedModule, cwd: AbsolutePath) async throws -> BuildInfo { let destinationPath = outputDirectory.appending(component: folderName) swiftCommandState.observabilityScope.emit(debug: "Generating \(folderName)") @@ -139,7 +144,8 @@ extension SwiftTestCommand { destinationAbsolutePath: destinationPath, testingFolderName: folderName, argumentPath: commandLine, - initialPackageType: packageType + initialPackageType: packageType, + cwd: cwd ) } @@ -238,8 +244,9 @@ extension SwiftTestCommand { buildOptions: BuildCommandOptions, destinationAbsolutePath: AbsolutePath, testingFolderName: String, - argumentPath: CommandPath, - initialPackageType: InitPackage.PackageType + argumentPath: [CommandComponent], + initialPackageType: InitPackage.PackageType, + cwd: AbsolutePath ) async throws -> BuildInfo { let startGen = DispatchTime.now() @@ -249,6 +256,7 @@ extension SwiftTestCommand { var buildDuration: DispatchTimeInterval = .never var logPath: String? = nil + var pluginOutput = "" do { let log = destinationAbsolutePath.appending("generation-output.log").pathString let (origOut, origErr) = try redirectStdoutAndStderr(to: log) @@ -256,8 +264,8 @@ extension SwiftTestCommand { let initTemplate = try InitTemplatePackage( name: testingFolderName, - initMode: .fileSystem(name: templateName, path: swiftCommandState.originalWorkingDirectory.pathString), - templatePath: swiftCommandState.originalWorkingDirectory, + initMode: .fileSystem(name: templateName, path: cwd.pathString), + templatePath: cwd, fileSystem: swiftCommandState.fileSystem, packageType: initialPackageType, supportedTestingLibraries: [], @@ -271,26 +279,38 @@ extension SwiftTestCommand { try await swiftCommandState.loadPackageGraph() } - for (index, command) in argumentPath.commandChain.enumerated() { + try await TemplateBuildSupport.buildForTesting(swiftCommandState: swiftCommandState, buildOptions: buildOptions, testingFolder: destinationAbsolutePath) + + var subCommandPath: [String] = [] + for (index, command) in argumentPath.enumerated() { + + subCommandPath.append(contentsOf: (index == 0 ? [] : [command.commandName])) + let commandArgs = command.arguments.flatMap { $0.commandLineFragments } - let fullCommand = (index == 0) ? [] : Array(argumentPath.commandChain.prefix(index + 1).map(\.commandName)) + commandArgs + let fullCommand = subCommandPath + commandArgs print("Running plugin with args:", fullCommand) try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in - _ = try await TemplatePluginRunner.run( + let output = try await TemplatePluginRunner.run( plugin: commandPlugin, package: graph.rootPackages.first!, packageGraph: graph, arguments: fullCommand, swiftCommandState: swiftCommandState ) + pluginOutput = String(data: output, encoding: .utf8) ?? "[Invalid UTF-8 output]" + print(pluginOutput) } } genDuration = startGen.distance(to: .now()) genSuccess = true - try FileManager.default.removeItem(atPath: log) + + if genSuccess { + try FileManager.default.removeItem(atPath: log) + } + } catch { genDuration = startGen.distance(to: .now()) genSuccess = false From c9b78be14c3f7602b17a00299c13fd2fde86ca60 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 2 Sep 2025 13:08:02 -0400 Subject: [PATCH 090/225] fixed bug where answers to arguments of a specific argument branch were not shared outside that branch + improved dry-run output to print command line arguments --- .../TestCommands/TestTemplateCommand.swift | 6 +- .../TemplateTesterManager.swift | 72 +++++++++++++++++-- Tests/CommandsTests/TemplateTests.swift | 5 -- 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 3a6771cf055..2cea98e1064 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -107,7 +107,9 @@ extension SwiftTestCommand { let commandLineFragments = try await pluginManager.run() if dryRun { - print(commandLineFragments) + for commandLine in commandLineFragments { + print(commandLine.displayFormat()) + } return } let packageType = try await inferPackageType(swiftCommandState: swiftCommandState, from: cwd) @@ -310,7 +312,7 @@ extension SwiftTestCommand { if genSuccess { try FileManager.default.removeItem(atPath: log) } - + } catch { genDuration = startGen.distance(to: .now()) genSuccess = false diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index 3860cc8baff..6e0ea9e49cb 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -80,6 +80,52 @@ public struct CommandComponent { let arguments: [TemplateTestPromptingSystem.ArgumentResponse] } +extension CommandPath { + func displayFormat() -> String { + let commandNames = commandChain.map { $0.commandName } + let fullPath = commandNames.joined(separator: " ") + + var result = "Command Path: \(fullPath) \nExecution Steps: \n\n" + + // Build progressive commands + for i in 0.. String { + let formattedArgs = argumentResponses.compactMap { response -> + String? in + guard let preferredName = + response.argument.preferredName?.name else { return nil } + + let values = response.values.joined(separator: " ") + return values.isEmpty ? nil : " --\(preferredName) \(values)" + } + + return formattedArgs.joined(separator: " \\\n") + } +} + + public class TemplateTestPromptingSystem { @@ -131,18 +177,31 @@ public class TemplateTestPromptingSystem { let allArgs = try convertArguments(from: command) - let currentArgs = allArgs.filter { arg in - !visitedArgs.contains(where: {$0.argument.valueName == arg.valueName}) - } + // Separate args into already answered and new ones + var finalArgs: [TemplateTestPromptingSystem.ArgumentResponse] = [] + var newArgs: [ArgumentInfoV0] = [] + + for arg in allArgs { + if let existingArg = visitedArgs.first(where: { $0.argument.valueName == arg.valueName }) { + // Reuse the previously answered argument + finalArgs.append(existingArg) + } else { + // This is a new argument that needs prompting + newArgs.append(arg) + } + } + // Only prompt for new arguments var collected: [String: ArgumentResponse] = [:] - let resolvedArgs = UserPrompter.prompt(for: currentArgs, collected: &collected) + let newResolvedArgs = UserPrompter.prompt(for: newArgs, collected: &collected) - resolvedArgs.forEach { visitedArgs.insert($0) } + // Add new arguments to final list and visited set + finalArgs.append(contentsOf: newResolvedArgs) + newResolvedArgs.forEach { visitedArgs.insert($0) } let currentComponent = CommandComponent( - commandName: command.commandName, arguments: resolvedArgs + commandName: command.commandName, arguments: finalArgs ) var newPath = path @@ -166,7 +225,6 @@ public class TemplateTestPromptingSystem { path.map { $0.commandName }.joined(separator: "-") } - } diff --git a/Tests/CommandsTests/TemplateTests.swift b/Tests/CommandsTests/TemplateTests.swift index 0c902cb5416..715d5916a43 100644 --- a/Tests/CommandsTests/TemplateTests.swift +++ b/Tests/CommandsTests/TemplateTests.swift @@ -482,8 +482,3 @@ import class Basics.AsyncProcess } */ } - - - - - From 9e93966bad995c95decf6b3246fcbf1512a9ee89 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 2 Sep 2025 14:03:24 -0400 Subject: [PATCH 091/225] added predefined arguments to testing, however will need to revisit to get clarity on handling of subcommands --- .../TestCommands/TestTemplateCommand.swift | 2 +- .../TemplateTesterManager.swift | 89 +++++++++++++++++-- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 2cea98e1064..5c31fe92499 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -73,7 +73,7 @@ extension SwiftTestCommand { /// Predetermined arguments specified by the consumer. @Argument( - help: "Predetermined arguments to pass to the template." + help: "Predetermined arguments to pass for testing template." ) var args: [String] = [] diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index 6e0ea9e49cb..c694c96f0b9 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -51,7 +51,7 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { } func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [CommandPath] { - try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command) + try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command, args: args) } public func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { @@ -164,19 +164,95 @@ public class TemplateTestPromptingSystem { // if subcommands exist, then for each subcommand, pass the function again, where we deepCopy a path // if not, then jointhe command names of all the paths, and append CommandPath() - public func generateCommandPaths(rootCommand: CommandInfoV0) throws -> [CommandPath] { + private func parseAndMatchArguments(_ input: [String], definedArgs: [ArgumentInfoV0]) throws -> (Set, [String]) { + var responses = Set() + var providedMap: [String: [String]] = [:] + + var leftover: [String] = [] + + var index = 0 + + while index < input.count { + let token = input[index] + + if token.starts(with: "--") { + let name = String(token.dropFirst(2)) + + guard let arg = definedArgs.first(where : {$0.valueName == name}) else { + // Unknown — defer for potential subcommand + leftover.append(token) + index += 1 + if index < input.count && !input[index].starts(with: "--") { + leftover.append(input[index]) + index += 1 + } + continue + } + + switch arg.kind { + case .flag: + providedMap[name] = ["true"] + case .option: + index += 1 + guard index < input.count else { + throw TemplateError.missingValueForOption(name: name) + } + providedMap[name] = [input[index]] + default: + throw TemplateError.unexpectedNamedArgument(name: name) + } + } else { + leftover.append(token) + } + index += 1 + + } + + for arg in definedArgs { + let name = arg.valueName ?? "__positional" + + guard let values = providedMap[name] else {continue} + + if let allowed = arg.allValues { + let invalid = values.filter {!allowed.contains($0)} + + if !invalid.isEmpty { + throw TemplateError.invalidValue( + argument: name, + invalidValues: invalid, + allowed: allowed + ) + + } + } + responses.insert(ArgumentResponse(argument: arg, values: values)) + providedMap[name] = nil + + } + + return (responses, leftover) + + } + + public func generateCommandPaths(rootCommand: CommandInfoV0, args: [String]) throws -> [CommandPath] { var paths: [CommandPath] = [] var visitedArgs = Set() - try dfs(command: rootCommand, path: [], visitedArgs: &visitedArgs, paths: &paths) + try dfs(command: rootCommand, path: [], visitedArgs: &visitedArgs, paths: &paths, predefinedArgs: args) return paths } - func dfs(command: CommandInfoV0, path: [CommandComponent], visitedArgs: inout Set, paths: inout [CommandPath]) throws{ + func dfs(command: CommandInfoV0, path: [CommandComponent], visitedArgs: inout Set, paths: inout [CommandPath], predefinedArgs: [String]) throws{ let allArgs = try convertArguments(from: command) + var currentPredefinedArgs = predefinedArgs + + let (answeredArgs, leftoverArgs) = try + parseAndMatchArguments(currentPredefinedArgs, definedArgs: allArgs) + + visitedArgs.formUnion(answeredArgs) // Separate args into already answered and new ones var finalArgs: [TemplateTestPromptingSystem.ArgumentResponse] = [] @@ -210,7 +286,7 @@ public class TemplateTestPromptingSystem { if let subcommands = getSubCommand(from: command) { for sub in subcommands { - try dfs(command: sub, path: newPath, visitedArgs: &visitedArgs, paths: &paths) + try dfs(command: sub, path: newPath, visitedArgs: &visitedArgs, paths: &paths, predefinedArgs: leftoverArgs) } } else { let fullPathKey = joinCommandNames(newPath) @@ -403,6 +479,7 @@ private enum TemplateError: Swift.Error { case unexpectedNamedArgument(name: String) case missingValueForOption(name: String) case invalidValue(argument: String, invalidValues: [String], allowed: [String]) + case unexpectedSubcommand(name: String) } extension TemplateError: CustomStringConvertible { @@ -425,6 +502,8 @@ extension TemplateError: CustomStringConvertible { "Missing value for option: \(name)" case .invalidValue(argument: let argument, invalidValues: let invalidValues, allowed: let allowed): "Invalid value \(argument). Valid values are: \(allowed.joined(separator: ", ")). \(invalidValues.isEmpty ? "" : "Also, \(invalidValues.joined(separator: ", ")) are not valid.")" + case .unexpectedSubcommand(name: let name): + "Invalid subcommand \(name) provided in arguments, arguments only accepts flags, options, or positional arguments. Subcommands are treated via the --branch option" } } } From 4d23cc62e37a3db24674fcda60bc19ea167acef5 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 2 Sep 2025 15:56:02 -0400 Subject: [PATCH 092/225] added ability to test specific branches of decision tree, need to flesh out however --- .../TestCommands/TestTemplateCommand.swift | 11 ++++++- .../TemplatePluginCoordinator.swift | 1 + .../TemplatePluginManager.swift | 3 +- .../TemplateTesterManager.swift | 32 +++++++++++++------ 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 5c31fe92499..2597c56aa43 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -77,6 +77,14 @@ extension SwiftTestCommand { ) var args: [String] = [] + @Option( + + name: .customLong("branches"), + parsing: .upToNextOption, + help: "Specify the branch of the template you want to test.", + ) + public var branches: [String] = [] + @Flag(help: "Dry-run to display argument tree") var dryRun: Bool = false @@ -100,7 +108,8 @@ extension SwiftTestCommand { swiftCommandState: swiftCommandState, template: templateName, scratchDirectory: cwd, - args: args + args: args, + branches: branches ) let commandPlugin = try pluginManager.loadTemplatePlugin() diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift index d45496ba94d..aa1e009a58c 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift @@ -21,6 +21,7 @@ struct TemplatePluginCoordinator { let scratchDirectory: Basics.AbsolutePath let template: String? let args: [String] + let branches: [String] let EXPERIMENTAL_DUMP_HELP = ["--", "--experimental-dump-help"] diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 113a9168d6e..00048f1e332 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -38,7 +38,8 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { swiftCommandState: swiftCommandState, scratchDirectory: scratchDirectory, template: template, - args: args + args: args, + branches: [] ) self.packageGraph = try await coordinator.loadPackageGraph() diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index c694c96f0b9..d541685cb9c 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -18,6 +18,7 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { public let scratchDirectory: Basics.AbsolutePath public let args: [String] public let packageGraph: ModulesGraph + public let branches: [String] let coordinator: TemplatePluginCoordinator public var rootPackage: ResolvedPackage { @@ -27,12 +28,13 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { return root } - init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String]) async throws { + init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String], branches: [String]) async throws { let coordinator = TemplatePluginCoordinator( swiftCommandState: swiftCommandState, scratchDirectory: scratchDirectory, template: template, - args: args + args: args, + branches: branches ) self.packageGraph = try await coordinator.loadPackageGraph() @@ -41,6 +43,7 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { self.scratchDirectory = scratchDirectory self.args = args self.coordinator = coordinator + self.branches = branches } func run() async throws -> [CommandPath] { @@ -51,7 +54,7 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { } func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [CommandPath] { - try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command, args: args) + try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command, args: args, branches: branches) } public func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { @@ -234,23 +237,21 @@ public class TemplateTestPromptingSystem { } - public func generateCommandPaths(rootCommand: CommandInfoV0, args: [String]) throws -> [CommandPath] { + public func generateCommandPaths(rootCommand: CommandInfoV0, args: [String], branches: [String]) throws -> [CommandPath] { var paths: [CommandPath] = [] var visitedArgs = Set() - try dfs(command: rootCommand, path: [], visitedArgs: &visitedArgs, paths: &paths, predefinedArgs: args) + try dfs(command: rootCommand, path: [], visitedArgs: &visitedArgs, paths: &paths, predefinedArgs: args, branches: branches, branchDepth: 0) return paths } - func dfs(command: CommandInfoV0, path: [CommandComponent], visitedArgs: inout Set, paths: inout [CommandPath], predefinedArgs: [String]) throws{ + func dfs(command: CommandInfoV0, path: [CommandComponent], visitedArgs: inout Set, paths: inout [CommandPath], predefinedArgs: [String], branches: [String], branchDepth: Int = 0) throws{ let allArgs = try convertArguments(from: command) - var currentPredefinedArgs = predefinedArgs - let (answeredArgs, leftoverArgs) = try - parseAndMatchArguments(currentPredefinedArgs, definedArgs: allArgs) + parseAndMatchArguments(predefinedArgs, definedArgs: allArgs) visitedArgs.formUnion(answeredArgs) @@ -286,7 +287,18 @@ public class TemplateTestPromptingSystem { if let subcommands = getSubCommand(from: command) { for sub in subcommands { - try dfs(command: sub, path: newPath, visitedArgs: &visitedArgs, paths: &paths, predefinedArgs: leftoverArgs) + let shouldTraverse: Bool + if branches.isEmpty { + shouldTraverse = true + } else if branchDepth < (branches.count - 1) { + shouldTraverse = sub.commandName == branches[branchDepth + 1] + } else { + shouldTraverse = true + } + + if shouldTraverse { + try dfs(command: sub, path: newPath, visitedArgs: &visitedArgs, paths: &paths, predefinedArgs: leftoverArgs, branches: branches, branchDepth: branchDepth + 1) + } } } else { let fullPathKey = joinCommandNames(newPath) From 09fe473dee25fc4caed0f942782b7a7e78a4bc55 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 3 Sep 2025 12:41:36 -0400 Subject: [PATCH 093/225] changes to usability to resolve to latest registry version --- Sources/Commands/PackageCommands/Init.swift | 2 + .../PackageCommands/ShowTemplates.swift | 19 +++++- Sources/Commands/SwiftBuildCommand.swift | 3 +- .../PackageInitializer.swift | 19 ++++-- .../RequirementResolver.swift | 59 ++++++++++++++++++- .../TemplatePathResolver.swift | 2 +- 6 files changed, 93 insertions(+), 11 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index f02569f003f..1b082e5a900 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -224,6 +224,8 @@ struct PackageInitConfiguration { if templateSource != nil { self.versionResolver = DependencyRequirementResolver( + packageIdentity: packageID, + swiftCommandState: swiftCommandState, exact: versionFlags.exact, revision: versionFlags.revision, branch: versionFlags.branch, diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index e51ed7530e9..f8d1de880eb 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -100,6 +100,8 @@ struct ShowTemplates: AsyncSwiftCommand { private func resolveTemplatePath(using swiftCommandState: SwiftCommandState, source: InitTemplatePackage.TemplateSource) async throws -> Basics.AbsolutePath { let requirementResolver = DependencyRequirementResolver( + packageIdentity: templatePackageID, + swiftCommandState: swiftCommandState, exact: exact, revision: revision, branch: branch, @@ -108,8 +110,21 @@ struct ShowTemplates: AsyncSwiftCommand { to: to ) - let registryRequirement = try? requirementResolver.resolveRegistry() - let sourceControlRequirement = try? requirementResolver.resolveSourceControl() + var sourceControlRequirement: PackageDependency.SourceControl.Requirement? + var registryRequirement: PackageDependency.Registry.Requirement? + + + switch source { + case .local: + sourceControlRequirement = nil + registryRequirement = nil + case .git: + sourceControlRequirement = try? requirementResolver.resolveSourceControl() + registryRequirement = nil + case .registry: + sourceControlRequirement = nil + registryRequirement = try? await requirementResolver.resolveRegistry() + } return try await TemplatePathResolver( source: source, diff --git a/Sources/Commands/SwiftBuildCommand.swift b/Sources/Commands/SwiftBuildCommand.swift index 8bc8e1aaccb..238871d3e3e 100644 --- a/Sources/Commands/SwiftBuildCommand.swift +++ b/Sources/Commands/SwiftBuildCommand.swift @@ -103,13 +103,14 @@ struct BuildCommandOptions: ParsableArguments { @Option(help: "Build the specified product.") var product: String? + /* /// Testing library options. /// /// These options are no longer used but are needed by older versions of the /// Swift VSCode plugin. They will be removed in a future update. @OptionGroup(visibility: .private) var testLibraryOptions: TestLibraryOptions - +*/ /// Specifies the traits to build. @OptionGroup(visibility: .hidden) package var traits: TraitOptions diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index c1cb78c4a67..500b4ac3246 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -36,10 +36,22 @@ struct TemplatePackageInitializer: PackageInitializer { func run() async throws { try precheck() - // Resolve version requirements - let sourceControlRequirement = try? versionResolver.resolveSourceControl() - let registryRequirement = try? versionResolver.resolveRegistry() + var sourceControlRequirement: PackageDependency.SourceControl.Requirement? + var registryRequirement: PackageDependency.Registry.Requirement? + + switch templateSource { + case .local: + sourceControlRequirement = nil + registryRequirement = nil + case .git: + sourceControlRequirement = try? versionResolver.resolveSourceControl() + registryRequirement = nil + case .registry: + sourceControlRequirement = nil + registryRequirement = try? await versionResolver.resolveRegistry() + } + // Resolve version requirements let resolvedTemplatePath = try await TemplatePathResolver( source: templateSource, templateDirectory: templateDirectory, @@ -65,7 +77,6 @@ struct TemplatePackageInitializer: PackageInitializer { resolvedTemplatePath: resolvedTemplatePath ) - let templatePackage = try setUpPackage(builder: builder, packageType: packageType, stagingPath: stagingPath) swiftCommandState.observabilityScope.emit(debug: "Set up initial package: \(templatePackage.packageName)") diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index 43e941dda8d..f6f478878bb 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -11,14 +11,19 @@ //===----------------------------------------------------------------------===// import PackageModel +import PackageRegistry import TSCBasic import TSCUtility +import CoreCommands +import Workspace +import PackageFingerprint +import PackageSigning /// A protocol defining interfaces for resolving package dependency requirements /// based on versioning input (e.g., version, branch, or revision). protocol DependencyRequirementResolving { func resolveSourceControl() throws -> PackageDependency.SourceControl.Requirement - func resolveRegistry() throws -> PackageDependency.Registry.Requirement? + func resolveRegistry() async throws -> PackageDependency.Registry.Requirement? } @@ -34,6 +39,10 @@ protocol DependencyRequirementResolving { /// `from`. struct DependencyRequirementResolver: DependencyRequirementResolving { + /// Package-id for registry + let packageIdentity: String? + /// SwiftCommandstate + let swiftCommandState: SwiftCommandState /// An exact version to use. let exact: Version? @@ -95,9 +104,29 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// - Returns: A valid `PackageDependency.Registry.Requirement`. /// - Throws: `StringError` if more than one registry versioning input is provided or if `to` is used without a base /// range. - func resolveRegistry() throws -> PackageDependency.Registry.Requirement? { + func resolveRegistry() async throws -> PackageDependency.Registry.Requirement? { if exact == nil, from == nil, upToNextMinorFrom == nil, to == nil { - return nil + let config = try RegistryTemplateFetcher.getRegistriesConfig(self.swiftCommandState, global: true) + let auth = try swiftCommandState.getRegistryAuthorizationProvider() + + guard let stringIdentity = self.packageIdentity else { + throw DependencyRequirementError.noRequirementSpecified + } + let identity = PackageIdentity.plain(stringIdentity) + let registryClient = RegistryClient( + configuration: config.configuration, + fingerprintStorage: .none, + fingerprintCheckingMode: .strict, + skipSignatureValidation: false, + signingEntityStorage: .none, + signingEntityCheckingMode: .strict, + authorizationProvider: auth, + delegate: .none, + checksumAlgorithm: SHA256() + ) + + let resolvedVersion = try await resolveVersion(for: identity, using: registryClient) + return (.exact(resolvedVersion)) } var specifiedRequirements: [PackageDependency.Registry.Requirement] = [] @@ -128,6 +157,23 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { return specifiedRequirements } + + /// Resolves the version to use for registry packages, fetching latest if none specified + /// + /// - Parameters: + /// - packageIdentity: The package identity to resolve version for + /// - registryClient: The registry client to use for fetching metadata + /// - Returns: The resolved version to use + /// - Throws: Error if version resolution fails + func resolveVersion(for packageIdentity: PackageIdentity, using registryClient: RegistryClient) async throws -> Version { + let metadata = try await registryClient.getPackageMetadata(package: packageIdentity, observabilityScope: swiftCommandState.observabilityScope) + + guard let maxVersion = metadata.versions.max() else { + throw DependencyRequirementError.failedToFetchLatestVersion(metadata: metadata, packageIdentity: packageIdentity) + } + + return maxVersion + } } /// Enum representing the type of dependency to resolve. @@ -142,6 +188,7 @@ enum DependencyRequirementError: Error, CustomStringConvertible { case multipleRequirementsSpecified case noRequirementSpecified case invalidToParameterWithoutFrom + case failedToFetchLatestVersion(metadata: RegistryClient.PackageMetadata, packageIdentity: PackageIdentity) var description: String { switch self { @@ -151,6 +198,12 @@ enum DependencyRequirementError: Error, CustomStringConvertible { return "No exact or lower bound version requirement specified." case .invalidToParameterWithoutFrom: return "--to requires --from or --up-to-next-minor-from" + case .failedToFetchLatestVersion(let metadata, let packageIdentity): + return """ + Failed to fetch latest version of \(packageIdentity) + Here is the metadata of the package you were trying to query: + \(metadata) + """ } } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index a5e57a35699..795330b176b 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -372,7 +372,7 @@ struct RegistryTemplateFetcher: TemplateFetcher { /// /// - Returns: Registry configuration to use for fetching packages. /// - Throws: If configurations are missing or unreadable. - private static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace + public static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace .Configuration.Registries { let sharedFile = Workspace.DefaultLocations .registriesConfigurationFile(at: swiftCommandState.sharedConfigurationDirectory) From cbbd104e2d32fbab51217d69df1da344ce20368c Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 3 Sep 2025 15:27:42 -0400 Subject: [PATCH 094/225] enforcing templates to have a corresponding product and plugin --- .../PackageLoading/ManifestLoader+Validation.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/PackageLoading/ManifestLoader+Validation.swift b/Sources/PackageLoading/ManifestLoader+Validation.swift index cecc121d5bf..4f5cb3fa0aa 100644 --- a/Sources/PackageLoading/ManifestLoader+Validation.swift +++ b/Sources/PackageLoading/ManifestLoader+Validation.swift @@ -60,6 +60,16 @@ public struct ManifestValidator { diagnostics.append(.duplicateTargetName(targetName: name)) } + let targetsInProducts = Set(self.manifest.products.flatMap { $0.targets }) + + let templateTargetsWithoutProducts = self.manifest.targets.filter { target in + target.templateInitializationOptions != nil && !targetsInProducts.contains(target.name) + } + + for target in templateTargetsWithoutProducts { + diagnostics.append(.templateTargetWithoutProduct(targetName: target.name)) + } + return diagnostics } @@ -288,6 +298,10 @@ extension Basics.Diagnostic { .error("product '\(productName)' doesn't reference any targets") } + static func templateTargetWithoutProduct(targetName: String) -> Self { + .error("template target named '\(targetName) must be referenced by a product'") + } + static func productTargetNotFound(productName: String, targetName: String, validTargets: [String]) -> Self { .error("target '\(targetName)' referenced in product '\(productName)' could not be found; valid targets are: '\(validTargets.joined(separator: "', '"))'") } From 2725a17e649fa42e97f8be7dd2086e54167cb8bd Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 3 Sep 2025 16:50:09 -0400 Subject: [PATCH 095/225] error handling more gracefully, with reporting from observability scope --- .../PackageDependencyBuilder.swift | 20 +-- ...ackageInitializationDirectoryManager.swift | 4 - .../PackageInitializer.swift | 137 ++++++++++-------- .../_InternalInitSupport/TemplateBuild.swift | 2 - .../TemplatePathResolver.swift | 10 -- .../TemplatePluginManager.swift | 20 ++- 6 files changed, 101 insertions(+), 92 deletions(-) diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift index b3b18d4a782..ab7987c31a3 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift @@ -77,19 +77,19 @@ struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { case .git: guard let url = templateURL else { - throw StringError("Missing Git url") + throw PackageDependencyBuilderError.missingGitURLOrPath } guard let requirement = sourceControlRequirement else { - throw StringError("Missing Git requirement") + throw PackageDependencyBuilderError.missingGitRequirement } return .sourceControl(name: self.packageName, location: url, requirement: requirement) case .registry: guard let id = templatePackageID else { - throw StringError("Missing Package ID") + throw PackageDependencyBuilderError.missingRegistryIdentity } guard let requirement = registryRequirement else { - throw StringError("Missing Registry requirement") + throw PackageDependencyBuilderError.missingRegistryRequirement } return .registry(id: id, requirement: requirement) } @@ -98,21 +98,21 @@ struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { /// Errors thrown by `TemplatePathResolver` during initialization. enum PackageDependencyBuilderError: LocalizedError, Equatable { - case missingGitURL + case missingGitURLOrPath case missingGitRequirement case missingRegistryIdentity case missingRegistryRequirement var errorDescription: String? { switch self { - case .missingGitURL: - return "Missing Git URL for git template." + case .missingGitURLOrPath: + return "Missing Git URL or path for template from git." case .missingGitRequirement: - return "Missing version requirement for template in git." + return "Missing version requirement for template from git." case .missingRegistryIdentity: - return "Missing registry package identity for template in registry." + return "Missing registry package identity for template from registry." case .missingRegistryRequirement: - return "Missing version requirement for template in registry ." + return "Missing version requirement for template from registry ." } } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift index 213c5372d7c..f70af672e9c 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift @@ -60,10 +60,6 @@ public struct TemplateInitializationDirectoryManager { } } catch { - observabilityScope.emit( - error: DirectoryManagerError.cleanupFailed(path: temporaryDirectory, underlying: error), - underlyingError: error - ) throw DirectoryManagerError.cleanupFailed(path: temporaryDirectory, underlying: error) } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index 500b4ac3246..1026a5c7d13 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -34,80 +34,92 @@ struct TemplatePackageInitializer: PackageInitializer { let swiftCommandState: SwiftCommandState func run() async throws { - try precheck() - - var sourceControlRequirement: PackageDependency.SourceControl.Requirement? - var registryRequirement: PackageDependency.Registry.Requirement? - - switch templateSource { - case .local: - sourceControlRequirement = nil - registryRequirement = nil - case .git: - sourceControlRequirement = try? versionResolver.resolveSourceControl() - registryRequirement = nil - case .registry: - sourceControlRequirement = nil - registryRequirement = try? await versionResolver.resolveRegistry() - } - - // Resolve version requirements - let resolvedTemplatePath = try await TemplatePathResolver( - source: templateSource, - templateDirectory: templateDirectory, - templateURL: templateURL, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - packageIdentity: templatePackageID, - swiftCommandState: swiftCommandState - ).resolve() - - let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) - let (stagingPath, cleanupPath, tempDir) = try directoryManager.createTemporaryDirectories() - - let packageType = try await TemplatePackageInitializer.inferPackageType(from: resolvedTemplatePath, templateName: templateName, swiftCommandState: swiftCommandState) - - let builder = DefaultPackageDependencyBuilder( - templateSource: templateSource, - packageName: packageName, - templateURL: templateURL, - templatePackageID: templatePackageID, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - resolvedTemplatePath: resolvedTemplatePath - ) + do { + try precheck() + var sourceControlRequirement: PackageDependency.SourceControl.Requirement? + var registryRequirement: PackageDependency.Registry.Requirement? + + swiftCommandState.observabilityScope.emit(debug: "Fetching versioning requirements and resolving path of template on local disk.") + + switch templateSource { + case .local: + sourceControlRequirement = nil + registryRequirement = nil + case .git: + sourceControlRequirement = try? versionResolver.resolveSourceControl() + registryRequirement = nil + case .registry: + sourceControlRequirement = nil + registryRequirement = try? await versionResolver.resolveRegistry() + } - let templatePackage = try setUpPackage(builder: builder, packageType: packageType, stagingPath: stagingPath) + // Resolve version requirements + let resolvedTemplatePath = try await TemplatePathResolver( + source: templateSource, + templateDirectory: templateDirectory, + templateURL: templateURL, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() + + let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) + let (stagingPath, cleanupPath, tempDir) = try directoryManager.createTemporaryDirectories() + + swiftCommandState.observabilityScope.emit(debug: "Inferring initial type of consumer's package based on template's specifications.") + + let packageType = try await TemplatePackageInitializer.inferPackageType(from: resolvedTemplatePath, templateName: templateName, swiftCommandState: swiftCommandState) + let builder = DefaultPackageDependencyBuilder( + templateSource: templateSource, + packageName: packageName, + templateURL: templateURL, + templatePackageID: templatePackageID, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ) - swiftCommandState.observabilityScope.emit(debug: "Set up initial package: \(templatePackage.packageName)") - try await TemplateBuildSupport.build( - swiftCommandState: swiftCommandState, - buildOptions: buildOptions, - globalOptions: globalOptions, - cwd: stagingPath, - transitiveFolder: stagingPath - ) + let templatePackage = try setUpPackage(builder: builder, packageType: packageType, stagingPath: stagingPath) - try await TemplateInitializationPluginManager( - swiftCommandState: swiftCommandState, - template: templateName, - scratchDirectory: stagingPath, - args: args - ).run() + swiftCommandState.observabilityScope.emit(debug: "Finished setting up initial package: \(templatePackage.packageName).") - try await directoryManager.finalize(cwd: cwd, stagingPath: stagingPath, cleanupPath: cleanupPath, swiftCommandState: swiftCommandState) + swiftCommandState.observabilityScope.emit(debug: "Building package with dependency on template.") - if validatePackage { try await TemplateBuildSupport.build( swiftCommandState: swiftCommandState, buildOptions: buildOptions, globalOptions: globalOptions, - cwd: cwd + cwd: stagingPath, + transitiveFolder: stagingPath ) - } - try directoryManager.cleanupTemporary(templateSource: templateSource, path: resolvedTemplatePath, temporaryDirectory: tempDir) + swiftCommandState.observabilityScope.emit(debug: "Running plugin steps, including prompting and running the template package's plugin.") + + try await TemplateInitializationPluginManager( + swiftCommandState: swiftCommandState, + template: templateName, + scratchDirectory: stagingPath, + args: args + ).run() + + try await directoryManager.finalize(cwd: cwd, stagingPath: stagingPath, cleanupPath: cleanupPath, swiftCommandState: swiftCommandState) + + if validatePackage { + try await TemplateBuildSupport.build( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + globalOptions: globalOptions, + cwd: cwd + ) + } + + try directoryManager.cleanupTemporary(templateSource: templateSource, path: resolvedTemplatePath, temporaryDirectory: tempDir) + + } catch { + swiftCommandState.observabilityScope.emit(error) + } } //Will have to add checking for git + registry too @@ -270,6 +282,5 @@ struct StandardPackageInitializer: PackageInitializer { } } } - } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift index e0985730164..8bc5b2f13e8 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -60,7 +60,6 @@ enum TemplateBuildSupport { do { try await buildSystem.build(subset: subset) } catch let diagnostics as Diagnostics { - swiftCommandState.observabilityScope.emit(diagnostics) throw ExitCode.failure } } @@ -94,7 +93,6 @@ enum TemplateBuildSupport { do { try await buildSystem.build(subset: subset) } catch let diagnostics as Diagnostics { - swiftCommandState.observabilityScope.emit(diagnostics) throw ExitCode.failure } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index 795330b176b..ab246b8ab5e 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -74,21 +74,18 @@ struct TemplatePathResolver { switch source { case .local: guard let path = templateDirectory else { - swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingLocalTemplatePath) throw TemplatePathResolverError.missingLocalTemplatePath } self.fetcher = LocalTemplateFetcher(path: path) case .git: guard let url = templateURL, let requirement = sourceControlRequirement else { - swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingGitURLOrRequirement) throw TemplatePathResolverError.missingGitURLOrRequirement } self.fetcher = GitTemplateFetcher(source: url, requirement: requirement, swiftCommandState: swiftCommandState) case .registry: guard let identity = packageIdentity, let requirement = registryRequirement else { - swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingRegistryIdentityOrRequirement) throw TemplatePathResolverError.missingRegistryIdentityOrRequirement } self.fetcher = RegistryTemplateFetcher( @@ -98,7 +95,6 @@ struct TemplatePathResolver { ) case .none: - swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingTemplateType) throw TemplatePathResolverError.missingTemplateType } } @@ -203,10 +199,8 @@ struct GitTemplateFetcher: TemplateFetcher { try provider.fetch(repository: repositorySpecifier, to: path) } catch { if isSSHPermissionError(error) { - swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.sshAuthenticationRequired(source: source)) throw GitTemplateFetcherError.sshAuthenticationRequired(source: source) } - swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.cloneFailed(source: source, underlyingError: error)) throw GitTemplateFetcherError.cloneFailed(source: source, underlyingError: error) } } @@ -222,7 +216,6 @@ struct GitTemplateFetcher: TemplateFetcher { private func validateBareRepository(at path: Basics.AbsolutePath) throws { let provider = GitRepositoryProvider() guard try provider.isValidDirectory(path) else { - swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.invalidRepositoryDirectory(path: path)) throw GitTemplateFetcherError.invalidRepositoryDirectory(path: path) } } @@ -242,7 +235,6 @@ struct GitTemplateFetcher: TemplateFetcher { editable: true ) } catch { - swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.createWorkingCopyFailed(path: workingCopyPath, underlyingError: error)) throw GitTemplateFetcherError.createWorkingCopyFailed(path: workingCopyPath, underlyingError: error) } } @@ -267,7 +259,6 @@ struct GitTemplateFetcher: TemplateFetcher { let versions = tags.compactMap { Version($0) } let filteredVersions = versions.filter { range.contains($0) } guard let latestVersion = filteredVersions.max() else { - swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.noMatchingTagInRange(range)) throw GitTemplateFetcherError.noMatchingTagInRange(range) } try repository.checkout(tag: latestVersion.description) @@ -383,7 +374,6 @@ struct RegistryTemplateFetcher: TemplateFetcher { sharedRegistriesFile: sharedFile ) } catch { - swiftCommandState.observabilityScope.emit(RegistryConfigError.failedToLoadConfiguration(file: sharedFile, underlyingError: error)) throw RegistryConfigError.failedToLoadConfiguration(file: sharedFile, underlyingError: error) } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 00048f1e332..04fdb130fb5 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -27,10 +27,12 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { var rootPackage: ResolvedPackage { - guard let root = packageGraph.rootPackages.first else { - fatalError("No root package found in the package graph.") + get throws { + guard let root = packageGraph.rootPackages.first else { + throw TemplateInitializationError.missingPackageGraph + } + return root } - return root } init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String]) async throws { @@ -114,4 +116,16 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { func loadTemplatePlugin() throws -> ResolvedModule { try coordinator.loadTemplatePlugin(from: packageGraph) } + + enum TemplateInitializationError: Error, CustomStringConvertible { + case missingPackageGraph + + var description: String { + switch self { + case .missingPackageGraph: + return "No root package was found in package graph." + } + } + } + } From ca7ca51eab12552cf4d05263421c5c0deb622b67 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 4 Sep 2025 11:16:32 -0400 Subject: [PATCH 096/225] fixed permissions to query for them only once during process + refactored coordinator + plugin managers --- .../TestCommands/TestTemplateCommand.swift | 8 ++- .../TemplatePluginCoordinator.swift | 5 +- .../TemplatePluginManager.swift | 60 +++++++++++++------ .../TemplatePluginRunner.swift | 21 ++++--- .../TemplateTesterManager.swift | 32 ++++------ 5 files changed, 72 insertions(+), 54 deletions(-) diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 2597c56aa43..f8ea6124524 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -303,12 +303,14 @@ extension SwiftTestCommand { print("Running plugin with args:", fullCommand) try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in - let output = try await TemplatePluginRunner.run( + + let output = try await TemplatePluginExecutor.execute( plugin: commandPlugin, - package: graph.rootPackages.first!, + rootPackage: graph.rootPackages.first!, packageGraph: graph, arguments: fullCommand, - swiftCommandState: swiftCommandState + swiftCommandState: swiftCommandState, + requestPermission: false ) pluginOutput = String(data: output, encoding: .utf8) ?? "[Invalid UTF-8 output]" print(pluginOutput) diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift index aa1e009a58c..18180dda02c 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift @@ -23,7 +23,7 @@ struct TemplatePluginCoordinator { let args: [String] let branches: [String] - let EXPERIMENTAL_DUMP_HELP = ["--", "--experimental-dump-help"] + private let EXPERIMENTAL_DUMP_HELP = ["--", "--experimental-dump-help"] func loadPackageGraph() async throws -> ModulesGraph { try await swiftCommandState.withTemporaryWorkspace(switchingTo: scratchDirectory) { _, _ in @@ -64,7 +64,8 @@ struct TemplatePluginCoordinator { package: rootPackage, packageGraph: packageGraph, arguments: EXPERIMENTAL_DUMP_HELP, - swiftCommandState: swiftCommandState + swiftCommandState: swiftCommandState, + requestPermission: true ) do { diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 04fdb130fb5..9551c49fe82 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -10,7 +10,27 @@ import PackageGraph public protocol TemplatePluginManager { func loadTemplatePlugin() throws -> ResolvedModule - func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data +} + +/// Utility for executing template plugins with common patterns. +enum TemplatePluginExecutor { + static func execute( + plugin: ResolvedModule, + rootPackage: ResolvedPackage, + packageGraph: ModulesGraph, + arguments: [String], + swiftCommandState: SwiftCommandState, + requestPermission: Bool = false + ) async throws -> Data { + return try await TemplatePluginRunner.run( + plugin: plugin, + package: rootPackage, + packageGraph: packageGraph, + arguments: arguments, + swiftCommandState: swiftCommandState, + requestPermission: requestPermission + ) + } } /// A utility for obtaining and running a template's plugin . @@ -18,15 +38,14 @@ public protocol TemplatePluginManager { /// `TemplateIntiializationPluginManager` encapsulates the logic needed to fetch, /// and run templates' plugins given arguments, based on the template initialization workflow. struct TemplateInitializationPluginManager: TemplatePluginManager { - let swiftCommandState: SwiftCommandState - let template: String? - let scratchDirectory: Basics.AbsolutePath - let args: [String] - let packageGraph: ModulesGraph - let coordinator: TemplatePluginCoordinator - - - var rootPackage: ResolvedPackage { + private let swiftCommandState: SwiftCommandState + private let template: String? + private let scratchDirectory: Basics.AbsolutePath + private let args: [String] + private let packageGraph: ModulesGraph + private let coordinator: TemplatePluginCoordinator + + private var rootPackage: ResolvedPackage { get throws { guard let root = packageGraph.rootPackages.first else { throw TemplateInitializationError.missingPackageGraph @@ -60,13 +79,13 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// - `TemplatePluginError.execu` func run() async throws { - let plugin = try coordinator.loadTemplatePlugin(from: packageGraph) + let plugin = try loadTemplatePlugin() let toolInfo = try await coordinator.dumpToolInfo(using: plugin, from: packageGraph, rootPackage: rootPackage) let cliResponses: [[String]] = try promptUserForTemplateArguments(using: toolInfo) for response in cliResponses { - _ = try await executeTemplatePlugin(plugin, with: response) + _ = try await runTemplatePlugin(plugin, with: response) } } @@ -78,8 +97,10 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// - Throws: /// - Any other errors thrown during the prompting of the user. /// - /// - Returns: A 2D array of the arguments given by the user, that will be consumed by the template during the project generation phase. - func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [[String]] { + /// - Parameter toolInfo: The JSON representation of the template's decision tree + /// - Returns: A 2D array of arguments provided by the user for template generation + /// - Throws: Any errors during user prompting + private func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [[String]] { return try TemplatePromptingSystem().promptUser(command: toolInfo.command, arguments: args) } @@ -95,13 +116,14 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// /// - Returns: A data representation of the result of the execution of the template's plugin. - func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { - return try await TemplatePluginRunner.run( + private func runTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { + return try await TemplatePluginExecutor.execute( plugin: plugin, - package: rootPackage, + rootPackage: rootPackage, packageGraph: packageGraph, arguments: arguments, - swiftCommandState: swiftCommandState + swiftCommandState: swiftCommandState, + requestPermission: false ) } @@ -114,7 +136,7 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// - Returns: A data representation of the result of the execution of the template's plugin. func loadTemplatePlugin() throws -> ResolvedModule { - try coordinator.loadTemplatePlugin(from: packageGraph) + return try coordinator.loadTemplatePlugin(from: packageGraph) } enum TemplateInitializationError: Error, CustomStringConvertible { diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift index 6bd4329d261..d7910cb0f6f 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift @@ -65,7 +65,8 @@ enum TemplatePluginRunner { packageGraph: ModulesGraph, arguments: [String], swiftCommandState: SwiftCommandState, - allowNetworkConnections: [SandboxNetworkPermission] = [] + allowNetworkConnections: [SandboxNetworkPermission] = [], + requestPermission: Bool ) async throws -> Data { let pluginTarget = try castToPlugin(plugin) let pluginsDir = try pluginDirectory(for: plugin.name, in: swiftCommandState) @@ -75,14 +76,16 @@ enum TemplatePluginRunner { var writableDirs = [outputDir, package.path] var allowedNetworkConnections = allowNetworkConnections - try requestPluginPermissions( - from: pluginTarget, - pluginName: plugin.name, - packagePath: package.path, - writableDirectories: &writableDirs, - allowNetworkConnections: &allowedNetworkConnections, - state: swiftCommandState - ) + if requestPermission { + try requestPluginPermissions( + from: pluginTarget, + pluginName: plugin.name, + packagePath: package.path, + writableDirectories: &writableDirs, + allowNetworkConnections: &allowedNetworkConnections, + state: swiftCommandState + ) + } let readOnlyDirs = writableDirs .contains(where: { package.path.isDescendantOfOrEqual(to: $0) }) ? [] : [package.path] diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index d541685cb9c..c99d2314436 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -13,15 +13,15 @@ import PackageGraph /// `TemplateTesterPluginManager` encapsulates the logic needed to fetch, /// and run templates' plugins given arguments, based on the template initialization workflow. public struct TemplateTesterPluginManager: TemplatePluginManager { - public let swiftCommandState: SwiftCommandState - public let template: String? - public let scratchDirectory: Basics.AbsolutePath - public let args: [String] - public let packageGraph: ModulesGraph - public let branches: [String] - let coordinator: TemplatePluginCoordinator - - public var rootPackage: ResolvedPackage { + private let swiftCommandState: SwiftCommandState + private let template: String? + private let scratchDirectory: Basics.AbsolutePath + private let args: [String] + private let packageGraph: ModulesGraph + private let branches: [String] + private let coordinator: TemplatePluginCoordinator + + private var rootPackage: ResolvedPackage { guard let root = packageGraph.rootPackages.first else { fatalError("No root package found.") } @@ -53,22 +53,12 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { return try promptUserForTemplateArguments(using: toolInfo) } - func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [CommandPath] { + private func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [CommandPath] { try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command, args: args, branches: branches) } - public func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { - try await TemplatePluginRunner.run( - plugin: plugin, - package: rootPackage, - packageGraph: packageGraph, - arguments: arguments, - swiftCommandState: swiftCommandState - ) - } - public func loadTemplatePlugin() throws -> ResolvedModule { - try coordinator.loadTemplatePlugin(from: packageGraph) + return try coordinator.loadTemplatePlugin(from: packageGraph) } } From 092d960fc4535906c6fbfa7f680afca1de40a837 Mon Sep 17 00:00:00 2001 From: Bri Peticca Date: Tue, 26 Aug 2025 15:39:32 -0400 Subject: [PATCH 097/225] Fix for .when(traits:) condition not working for multiple traits (#9015) The culprit here is the fact that we were essentially doing an AND boolean check across all the traits in the target dependency condition, when we should be doing an OR boolean check. If at least one trait is enabled in the list of traits in the target dependency condition, then the target dependecy should be considered enabled as well. --- Fixtures/Traits/Example/Package.swift | 6 + .../Example/Sources/Example/Example.swift | 8 ++ Fixtures/Traits/Package10/Package.swift | 7 ++ .../Sources/Package10Library2/Library.swift | 3 + .../Manifest/Manifest+Traits.swift | 17 +-- .../SwiftTesting+TraitArgumentData.swift | 27 +++++ Tests/FunctionalTests/TraitTests.swift | 81 ++++++++++++- Tests/PackageModelTests/ManifestTests.swift | 56 ++++++++- Tests/WorkspaceTests/WorkspaceTests.swift | 107 ++++++++++++++++++ 9 files changed, 300 insertions(+), 12 deletions(-) create mode 100644 Fixtures/Traits/Package10/Sources/Package10Library2/Library.swift create mode 100644 Sources/_InternalTestSupport/SwiftTesting+TraitArgumentData.swift diff --git a/Fixtures/Traits/Example/Package.swift b/Fixtures/Traits/Example/Package.swift index 6eaf1479afa..b5358cb9d3b 100644 --- a/Fixtures/Traits/Example/Package.swift +++ b/Fixtures/Traits/Example/Package.swift @@ -25,6 +25,7 @@ let package = Package( "BuildCondition1", "BuildCondition2", "BuildCondition3", + "ExtraTrait", ], dependencies: [ .package( @@ -101,6 +102,11 @@ let package = Package( package: "Package10", condition: .when(traits: ["Package10"]) ), + .product( + name: "Package10Library2", + package: "Package10", + condition: .when(traits: ["Package10", "ExtraTrait"]) + ) ], swiftSettings: [ .define("DEFINE1", .when(traits: ["BuildCondition1"])), diff --git a/Fixtures/Traits/Example/Sources/Example/Example.swift b/Fixtures/Traits/Example/Sources/Example/Example.swift index 5f1d871df50..d1edafb6bb2 100644 --- a/Fixtures/Traits/Example/Sources/Example/Example.swift +++ b/Fixtures/Traits/Example/Sources/Example/Example.swift @@ -21,6 +21,10 @@ import Package9Library1 #endif #if Package10 import Package10Library1 +import Package10Library2 +#endif +#if ExtraTrait +import Package10Library2 #endif @main @@ -49,6 +53,10 @@ struct Example { #endif #if Package10 Package10Library1.hello() + Package10Library2.hello() + #endif + #if ExtraTrait + Package10Library2.hello() #endif #if DEFINE1 print("DEFINE1 enabled") diff --git a/Fixtures/Traits/Package10/Package.swift b/Fixtures/Traits/Package10/Package.swift index 4236e806cff..60767db5e7a 100644 --- a/Fixtures/Traits/Package10/Package.swift +++ b/Fixtures/Traits/Package10/Package.swift @@ -9,6 +9,10 @@ let package = Package( name: "Package10Library1", targets: ["Package10Library1"] ), + .library( + name: "Package10Library2", + targets: ["Package10Library2"] + ), ], traits: [ "Package10Trait1", @@ -18,6 +22,9 @@ let package = Package( .target( name: "Package10Library1" ), + .target( + name: "Package10Library2" + ), .plugin( name: "SymbolGraphExtract", capability: .command( diff --git a/Fixtures/Traits/Package10/Sources/Package10Library2/Library.swift b/Fixtures/Traits/Package10/Sources/Package10Library2/Library.swift new file mode 100644 index 00000000000..8d1dd343c75 --- /dev/null +++ b/Fixtures/Traits/Package10/Sources/Package10Library2/Library.swift @@ -0,0 +1,3 @@ +public func hello() { + print("Package10Library2 has been included.") +} \ No newline at end of file diff --git a/Sources/PackageModel/Manifest/Manifest+Traits.swift b/Sources/PackageModel/Manifest/Manifest+Traits.swift index 9f9364ea3a9..e5258a7a188 100644 --- a/Sources/PackageModel/Manifest/Manifest+Traits.swift +++ b/Sources/PackageModel/Manifest/Manifest+Traits.swift @@ -95,7 +95,7 @@ extension Manifest { _ parentPackage: PackageIdentifier? = nil ) throws { guard supportsTraits else { - if explicitlyEnabledTraits != ["default"] /*!explicitlyEnabledTraits.contains("default")*/ { + if explicitlyEnabledTraits != ["default"] { throw TraitError.traitsNotSupported( parent: parentPackage, package: .init(self), @@ -116,7 +116,7 @@ extension Manifest { let areDefaultsEnabled = enabledTraits.contains("default") // Ensure that disabling default traits is disallowed for packages that don't define any traits. - if !(explicitlyEnabledTraits == nil || areDefaultsEnabled) && !self.supportsTraits { + if !areDefaultsEnabled && !self.supportsTraits { // We throw an error when default traits are disabled for a package without any traits // This allows packages to initially move new API behind traits once. throw TraitError.traitsNotSupported( @@ -449,15 +449,18 @@ extension Manifest { let traitsToEnable = self.traitGuardedTargetDependencies(for: target)[dependency] ?? [] - let isEnabled = try traitsToEnable.allSatisfy { try self.isTraitEnabled( + // Check if any of the traits guarding this dependency is enabled; + // if so, the condition is met and the target dependency is considered + // to be in an enabled state. + let isEnabled = try traitsToEnable.contains(where: { try self.isTraitEnabled( .init(stringLiteral: $0), enabledTraits, - ) } + ) }) return traitsToEnable.isEmpty || isEnabled } /// Determines whether a given package dependency is used by this manifest given a set of enabled traits. - public func isPackageDependencyUsed(_ dependency: PackageDependency, enabledTraits: Set/* = ["default"]*/) throws -> Bool { + public func isPackageDependencyUsed(_ dependency: PackageDependency, enabledTraits: Set) throws -> Bool { if self.pruneDependencies { let usedDependencies = try self.usedDependencies(withTraits: enabledTraits) let foundKnownPackage = usedDependencies.knownPackage.contains(where: { @@ -478,8 +481,8 @@ extension Manifest { // if target deps is empty, default to returning true here. let isTraitGuarded = targetDependenciesForPackageDependency.isEmpty ? false : targetDependenciesForPackageDependency.compactMap({ $0.condition?.traits }).allSatisfy({ - let condition = $0.subtracting(enabledTraits) - return !condition.isEmpty + let isGuarded = $0.intersection(enabledTraits).isEmpty + return isGuarded }) let isUsedWithoutTraitGuarding = !targetDependenciesForPackageDependency.filter({ $0.condition?.traits == nil }).isEmpty diff --git a/Sources/_InternalTestSupport/SwiftTesting+TraitArgumentData.swift b/Sources/_InternalTestSupport/SwiftTesting+TraitArgumentData.swift new file mode 100644 index 00000000000..92664e017a6 --- /dev/null +++ b/Sources/_InternalTestSupport/SwiftTesting+TraitArgumentData.swift @@ -0,0 +1,27 @@ +//===----------------------------------------------------------------------===// +// +// 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 struct SPMBuildCore.BuildSystemProvider +import enum PackageModel.BuildConfiguration + +/// A utility struct that represents a list of traits that would be passed through the command line. +/// This is used for testing purposes, and its use is currently specific to the `TraitTests.swift` +public struct TraitArgumentData { + public var traitsArgument: String + public var expectedOutput: String +} + +public func getTraitCombinations(_ traitsAndMessage: (traits: String, output: String)...) -> [TraitArgumentData] { + traitsAndMessage.map { traitListAndMessage in + TraitArgumentData(traitsArgument: traitListAndMessage.traits, expectedOutput: traitListAndMessage.output) + } +} diff --git a/Tests/FunctionalTests/TraitTests.swift b/Tests/FunctionalTests/TraitTests.swift index 9475ab9a5d8..39b3615c06b 100644 --- a/Tests/FunctionalTests/TraitTests.swift +++ b/Tests/FunctionalTests/TraitTests.swift @@ -111,6 +111,7 @@ struct TraitTests { Package10Library1 trait2 enabled Package10Library1 trait1 enabled Package10Library1 trait2 enabled + Package10Library2 has been included. DEFINE1 enabled DEFINE2 disabled DEFINE3 disabled @@ -362,6 +363,8 @@ struct TraitTests { Package10Library1 trait2 enabled Package10Library1 trait1 enabled Package10Library1 trait2 enabled + Package10Library2 has been included. + Package10Library2 has been included. DEFINE1 enabled DEFINE2 enabled DEFINE3 enabled @@ -421,6 +424,8 @@ struct TraitTests { Package10Library1 trait2 enabled Package10Library1 trait1 enabled Package10Library1 trait2 enabled + Package10Library2 has been included. + Package10Library2 has been included. DEFINE1 enabled DEFINE2 enabled DEFINE3 enabled @@ -454,7 +459,7 @@ struct TraitTests { let json = try JSON(bytes: ByteString(encodingAsUTF8: dumpOutput)) guard case .dictionary(let contents) = json else { Issue.record("unexpected result"); return } guard case .array(let traits)? = contents["traits"] else { Issue.record("unexpected result"); return } - #expect(traits.count == 12) + #expect(traits.count == 13) } } @@ -653,4 +658,78 @@ struct TraitTests { } } } + + @Test( + .IssueSwiftBuildLinuxRunnable, + .IssueProductTypeForObjectLibraries, + .tags( + Tag.Feature.Command.Run, + ), + arguments: + getBuildData(for: SupportedBuildSystemOnAllPlatforms), + getTraitCombinations( + ("ExtraTrait", + """ + Package10Library2 has been included. + DEFINE1 disabled + DEFINE2 disabled + DEFINE3 disabled + + """ + ), + ("Package10", + """ + Package10Library1 trait1 disabled + Package10Library1 trait2 enabled + Package10Library2 has been included. + DEFINE1 disabled + DEFINE2 disabled + DEFINE3 disabled + + """ + ), + ("ExtraTrait,Package10", + """ + Package10Library1 trait1 disabled + Package10Library1 trait2 enabled + Package10Library2 has been included. + Package10Library2 has been included. + DEFINE1 disabled + DEFINE2 disabled + DEFINE3 disabled + + """ + ) + ) + ) + func traits_whenManyTraitsEnableTargetDependency( + data: BuildData, + traits: TraitArgumentData + ) async throws { + try await withKnownIssue( + """ + Linux: https://github.com/swiftlang/swift-package-manager/issues/8416, + Windows: https://github.com/swiftlang/swift-build/issues/609 + """, + isIntermittent: (ProcessInfo.hostOperatingSystem == .windows), + ) { + try await fixture(name: "Traits") { fixturePath in + // We expect no warnings to be produced. Specifically no unused dependency warnings. + let unusedDependencyRegex = try Regex("warning: '.*': dependency '.*' is not used by any target") + + let (stdout, stderr) = try await executeSwiftRun( + fixturePath.appending("Example"), + "Example", + configuration: data.config, + extraArgs: ["--traits", traits.traitsArgument], + buildSystem: data.buildSystem, + ) + #expect(!stderr.contains(unusedDependencyRegex)) + #expect(stdout == traits.expectedOutput) + } + } when: { + (ProcessInfo.hostOperatingSystem == .windows && (CiEnvironment.runningInSmokeTestPipeline || data.buildSystem == .swiftbuild)) + || (data.buildSystem == .swiftbuild && ProcessInfo.hostOperatingSystem == .linux && CiEnvironment.runningInSelfHostedPipeline) + } + } } diff --git a/Tests/PackageModelTests/ManifestTests.swift b/Tests/PackageModelTests/ManifestTests.swift index d42b450bdb1..19b84a0c56d 100644 --- a/Tests/PackageModelTests/ManifestTests.swift +++ b/Tests/PackageModelTests/ManifestTests.swift @@ -683,6 +683,7 @@ class ManifestTests: XCTestCase { let dependencies: [PackageDependency] = [ .localSourceControl(path: "/Bar", requirement: .upToNextMajor(from: "1.0.0")), .localSourceControl(path: "/Baz", requirement: .upToNextMajor(from: "1.0.0")), + .localSourceControl(path: "/Cosmic", requirement: .upToNextMajor(from: "1.0.0")), ] let products = try [ @@ -711,6 +712,12 @@ class ManifestTests: XCTestCase { package: "Boom" ) + let manyTraitsEnableTargetDependency: TargetDescription.Dependency = .product( + name: "Supernova", + package: "Cosmic", + condition: .init(traits: ["Space", "Music"]) + ) + let target = try TargetDescription( name: "Foo", dependencies: [ @@ -718,6 +725,7 @@ class ManifestTests: XCTestCase { trait3GuardedTargetDependency, defaultTraitGuardedTargetDependency, enabledTargetDependencyWithSamePackage, + manyTraitsEnableTargetDependency ] ) @@ -726,6 +734,8 @@ class ManifestTests: XCTestCase { TraitDescription(name: "Trait1", enabledTraits: ["Trait2"]), TraitDescription(name: "Trait2"), TraitDescription(name: "Trait3"), + TraitDescription(name: "Space"), + TraitDescription(name: "Music"), ] do { @@ -792,6 +802,33 @@ class ManifestTests: XCTestCase { enabledTargetDependencyWithSamePackage, enabledTraits: [] )) + + // Test variations of traits that enable a target dependency that is unguarded by many traits. + XCTAssertFalse(try manifest.isTargetDependencyEnabled( + target: "Foo", + manyTraitsEnableTargetDependency, + enabledTraits: [] + )) + XCTAssertTrue(try manifest.isTargetDependencyEnabled( + target: "Foo", + manyTraitsEnableTargetDependency, + enabledTraits: ["Space"] + )) + XCTAssertTrue(try manifest.isTargetDependencyEnabled( + target: "Foo", + manyTraitsEnableTargetDependency, + enabledTraits: ["Music"] + )) + XCTAssertTrue(try manifest.isTargetDependencyEnabled( + target: "Foo", + manyTraitsEnableTargetDependency, + enabledTraits: ["Music", "Space"] + )) + XCTAssertTrue(try manifest.isTargetDependencyEnabled( + target: "Foo", + manyTraitsEnableTargetDependency, + enabledTraits: ["Trait3", "Music", "Space", "Trait1", "Trait2"] + )) } } @@ -799,11 +836,13 @@ class ManifestTests: XCTestCase { let bar: PackageDependency = .localSourceControl(path: "/Bar", requirement: .upToNextMajor(from: "1.0.0")) let baz: PackageDependency = .localSourceControl(path: "/Baz", requirement: .upToNextMajor(from: "1.0.0")) let bam: PackageDependency = .localSourceControl(path: "/Bam", requirement: .upToNextMajor(from: "1.0.0")) + let drinks: PackageDependency = .localSourceControl(path: "/Drinks", requirement: .upToNextMajor(from: "1.0.0")) let dependencies: [PackageDependency] = [ bar, baz, bam, + drinks, ] let products = try [ @@ -832,12 +871,19 @@ class ManifestTests: XCTestCase { package: "Bam" ) + let manyTraitsGuardingTargetDependency: TargetDescription.Dependency = .product( + name: "Coffee", + package: "Drinks", + condition: .init(traits: ["Sugar", "Cream"]) + ) + let target = try TargetDescription( name: "Foo", dependencies: [ unguardedTargetDependency, trait3GuardedTargetDependency, defaultTraitGuardedTargetDependency, + manyTraitsGuardingTargetDependency ] ) @@ -856,6 +902,8 @@ class ManifestTests: XCTestCase { TraitDescription(name: "Trait1", enabledTraits: ["Trait2"]), TraitDescription(name: "Trait2"), TraitDescription(name: "Trait3"), + TraitDescription(name: "Sugar"), + TraitDescription(name: "Cream"), ] do { @@ -879,19 +927,19 @@ class ManifestTests: XCTestCase { traits: traits ) -// XCTAssertTrue(try manifest.isPackageDependencyUsed(bar)) XCTAssertTrue(try manifest.isPackageDependencyUsed(bar, enabledTraits: [])) -// XCTAssertFalse(try manifest.isPackageDependencyUsed(baz)) XCTAssertTrue(try manifest.isPackageDependencyUsed(baz, enabledTraits: ["Trait3"])) -// XCTAssertTrue(try manifest.isPackageDependencyUsed(bam)) XCTAssertFalse(try manifest.isPackageDependencyUsed(bam, enabledTraits: [])) XCTAssertFalse(try manifest.isPackageDependencyUsed(bam, enabledTraits: ["Trait3"])) + XCTAssertFalse(try manifest.isPackageDependencyUsed(drinks, enabledTraits: [])) + XCTAssertTrue(try manifest.isPackageDependencyUsed(drinks, enabledTraits: ["Sugar"])) + XCTAssertTrue(try manifest.isPackageDependencyUsed(drinks, enabledTraits: ["Cream"])) + XCTAssertTrue(try manifest.isPackageDependencyUsed(drinks, enabledTraits: ["Sugar", "Cream"])) // Configuration of the manifest that includes a case where there exists a target // dependency that depends on the same package as another target dependency, but // is unguarded by traits; therefore, this package dependency should be considered used // in every scenario, regardless of the passed trait configuration. -// XCTAssertTrue(try manifestWithBamDependencyAlwaysUsed.isPackageDependencyUsed(bam, enabledTraits: nil)) XCTAssertTrue(try manifestWithBamDependencyAlwaysUsed.isPackageDependencyUsed(bam, enabledTraits: [])) XCTAssertTrue(try manifestWithBamDependencyAlwaysUsed.isPackageDependencyUsed(bam, enabledTraits: ["Trait3"])) } diff --git a/Tests/WorkspaceTests/WorkspaceTests.swift b/Tests/WorkspaceTests/WorkspaceTests.swift index 667d61aee2f..cdfd563f3ec 100644 --- a/Tests/WorkspaceTests/WorkspaceTests.swift +++ b/Tests/WorkspaceTests/WorkspaceTests.swift @@ -16257,6 +16257,113 @@ final class WorkspaceTests: XCTestCase { } } + func testManyTraitsEnableTargetDependency() async throws { + let sandbox = AbsolutePath("/tmp/ws/") + let fs = InMemoryFileSystem() + + func createMockWorkspace(_ traitConfiguration: TraitConfiguration) async throws -> MockWorkspace { + try await MockWorkspace( + sandbox: sandbox, + fileSystem: fs, + roots: [ + MockPackage( + name: "Cereal", + targets: [ + MockTarget( + name: "Wheat", + dependencies: [ + .product( + name: "Icing", + package: "Sugar", + condition: .init(traits: ["BreakfastOfChampions", "DontTellMom"]) + ), + ] + ), + ], + products: [ + MockProduct(name: "YummyBreakfast", modules: ["Wheat"]) + ], + dependencies: [ + .sourceControl(path: "./Sugar", requirement: .upToNextMajor(from: "1.0.0")), + ], + traits: ["BreakfastOfChampions", "DontTellMom"] + ), + ], + packages: [ + MockPackage( + name: "Sugar", + targets: [ + MockTarget(name: "Icing"), + ], + products: [ + MockProduct(name: "Icing", modules: ["Icing"]), + ], + versions: ["1.0.0", "1.5.0"] + ), + ], + traitConfiguration: traitConfiguration + ) + } + + + let deps: [MockDependency] = [ + .sourceControl(path: "./Sugar", requirement: .exact("1.0.0"), products: .specific(["Icing"])), + ] + + let workspaceOfChampions = try await createMockWorkspace(.enabledTraits(["BreakfastOfChampions"])) + try await workspaceOfChampions.checkPackageGraph(roots: ["Cereal"], deps: deps) { graph, diagnostics in + XCTAssertNoDiagnostics(diagnostics) + PackageGraphTesterXCTest(graph) { result in + result.check(roots: "Cereal") + result.check(packages: "cereal", "sugar") + result.check(modules: "Wheat", "Icing") + result.check(products: "YummyBreakfast", "Icing") + result.checkTarget("Wheat") { result in + result.check(dependencies: "Icing") + } + } + } + + let dontTellMomAboutThisWorkspace = try await createMockWorkspace(.enabledTraits(["DontTellMom"])) + try await dontTellMomAboutThisWorkspace.checkPackageGraph(roots: ["Cereal"], deps: deps) { graph, diagnostics in + XCTAssertNoDiagnostics(diagnostics) + PackageGraphTesterXCTest(graph) { result in + result.check(roots: "Cereal") + result.check(packages: "cereal", "sugar") + result.check(modules: "Wheat", "Icing") + result.check(products: "YummyBreakfast", "Icing") + result.checkTarget("Wheat") { result in + result.check(dependencies: "Icing") + } + } + } + + let allEnabledTraitsWorkspace = try await createMockWorkspace(.enableAllTraits) + try await allEnabledTraitsWorkspace.checkPackageGraph(roots: ["Cereal"], deps: deps) { graph, diagnostics in + XCTAssertNoDiagnostics(diagnostics) + PackageGraphTesterXCTest(graph) { result in + result.check(roots: "Cereal") + result.check(packages: "cereal", "sugar") + result.check(modules: "Wheat", "Icing") + result.check(products: "YummyBreakfast", "Icing") + result.checkTarget("Wheat") { result in + result.check(dependencies: "Icing") + } + } + } + + let noSugarForBreakfastWorkspace = try await createMockWorkspace(.disableAllTraits) + try await noSugarForBreakfastWorkspace.checkPackageGraph(roots: ["Cereal"], deps: deps) { graph, diagnostics in + XCTAssertNoDiagnostics(diagnostics) + PackageGraphTesterXCTest(graph) { result in + result.check(roots: "Cereal") + result.check(packages: "cereal") + result.check(modules: "Wheat") + result.check(products: "YummyBreakfast") + } + } + } + func makeRegistryClient( packageIdentity: PackageIdentity, packageVersion: Version, From b6d0cdd81c6c12f7486dccb6da6832d0e1eefdc4 Mon Sep 17 00:00:00 2001 From: Jeff Schmitz Date: Wed, 27 Aug 2025 08:02:32 -0500 Subject: [PATCH 098/225] Fix: Generate tests for executable/tool with `--enable-swift-testing` (#9032) Updated `InitPackage` to handle `.executable` and `.tool` types when generating test files with SwiftTesting. Added corresponding tests to verify correct test file content for these package types. ### Motivation: The `swift package init` command did not generate a `Tests` directory for executable or tool packages when the `--enable-swift-testing` flag was used. This was caused by logic in `Sources/Workspace/InitPackage.swift` that incorrectly skipped test generation for these package types. This change fixes that behavior, ensuring that tests are created as expected. ### Modifications: - In `Sources/Workspace/InitPackage.swift`, the `writeTests()` function was modified to proceed with test generation for `.executable` and `.tool` package types. - In the same file, the `writeTestFileStubs()` function was updated to include `.executable` and `.tool` types, allowing the creation of standard library-style test files for them. ### Result: After this change, running `swift package init --type executable --enable-swift-testing` or `swift package init --type tool --enable-swift-testing` correctly generates a `Tests` directory with sample test files, aligning the tool's behavior with its intended functionality. ### Reproduction and Verification: #### Reproducing the Issue (on `main` branch): 1. Create a temporary directory: `mkdir -p /tmp/test-repro && cd /tmp/test-repro` 2. Run `swift package init --type executable --enable-swift-testing`. 3. Verify that no `Tests` directory is created by running `ls -F`. #### Verifying the Fix (on this branch): 1. Build the package manager: `swift build` 2. Find the built executable: `find .build -name swift-package` 3. Create a temporary directory: `mkdir -p /tmp/test-fix && cd /tmp/test-fix` 4. Run the newly built package manager to init a project: `../.build/arm64-apple-macosx/debug/swift-package init --type executable --enable-swift-testing` (adjust path as needed). 5. Verify that a `Tests` directory is now created by running `ls -F`. --- Sources/Workspace/InitPackage.swift | 13 +++++-- Tests/WorkspaceTests/InitTests.swift | 56 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/Sources/Workspace/InitPackage.swift b/Sources/Workspace/InitPackage.swift index f66d72c5985..79f8b8167fc 100644 --- a/Sources/Workspace/InitPackage.swift +++ b/Sources/Workspace/InitPackage.swift @@ -661,9 +661,12 @@ public final class InitPackage { } switch packageType { - case .empty, .executable, .tool, .buildToolPlugin, .commandPlugin: return - default: break + case .empty, .buildToolPlugin, .commandPlugin: + return + case .library, .executable, .tool, .macro: + break } + let tests = destinationPath.appending("Tests") guard self.fileSystem.exists(tests) == false else { return @@ -873,9 +876,11 @@ public final class InitPackage { try makeDirectories(testModule) let testClassFile = try AbsolutePath(validating: "\(moduleName)Tests.swift", relativeTo: testModule) + switch packageType { - case .empty, .buildToolPlugin, .commandPlugin, .executable, .tool: break - case .library: + case .empty, .buildToolPlugin, .commandPlugin: + break + case .library, .executable, .tool: try writeLibraryTestsFile(testClassFile) case .macro: try writeMacroTestsFile(testClassFile) diff --git a/Tests/WorkspaceTests/InitTests.swift b/Tests/WorkspaceTests/InitTests.swift index d09e117d1a5..287077570b6 100644 --- a/Tests/WorkspaceTests/InitTests.swift +++ b/Tests/WorkspaceTests/InitTests.swift @@ -297,6 +297,62 @@ final class InitTests: XCTestCase { } } + func testInitPackageExecutableWithSwiftTesting() async throws { + try testWithTemporaryDirectory { tmpPath in + let fs = localFileSystem + let path = tmpPath.appending("Foo") + let name = path.basename + try fs.createDirectory(path) + // Create the package + let initPackage = try InitPackage( + name: name, + packageType: .executable, + supportedTestingLibraries: [.swiftTesting], + destinationPath: path, + fileSystem: localFileSystem + ) + + try initPackage.writePackageStructure() + // Verify basic file system content that we expect in the package + let manifest = path.appending("Package.swift") + XCTAssertFileExists(manifest) + let testFile = path.appending("Tests").appending("FooTests").appending("FooTests.swift") + let testFileContents: String = try localFileSystem.readFileContents(testFile) + XCTAssertMatch(testFileContents, .contains(#"import Testing"#)) + XCTAssertNoMatch(testFileContents, .contains(#"import XCTest"#)) + XCTAssertMatch(testFileContents, .contains(#"@Test func example() async throws"#)) + XCTAssertNoMatch(testFileContents, .contains("func testExample() throws")) + } + } + + func testInitPackageToolWithSwiftTesting() async throws { + try testWithTemporaryDirectory { tmpPath in + let fs = localFileSystem + let path = tmpPath.appending("Foo") + let name = path.basename + try fs.createDirectory(path) + // Create the package + let initPackage = try InitPackage( + name: name, + packageType: .tool, + supportedTestingLibraries: [.swiftTesting], + destinationPath: path, + fileSystem: localFileSystem + ) + + try initPackage.writePackageStructure() + // Verify basic file system content that we expect in the package + let manifest = path.appending("Package.swift") + XCTAssertFileExists(manifest) + let testFile = path.appending("Tests").appending("FooTests").appending("FooTests.swift") + let testFileContents: String = try localFileSystem.readFileContents(testFile) + XCTAssertMatch(testFileContents, .contains(#"import Testing"#)) + XCTAssertNoMatch(testFileContents, .contains(#"import XCTest"#)) + XCTAssertMatch(testFileContents, .contains(#"@Test func example() async throws"#)) + XCTAssertNoMatch(testFileContents, .contains("func testExample() throws")) + } + } + func testInitPackageCommandPlugin() throws { try testWithTemporaryDirectory { tmpPath in let fs = localFileSystem From c0e0948aad119c6a6c83ec89d9b31b500a7b393d Mon Sep 17 00:00:00 2001 From: Jake Petroules Date: Thu, 21 Aug 2025 08:49:40 -0700 Subject: [PATCH 099/225] Emit "Found unhandled resource" to observability scope Libraries should never call print. Emit this diagnostic message to the observability scope instead. --- Sources/PackageLoading/TargetSourcesBuilder.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PackageLoading/TargetSourcesBuilder.swift b/Sources/PackageLoading/TargetSourcesBuilder.swift index 17f36fb2cbe..e218bf3f227 100644 --- a/Sources/PackageLoading/TargetSourcesBuilder.swift +++ b/Sources/PackageLoading/TargetSourcesBuilder.swift @@ -191,7 +191,7 @@ public struct TargetSourcesBuilder { if handledResources.contains(resource.path) { return nil } else { - print("Found unhandled resource at \(resource.path)") + self.observabilityScope.emit(info: "Found unhandled resource at \(resource.path)") return self.resource(for: resource.path, with: .init(resource.rule)) } } From 1ccfce4c6127c221610a3c4b115c8980eedf0af9 Mon Sep 17 00:00:00 2001 From: Pavel Yaskevich Date: Tue, 26 Aug 2025 11:27:45 -0700 Subject: [PATCH 100/225] [Commands] Adopt changes to package manifest refactoring actions Previously package manifest refactoring actions conformed to a custom refactoring provider and produced a `PackageEdit` that, in addition to manifest file changes, included a list of auxiliary files. This is no longer the case and all package manifest refactoring actions are now responsible only for manifest file transformations, the rest is handled by the commands themselves. --- Sources/Commands/PackageCommands/AddDependency.swift | 2 +- Sources/Commands/PackageCommands/AddProduct.swift | 2 +- Sources/Commands/PackageCommands/AddSetting.swift | 2 +- Sources/Commands/PackageCommands/AddTarget.swift | 3 ++- Sources/Commands/PackageCommands/AddTargetDependency.swift | 2 +- Sources/Commands/Utilities/RefactoringSupport.swift | 4 ++-- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Sources/Commands/PackageCommands/AddDependency.swift b/Sources/Commands/PackageCommands/AddDependency.swift index e6086e11781..03c49fc9661 100644 --- a/Sources/Commands/PackageCommands/AddDependency.swift +++ b/Sources/Commands/PackageCommands/AddDependency.swift @@ -255,7 +255,7 @@ extension SwiftPackageCommand { } } - let editResult = try AddPackageDependency.manifestRefactor( + let editResult = try AddPackageDependency.textRefactor( syntax: manifestSyntax, in: .init(dependency: packageDependency) ) diff --git a/Sources/Commands/PackageCommands/AddProduct.swift b/Sources/Commands/PackageCommands/AddProduct.swift index 9410b035b76..288e691797b 100644 --- a/Sources/Commands/PackageCommands/AddProduct.swift +++ b/Sources/Commands/PackageCommands/AddProduct.swift @@ -93,7 +93,7 @@ extension SwiftPackageCommand { targets: targets ) - let editResult = try SwiftRefactor.AddProduct.manifestRefactor( + let editResult = try SwiftRefactor.AddProduct.textRefactor( syntax: manifestSyntax, in: .init(product: product) ) diff --git a/Sources/Commands/PackageCommands/AddSetting.swift b/Sources/Commands/PackageCommands/AddSetting.swift index 9a1e3988895..4589bfa50f9 100644 --- a/Sources/Commands/PackageCommands/AddSetting.swift +++ b/Sources/Commands/PackageCommands/AddSetting.swift @@ -126,7 +126,7 @@ extension SwiftPackageCommand { } } - let editResult: PackageEdit + let editResult: [SourceEdit] switch setting { case .experimentalFeature: diff --git a/Sources/Commands/PackageCommands/AddTarget.swift b/Sources/Commands/PackageCommands/AddTarget.swift index b625931bd44..089f148675f 100644 --- a/Sources/Commands/PackageCommands/AddTarget.swift +++ b/Sources/Commands/PackageCommands/AddTarget.swift @@ -129,7 +129,8 @@ extension SwiftPackageCommand { url: url, checksum: checksum ) - let editResult = try AddPackageTarget.manifestRefactor( + + let editResult = try AddPackageTarget.textRefactor( syntax: manifestSyntax, in: .init( target: target, diff --git a/Sources/Commands/PackageCommands/AddTargetDependency.swift b/Sources/Commands/PackageCommands/AddTargetDependency.swift index ad8cb7829fc..c5daf524361 100644 --- a/Sources/Commands/PackageCommands/AddTargetDependency.swift +++ b/Sources/Commands/PackageCommands/AddTargetDependency.swift @@ -73,7 +73,7 @@ extension SwiftPackageCommand { dependency = .target(name: dependencyName) } - let editResult = try SwiftRefactor.AddTargetDependency.manifestRefactor( + let editResult = try SwiftRefactor.AddTargetDependency.textRefactor( syntax: manifestSyntax, in: .init( dependency: dependency, diff --git a/Sources/Commands/Utilities/RefactoringSupport.swift b/Sources/Commands/Utilities/RefactoringSupport.swift index fe57c09dc4f..352e4055c8e 100644 --- a/Sources/Commands/Utilities/RefactoringSupport.swift +++ b/Sources/Commands/Utilities/RefactoringSupport.swift @@ -15,7 +15,7 @@ import Basics @_spi(PackageRefactor) import SwiftRefactor import SwiftSyntax -package extension PackageEdit { +package extension [SourceEdit] { /// Apply the edits for the given manifest to the specified file system, /// updating the manifest to the given manifest func applyEdits( @@ -32,7 +32,7 @@ package extension PackageEdit { } let updatedManifestSource = FixItApplier.apply( - edits: manifestEdits, + edits: self, to: manifest ) try filesystem.writeFileContents( From 737742919b5b821a80e7d32a419093a84414c7b2 Mon Sep 17 00:00:00 2001 From: Joseph Heck Date: Wed, 27 Aug 2025 14:36:36 -0700 Subject: [PATCH 101/225] adds traits to dependency output in show-dependencies --json and --text (#9034) Adds traits to the output of the show-dependencies command for `--text` and `--json` output. ### Motivation: Exposes the default traits, if any, that are enabled by the dependencies a package. Resolves: #9033 ### Modifications: Extended the logic in show-dependencies to add in the `enabledTraits` for a resolved package. ### Result: The output of show-dependencies includes output akin to `(traits: FirstTrait, SecondTrait)` is the resolved package has traits and includes the traits that it has. If the package doesn't offer traits, no additional/new content is displayed in `--text`. The JSON provided from `show-dependencies --json` includes a `traits` property, empty if there aren't any traits enabled. --- .../Utilities/DependenciesSerializer.swift | 10 +++- Tests/CommandsTests/PackageCommandTests.swift | 49 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/Sources/Commands/Utilities/DependenciesSerializer.swift b/Sources/Commands/Utilities/DependenciesSerializer.swift index d58e5c58162..31f82582013 100644 --- a/Sources/Commands/Utilities/DependenciesSerializer.swift +++ b/Sources/Commands/Utilities/DependenciesSerializer.swift @@ -34,7 +34,14 @@ final class PlainTextDumper: DependenciesDumper { let pkgVersion = package.manifest.version?.description ?? "unspecified" - stream.send("\(hanger)\(package.identity.description)<\(package.manifest.packageLocation)@\(pkgVersion)>\n") + let traitsEnabled: String + if let enabled = package.enabledTraits, !enabled.isEmpty { + traitsEnabled = "(traits: \(package.enabledTraits?.joined(separator: ", ") ?? ""))" + } else { + traitsEnabled = "" + } + + stream.send("\(hanger)\(package.identity.description)<\(package.manifest.packageLocation)@\(pkgVersion)>\(traitsEnabled)\n") if !package.dependencies.isEmpty { let replacement = (index == packages.count - 1) ? " " : "│ " @@ -130,6 +137,7 @@ final class JSONDumper: DependenciesDumper { "url": .string(package.manifest.packageLocation), "version": .string(package.manifest.version?.description ?? "unspecified"), "path": .string(package.path.pathString), + "traits": .array(package.enabledTraits?.map { .string($0) } ?? []), "dependencies": .array(package.dependencies.compactMap { graph.packages[$0] }.map(convert)), ]) } diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index ce4a501fda7..492360468af 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -1315,6 +1315,55 @@ struct PackageCommandTests { } } + @Test( + .tags( + .Feature.Command.Package.ShowDependencies, + ), + arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), + ) + func showDependenciesWithTraits( + data: BuildData, + ) async throws { + try await fixture(name: "Traits") { fixturePath in + let packageRoot = fixturePath.appending("Example") + let (textOutput, _) = try await execute( + ["show-dependencies", "--format=text"], + packagePath: packageRoot, + configuration: data.config, + buildSystem: data.buildSystem, + ) + #expect(textOutput.contains("(traits: Package3Trait3)")) + + let (jsonOutput, _) = try await execute( + ["show-dependencies", "--format=json"], + packagePath: packageRoot, + configuration: data.config, + buildSystem: data.buildSystem, + ) + let json = try JSON(bytes: ByteString(encodingAsUTF8: jsonOutput)) + guard case .dictionary(let contents) = json else { + Issue.record("unexpected result") + return + } + guard case .string(let name)? = contents["name"] else { + Issue.record("unexpected result") + return + } + #expect(name == "TraitsExample") + + // verify the traits JSON entry lists each of the traits in the fixture + guard case .array(let traitsProperty)? = contents["traits"] else { + Issue.record("unexpected result") + return + } + #expect(traitsProperty.contains(.string("Package1"))) + #expect(traitsProperty.contains(.string("Package2"))) + #expect(traitsProperty.contains(.string("Package3"))) + #expect(traitsProperty.contains(.string("Package4"))) + #expect(traitsProperty.contains(.string("BuildCondition1"))) + } + } + @Test( arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), ) From 181b1d649e7ccd4b48ee1b6bbad0959694799279 Mon Sep 17 00:00:00 2001 From: "Bassam (Sam) Khouri" Date: Tue, 2 Sep 2025 12:16:24 -0400 Subject: [PATCH 102/225] Tests: Migrate 2 suites to Swift Testing (#9017) Migrate a few suites to Swift Teting, namely, - SwiftSDKCommandTests - MultiRootSupportTests Depends on: #9012 Relates to: #8997 issue: rdar://157669245 --- Sources/Basics/ArrayHelpers.swift | 54 ++- .../SwiftTesting+Tags.swift | 3 +- Tests/BasicsTests/ArrayHelpersTests.swift | 112 +++++- Tests/CommandsTests/CommandsTestCase.swift | 54 --- .../CommandsTests/MultiRootSupportTests.swift | 25 +- .../SwiftCommandStateTests.swift | 15 +- .../CommandsTests/SwiftSDKCommandTests.swift | 342 ++++++++++-------- 7 files changed, 384 insertions(+), 221 deletions(-) delete mode 100644 Tests/CommandsTests/CommandsTestCase.swift diff --git a/Sources/Basics/ArrayHelpers.swift b/Sources/Basics/ArrayHelpers.swift index 9a900fb0783..d6c1dd8e37c 100644 --- a/Sources/Basics/ArrayHelpers.swift +++ b/Sources/Basics/ArrayHelpers.swift @@ -17,5 +17,57 @@ public func nextItem(in array: [T], after item: T) -> T? { return nextIndex < array.count ? array[nextIndex] : nil } } - return nil // Item not found or it's the last item + return nil // Item not found or it's the last item +} + +/// Determines if an array contains all elements of a subset array in any order. +/// - Parameters: +/// - array: The array to search within +/// - subset: The subset array to check for +/// - shouldBeContiguous: if `true`, the subset match must be contiguous sequenence +/// - Returns: `true` if all elements in `subset` are present in `array`, `false` otherwise +public func contains(array: [T], subset: [T], shouldBeContiguous: Bool = true) -> Bool { + if shouldBeContiguous { + return containsContiguousSubset(array: array, subset: subset) + } else { + return containsNonContiguousSubset(array: array, subset: subset) + } +} + +/// Determines if an array contains all elements of a subset array in any order. +/// - Parameters: +/// - array: The array to search within +/// - subset: The subset array to check for +/// - Returns: `true` if all elements in `subset` are present in `array`, `false` otherwise +internal func containsNonContiguousSubset(array: [T], subset: [T]) -> Bool { + for element in subset { + if !array.contains(element) { + return false + } + } + return true +} + +/// Determines if an array contains a contiguous subsequence matching the subset array. +/// - Parameters: +/// - array: The array to search within +/// - subset: The contiguous subset array to check for +/// - Returns: `true` if `subset` appears as a contiguous subsequence in `array`, `false` otherwise +internal func containsContiguousSubset(array: [T], subset: [T]) -> Bool { + guard !subset.isEmpty else { return true } + guard subset.count <= array.count else { return false } + + for startIndex in 0...(array.count - subset.count) { + var matches = true + for (offset, element) in subset.enumerated() { + if array[startIndex + offset] != element { + matches = false + break + } + } + if matches { + return true + } + } + return false } diff --git a/Sources/_InternalTestSupport/SwiftTesting+Tags.swift b/Sources/_InternalTestSupport/SwiftTesting+Tags.swift index 8ffd8b7e7af..d49995cd172 100644 --- a/Sources/_InternalTestSupport/SwiftTesting+Tags.swift +++ b/Sources/_InternalTestSupport/SwiftTesting+Tags.swift @@ -41,8 +41,9 @@ extension Tag.Feature.Command { public enum Package {} public enum PackageRegistry {} @Tag public static var Build: Tag - @Tag public static var Test: Tag @Tag public static var Run: Tag + @Tag public static var Sdk: Tag + @Tag public static var Test: Tag } extension Tag.Feature.Command.Package { diff --git a/Tests/BasicsTests/ArrayHelpersTests.swift b/Tests/BasicsTests/ArrayHelpersTests.swift index 5cfc6347a61..55cc50fa82c 100644 --- a/Tests/BasicsTests/ArrayHelpersTests.swift +++ b/Tests/BasicsTests/ArrayHelpersTests.swift @@ -8,8 +8,9 @@ See http://swift.org/CONTRIBUTORS.txt for Swift project authors */ - import func Basics.nextItem +@testable import func Basics.containsNonContiguousSubset +@testable import func Basics.containsContiguousSubset import Testing @Suite struct ArrayHelpersTests { @@ -29,4 +30,113 @@ struct ArrayHelpersTests { #expect(nextItem(in: [1], after: 1) == nil) #expect(nextItem(in: [0, 1, 12, 1, 4], after: 1) == 12) } + + @Test( + .tags( + Tag.TestSize.small, + ), + ) + func containsNonContiguousSubsetReturnsExpectedValue() async throws { + // Empty subset should always return true + #expect(containsNonContiguousSubset(array: [] as [String], subset: []) == true) + #expect(containsNonContiguousSubset(array: [] as [Int], subset: []) == true) + #expect(containsNonContiguousSubset(array: [] as [Bool], subset: []) == true) + #expect(containsNonContiguousSubset(array: [1, 2, 3], subset: []) == true) + + // Empty array with non-empty subset should return false + #expect(containsNonContiguousSubset(array: [] as [Int], subset: [1]) == false) + + // Single element tests + #expect(containsNonContiguousSubset(array: [1], subset: [1]) == true) + #expect(containsNonContiguousSubset(array: [1], subset: [2]) == false) + + // Basic subset tests - all elements present + #expect(containsNonContiguousSubset(array: [1, 2, 3, 4, 5], subset: [1, 3, 5]) == true) + #expect(containsNonContiguousSubset(array: [1, 2, 3, 4, 5], subset: [5, 1, 3]) == true) // Order doesn't matter + #expect(containsNonContiguousSubset(array: [1, 2, 3, 4, 5], subset: [2, 4]) == true) + + // Missing elements tests + #expect(containsNonContiguousSubset(array: [1, 2, 3, 4, 5], subset: [1, 6]) == false) + #expect(containsNonContiguousSubset(array: [1, 2, 3, 4, 5], subset: [6, 7, 8]) == false) + + // Duplicate elements in subset + #expect(containsNonContiguousSubset(array: [1, 2, 2, 3, 4], subset: [2, 2]) == true) + #expect(containsNonContiguousSubset(array: [1, 2, 3, 4], subset: [2, 2]) == true) // Only one 2 in array, but still contains 2 + + // String tests + #expect(containsNonContiguousSubset(array: ["a", "b", "c", "d"], subset: ["a", "c"]) == true) + #expect(containsNonContiguousSubset(array: ["a", "b", "c", "d"], subset: ["d", "a"]) == true) + #expect(containsNonContiguousSubset(array: ["a", "b", "c", "d"], subset: ["e"]) == false) + + // Subset same size as array + #expect(containsNonContiguousSubset(array: [1, 2, 3], subset: [3, 2, 1]) == true) + #expect(containsNonContiguousSubset(array: [1, 2, 3], subset: [1, 2, 4]) == false) + + // Subset larger than array + #expect(containsNonContiguousSubset(array: [1, 2], subset: [1, 2, 3]) == false) + } + + @Test( + .tags( + Tag.TestSize.small, + ), + ) + func containsContiguousSubsetReturnsExpectedValue() async throws { + // Empty subset should always return true + #expect(containsContiguousSubset(array: [] as [String], subset: []) == true) + #expect(containsContiguousSubset(array: [] as [Int], subset: []) == true) + #expect(containsContiguousSubset(array: [] as [Bool], subset: []) == true) + #expect(containsContiguousSubset(array: [1, 2, 3], subset: []) == true) + + // Empty array with non-empty subset should return false + #expect(containsContiguousSubset(array: [] as [Int], subset: [1]) == false) + + // Single element tests + #expect(containsContiguousSubset(array: [1], subset: [1]) == true) + #expect(containsContiguousSubset(array: [1], subset: [2]) == false) + + // Basic contiguous subset tests + #expect(containsContiguousSubset(array: [1, 2, 3, 4, 5], subset: [2, 3, 4]) == true) + #expect(containsContiguousSubset(array: [1, 2, 3, 4, 5], subset: [1, 2]) == true) + #expect(containsContiguousSubset(array: [1, 2, 3, 4, 5], subset: [4, 5]) == true) + #expect(containsContiguousSubset(array: [1, 2, 3, 4, 5], subset: [1, 2, 3, 4, 5]) == true) // Entire array + + // Non-contiguous elements should return false + #expect(containsContiguousSubset(array: [1, 2, 3, 4, 5], subset: [1, 3, 5]) == false) + #expect(containsContiguousSubset(array: [1, 2, 3, 4, 5], subset: [2, 4]) == false) + #expect(containsContiguousSubset(array: [1, 2, 3, 4, 5], subset: [1, 3]) == false) + + // Wrong order should return false + #expect(containsContiguousSubset(array: [1, 2, 3, 4, 5], subset: [3, 2, 1]) == false) + #expect(containsContiguousSubset(array: [1, 2, 3, 4, 5], subset: [5, 4, 3]) == false) + + // Missing elements + #expect(containsContiguousSubset(array: [1, 2, 3, 4, 5], subset: [1, 6]) == false) + #expect(containsContiguousSubset(array: [1, 2, 3, 4, 5], subset: [6, 7]) == false) + + // Duplicate elements + #expect(containsContiguousSubset(array: [1, 2, 2, 3, 4], subset: [2, 2, 3]) == true) + #expect(containsContiguousSubset(array: [1, 2, 3, 2, 4], subset: [2, 2]) == false) // 2s are not contiguous + #expect(containsContiguousSubset(array: [1, 1, 2, 3], subset: [1, 1]) == true) + + // String tests + #expect(containsContiguousSubset(array: ["a"], subset: ["b", "c"]) == false) + #expect(containsContiguousSubset(array: ["a"], subset: []) == true) + #expect(containsContiguousSubset(array: ["a", "b", "c", "d"], subset: ["b", "c"]) == true) + #expect(containsContiguousSubset(array: ["a", "b", "c", "d"], subset: ["a", "c"]) == false) // Not contiguous + #expect(containsContiguousSubset(array: ["hello", "world", "test"], subset: ["world", "test"]) == true) + + // Subset larger than array + #expect(containsContiguousSubset(array: [1, 2], subset: [1, 2, 3]) == false) + + // Multiple occurrences - should find first match + #expect(containsContiguousSubset(array: [1, 2, 3, 1, 2, 3], subset: [1, 2]) == true) + #expect(containsContiguousSubset(array: [1, 2, 3, 1, 2, 3], subset: [2, 3]) == true) + + // Edge case: subset at the end + #expect(containsContiguousSubset(array: [1, 2, 3, 4], subset: [3, 4]) == true) + + // Edge case: subset at the beginning + #expect(containsContiguousSubset(array: [1, 2, 3, 4], subset: [1, 2]) == true) + } } diff --git a/Tests/CommandsTests/CommandsTestCase.swift b/Tests/CommandsTests/CommandsTestCase.swift deleted file mode 100644 index 93549b997ed..00000000000 --- a/Tests/CommandsTests/CommandsTestCase.swift +++ /dev/null @@ -1,54 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift open source project -// -// Copyright (c) 2021 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 XCTest -import _InternalTestSupport - -class CommandsTestCase: XCTestCase { - - /// Original working directory before the test ran (if known). - private var originalWorkingDirectory: AbsolutePath? = .none - public let duplicateSymbolRegex = StringPattern.regex( - #"objc[83768]: (.*) is implemented in both .* \(.*\) and .* \(.*\) . One of the two will be used. Which one is undefined."# - ) - - override func setUp() { - originalWorkingDirectory = localFileSystem.currentWorkingDirectory - } - - override func tearDown() { - if let originalWorkingDirectory { - try? localFileSystem.changeCurrentWorkingDirectory(to: originalWorkingDirectory) - } - } - - // FIXME: We should also hoist the `execute()` helper function that the various test suites implement, but right now they all seem to have slightly different implementations, so that's a later project. -} - -class CommandsBuildProviderTestCase: BuildSystemProviderTestCase { - /// Original working directory before the test ran (if known). - private var originalWorkingDirectory: AbsolutePath? = .none - let duplicateSymbolRegex = StringPattern.regex(".*One of the duplicates must be removed or renamed.") - - override func setUp() { - originalWorkingDirectory = localFileSystem.currentWorkingDirectory - } - - override func tearDown() { - if let originalWorkingDirectory { - try? localFileSystem.changeCurrentWorkingDirectory(to: originalWorkingDirectory) - } - } - - // FIXME: We should also hoist the `execute()` helper function that the various test suites implement, but right now they all seem to have slightly different implementations, so that's a later project. -} diff --git a/Tests/CommandsTests/MultiRootSupportTests.swift b/Tests/CommandsTests/MultiRootSupportTests.swift index ae317528862..eb67b4374b3 100644 --- a/Tests/CommandsTests/MultiRootSupportTests.swift +++ b/Tests/CommandsTests/MultiRootSupportTests.swift @@ -2,28 +2,37 @@ // // This source file is part of the Swift open source project // -// Copyright (c) 2014-2024 Apple Inc. and the Swift project authors +// 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 Foundation import Basics import Commands import _InternalTestSupport import Workspace -import XCTest +import Testing -final class MultiRootSupportTests: CommandsTestCase { - func testWorkspaceLoader() throws { +@Suite( + .tags( + .TestSize.large, + ), +) +struct MultiRootSupportTests { + @Test + func workspaceLoader() throws { let fs = InMemoryFileSystem(emptyFiles: [ "/tmp/test/dep/Package.swift", "/tmp/test/local/Package.swift", ]) let path = AbsolutePath("/tmp/test/Workspace.xcworkspace") - try fs.writeFileContents(path.appending("contents.xcworkspacedata"), string: + try fs.writeFileContents( + path.appending("contents.xcworkspacedata"), + string: """ ") || stdout.contains("USAGE: swift sdk []"), "got stdout:\n" + stdout) - } +@Suite( + .serialized, + .tags( + .Feature.Command.Sdk, + .TestSize.large, + ), +) +struct SwiftSDKCommandTests { + @Test( + arguments: [SwiftPM.sdk, SwiftPM.experimentalSDK], + ) + func usage( + command: SwiftPM, + ) async throws { + + let stdout = try await command.execute(["-help"]).stdout + #expect( + stdout.contains("USAGE: swift sdk ") || stdout.contains("USAGE: swift sdk []"), + "got stdout:\n\(stdout)", + ) } - func testCommandDoesNotEmitDuplicateSymbols() async throws { - for command in [SwiftPM.sdk, SwiftPM.experimentalSDK] { - let (stdout, stderr) = try await command.execute(["--help"]) - XCTAssertNoMatch(stdout, duplicateSymbolRegex) - XCTAssertNoMatch(stderr, duplicateSymbolRegex) - } + @Test( + arguments: [SwiftPM.sdk, SwiftPM.experimentalSDK], + ) + func commandDoesNotEmitDuplicateSymbols( + command: SwiftPM, + ) async throws { + let (stdout, stderr) = try await command.execute(["--help"]) + let duplicateSymbolRegex = try Regex(#"objc[83768]: (.*) is implemented in both .* \(.*\) and .* \(.*\) . One of the two will be used. Which one is undefined."#) + #expect(!stdout.contains(duplicateSymbolRegex)) + #expect(!stderr.contains(duplicateSymbolRegex)) + } - func testVersionS() async throws { + @Test( + arguments: [SwiftPM.sdk, SwiftPM.experimentalSDK], + ) + func version( + command: SwiftPM, + ) async throws { for command in [SwiftPM.sdk, SwiftPM.experimentalSDK] { let stdout = try await command.execute(["--version"]).stdout - XCTAssertMatch(stdout, .regex(#"Swift Package Manager -( \w+ )?\d+.\d+.\d+(-\w+)?"#)) + let versionRegex = try Regex(#"Swift Package Manager -( \w+ )?\d+.\d+.\d+(-\w+)?"#) + #expect(stdout.contains(versionRegex)) } } - func testInstallSubcommand() async throws { - for command in [SwiftPM.sdk, SwiftPM.experimentalSDK] { - try await fixtureXCTest(name: "SwiftSDKs") { fixturePath in - for bundle in ["test-sdk.artifactbundle.tar.gz", "test-sdk.artifactbundle.zip"] { - var (stdout, stderr) = try await command.execute( - [ - "install", - "--swift-sdks-path", fixturePath.pathString, - fixturePath.appending(bundle).pathString - ] - ) - - if command == .experimentalSDK { - XCTAssertMatch(stderr, .contains(sdkCommandDeprecationWarning)) - XCTAssertNoMatch(stdout, .contains(sdkCommandDeprecationWarning)) - } + @Test( + arguments: [SwiftPM.sdk, SwiftPM.experimentalSDK], + ["test-sdk.artifactbundle.tar.gz", "test-sdk.artifactbundle.zip"], + ) + func installSubcommand( + command: SwiftPM, + bundle: String, + ) async throws { + try await fixture(name: "SwiftSDKs") { fixturePath in + let bundlePath = fixturePath.appending(bundle) + expectFileExists(at: bundlePath) + var (stdout, stderr) = try await command.execute( + [ + "install", + "--swift-sdks-path", fixturePath.pathString, + bundlePath.pathString, + ] + ) - // We only expect tool's output on the stdout stream. - XCTAssertMatch( - stdout + "\nstderr:\n" + stderr, - .contains("\(bundle)` successfully installed as test-sdk.artifactbundle.") - ) + if command == .experimentalSDK { + #expect(stderr.contains(sdkCommandDeprecationWarning)) + #expect(!stdout.contains(sdkCommandDeprecationWarning)) + } - (stdout, stderr) = try await command.execute( - ["list", "--swift-sdks-path", fixturePath.pathString]) + // We only expect tool's output on the stdout stream. + #expect( + (stdout + "\nstderr:\n" + stderr).contains("\(bundle)` successfully installed as test-sdk.artifactbundle.") + ) - if command == .experimentalSDK { - XCTAssertMatch(stderr, .contains(sdkCommandDeprecationWarning)) - XCTAssertNoMatch(stdout, .contains(sdkCommandDeprecationWarning)) - } + (stdout, stderr) = try await command.execute( + ["list", "--swift-sdks-path", fixturePath.pathString]) - // We only expect tool's output on the stdout stream. - XCTAssertMatch(stdout, .contains("test-artifact")) - - await XCTAssertAsyncThrowsError(try await command.execute( - [ - "install", - "--swift-sdks-path", fixturePath.pathString, - fixturePath.appending(bundle).pathString - ] - )) { error in - guard case SwiftPMError.executionFailure(_, _, let stderr) = error else { - XCTFail() - return - } + if command == .experimentalSDK { + #expect(stderr.contains(sdkCommandDeprecationWarning)) + #expect(!stdout.contains(sdkCommandDeprecationWarning)) + } - XCTAssertMatch( - stderr, .contains( - "Error: Swift SDK bundle with name `test-sdk.artifactbundle` is already installed. Can't install a new bundle with the same name." - ), - ) - } + // We only expect tool's output on the stdout stream. + #expect(stdout.contains("test-artifact")) - if command == .experimentalSDK { - XCTAssertMatch(stderr, .contains(sdkCommandDeprecationWarning)) - } + await expectThrowsCommandExecutionError( + try await command.execute( + [ + "install", + "--swift-sdks-path", fixturePath.pathString, + bundlePath.pathString, + ] + ) + ) { error in + let stderr = error.stderr + #expect( + stderr.contains( + "Error: Swift SDK bundle with name `test-sdk.artifactbundle` is already installed. Can't install a new bundle with the same name." + ), + ) + } - (stdout, stderr) = try await command.execute( - ["remove", "--swift-sdks-path", fixturePath.pathString, "test-artifact"]) + if command == .experimentalSDK { + #expect(stderr.contains(sdkCommandDeprecationWarning)) + } - if command == .experimentalSDK { - XCTAssertMatch(stderr, .contains(sdkCommandDeprecationWarning)) - XCTAssertNoMatch(stdout, .contains(sdkCommandDeprecationWarning)) - } + (stdout, stderr) = try await command.execute( + ["remove", "--swift-sdks-path", fixturePath.pathString, "test-artifact"]) - // We only expect tool's output on the stdout stream. - XCTAssertMatch(stdout, .contains("test-sdk.artifactbundle` was successfully removed from the file system.")) + if command == .experimentalSDK { + #expect(stderr.contains(sdkCommandDeprecationWarning)) + #expect(!stdout.contains(sdkCommandDeprecationWarning)) + } - (stdout, stderr) = try await command.execute( - ["list", "--swift-sdks-path", fixturePath.pathString]) + // We only expect tool's output on the stdout stream. + #expect(stdout.contains("test-sdk.artifactbundle` was successfully removed from the file system.")) - if command == .experimentalSDK { - XCTAssertMatch(stderr, .contains(sdkCommandDeprecationWarning)) - XCTAssertNoMatch(stdout, .contains(sdkCommandDeprecationWarning)) - } + (stdout, stderr) = try await command.execute( + ["list", "--swift-sdks-path", fixturePath.pathString]) - // We only expect tool's output on the stdout stream. - XCTAssertNoMatch(stdout, .contains("test-artifact")) - } + if command == .experimentalSDK { + #expect(stderr.contains(sdkCommandDeprecationWarning)) + #expect(!stdout.contains(sdkCommandDeprecationWarning)) } + + // We only expect tool's output on the stdout stream. + #expect(!stdout.contains("test-artifact")) } } - func testConfigureSubcommand() async throws { + @Test( + arguments: [SwiftPM.sdk, SwiftPM.experimentalSDK], + ) + func configureSubcommand( + command: SwiftPM, + ) async throws { let deprecationWarning = """ warning: `swift sdk configuration` command is deprecated and will be removed in a future version of \ SwiftPM. Use `swift sdk configure` instead. """ - for command in [SwiftPM.sdk, SwiftPM.experimentalSDK] { - try await fixtureXCTest(name: "SwiftSDKs") { fixturePath in - let bundle = "test-sdk.artifactbundle.zip" + try await fixture(name: "SwiftSDKs") { fixturePath in + let bundle = "test-sdk.artifactbundle.zip" - var (stdout, stderr) = try await command.execute([ - "install", - "--swift-sdks-path", fixturePath.pathString, - fixturePath.appending(bundle).pathString - ]) + var (stdout, stderr) = try await command.execute([ + "install", + "--swift-sdks-path", fixturePath.pathString, + fixturePath.appending(bundle).pathString, + ]) - // We only expect tool's output on the stdout stream. - XCTAssertMatch( - stdout, - .contains("\(bundle)` successfully installed as test-sdk.artifactbundle.") - ) + // We only expect tool's output on the stdout stream. + #expect( + stdout.contains("\(bundle)` successfully installed as test-sdk.artifactbundle.") + ) - let deprecatedShowSubcommand = ["configuration", "show"] + let deprecatedShowSubcommand = ["configuration", "show"] - for showSubcommand in [deprecatedShowSubcommand, ["configure", "--show-configuration"]] { - let invocation = showSubcommand + [ + for showSubcommand in [deprecatedShowSubcommand, ["configure", "--show-configuration"]] { + let invocation = + showSubcommand + [ "--swift-sdks-path", fixturePath.pathString, "test-artifact", "aarch64-unknown-linux-gnu", ] - (stdout, stderr) = try await command.execute(invocation) + (stdout, stderr) = try await command.execute(invocation) - if command == .experimentalSDK { - XCTAssertMatch(stderr, .contains(sdkCommandDeprecationWarning)) - } + if command == .experimentalSDK { + #expect(stderr.contains(sdkCommandDeprecationWarning)) + } - if showSubcommand == deprecatedShowSubcommand { - XCTAssertMatch(stderr, .contains(deprecationWarning)) - } + if showSubcommand == deprecatedShowSubcommand { + #expect(stderr.contains(deprecationWarning)) + } - let sdkSubpath = ["test-sdk.artifactbundle", "sdk" ,"sdk"] + let sdkSubpath = ["test-sdk.artifactbundle", "sdk", "sdk"] - XCTAssertEqual(stdout, - """ + #expect( + stdout == """ sdkRootPath: \(fixturePath.appending(components: sdkSubpath)) swiftResourcesPath: not set swiftStaticResourcesPath: not set @@ -182,47 +213,48 @@ final class SwiftSDKCommandTests: CommandsTestCase { librarySearchPaths: not set toolsetPaths: not set - """, - invocation.joined(separator: " ") - ) + """ + ) - let deprecatedSetSubcommand = ["configuration", "set"] - let deprecatedResetSubcommand = ["configuration", "reset"] - for setSubcommand in [deprecatedSetSubcommand, ["configure"]] { - for resetSubcommand in [deprecatedResetSubcommand, ["configure", "--reset"]] { - var invocation = setSubcommand + [ + let deprecatedSetSubcommand = ["configuration", "set"] + let deprecatedResetSubcommand = ["configuration", "reset"] + for setSubcommand in [deprecatedSetSubcommand, ["configure"]] { + for resetSubcommand in [deprecatedResetSubcommand, ["configure", "--reset"]] { + var invocation = + setSubcommand + [ "--swift-resources-path", fixturePath.appending("foo").pathString, "--swift-sdks-path", fixturePath.pathString, "test-artifact", "aarch64-unknown-linux-gnu", ] - (stdout, stderr) = try await command.execute(invocation) + (stdout, stderr) = try await command.execute(invocation) - XCTAssertEqual(stdout, """ + #expect( + stdout == """ info: These properties of Swift SDK `test-artifact` for target triple `aarch64-unknown-linux-gnu` \ were successfully updated: swiftResourcesPath. - """, - invocation.joined(separator: " ") - ) + """ + ) - if command == .experimentalSDK { - XCTAssertMatch(stderr, .contains(sdkCommandDeprecationWarning)) - } + if command == .experimentalSDK { + #expect(stderr.contains(sdkCommandDeprecationWarning)) + } - if setSubcommand == deprecatedSetSubcommand { - XCTAssertMatch(stderr, .contains(deprecationWarning)) - } + if setSubcommand == deprecatedSetSubcommand { + #expect(stderr.contains(deprecationWarning)) + } - invocation = showSubcommand + [ + invocation = + showSubcommand + [ "--swift-sdks-path", fixturePath.pathString, "test-artifact", "aarch64-unknown-linux-gnu", ] - (stdout, stderr) = try await command.execute(invocation) + (stdout, stderr) = try await command.execute(invocation) - XCTAssertEqual(stdout, - """ + #expect( + stdout == """ sdkRootPath: \(fixturePath.appending(components: sdkSubpath).pathString) swiftResourcesPath: \(fixturePath.appending("foo")) swiftStaticResourcesPath: not set @@ -230,42 +262,40 @@ final class SwiftSDKCommandTests: CommandsTestCase { librarySearchPaths: not set toolsetPaths: not set - """, - invocation.joined(separator: " ") - ) + """ + ) - invocation = resetSubcommand + [ + invocation = + resetSubcommand + [ "--swift-sdks-path", fixturePath.pathString, "test-artifact", "aarch64-unknown-linux-gnu", ] - (stdout, stderr) = try await command.execute(invocation) + (stdout, stderr) = try await command.execute(invocation) - if command == .experimentalSDK { - XCTAssertMatch(stderr, .contains(sdkCommandDeprecationWarning)) - } + if command == .experimentalSDK { + #expect(stderr.contains(sdkCommandDeprecationWarning)) + } - if resetSubcommand == deprecatedResetSubcommand { - XCTAssertMatch(stderr, .contains(deprecationWarning)) - } + if resetSubcommand == deprecatedResetSubcommand { + #expect(stderr.contains(deprecationWarning)) + } - XCTAssertEqual(stdout, - """ + #expect( + stdout == """ info: All configuration properties of Swift SDK `test-artifact` for target triple `aarch64-unknown-linux-gnu` were successfully reset. - """, - invocation.joined(separator: " ") - ) - } + """ + ) } } + } - (stdout, stderr) = try await command.execute( - ["remove", "--swift-sdks-path", fixturePath.pathString, "test-artifact"]) + (stdout, stderr) = try await command.execute( + ["remove", "--swift-sdks-path", fixturePath.pathString, "test-artifact"]) - // We only expect tool's output on the stdout stream. - XCTAssertMatch(stdout, .contains("test-sdk.artifactbundle` was successfully removed from the file system.")) - } + // We only expect tool's output on the stdout stream. + #expect(stdout.contains("test-sdk.artifactbundle` was successfully removed from the file system.")) } } } From 86091fae4da3d48d151347ff86176f9aefd7ad72 Mon Sep 17 00:00:00 2001 From: "Bassam (Sam) Khouri" Date: Tue, 2 Sep 2025 13:46:56 -0400 Subject: [PATCH 103/225] Tests: Migrate BuildSystemDelegateTests to Swift Testing and augment (#9013) Migrate the `BuildSystemDelegateTests` test to Swift Testing and augment the test to run against both the Native and SwiftBUild build system, in addition to the `debug` and `release` build configuration. Depends on: #9012 Relates to: #8997 issue: rdar://157669245 --- .../BuildSystemProvider+Configuration.swift | 40 +++- .../SwiftTesting+TraitConditional.swift | 7 + .../BuildTests/BuildSystemDelegateTests.swift | 102 +++++++--- Tests/CommandsTests/BuildCommandTests.swift | 43 ++--- Tests/CommandsTests/PackageCommandTests.swift | 177 +++++++----------- .../DependencyResolutionTests.swift | 34 ++-- .../ModuleAliasingFixtureTests.swift | 8 +- Tests/FunctionalTests/ToolsVersionTests.swift | 3 +- 8 files changed, 222 insertions(+), 192 deletions(-) diff --git a/Sources/_InternalTestSupport/BuildSystemProvider+Configuration.swift b/Sources/_InternalTestSupport/BuildSystemProvider+Configuration.swift index 5b7adcff485..30b3db4b2c7 100644 --- a/Sources/_InternalTestSupport/BuildSystemProvider+Configuration.swift +++ b/Sources/_InternalTestSupport/BuildSystemProvider+Configuration.swift @@ -12,16 +12,18 @@ import struct SPMBuildCore.BuildSystemProvider import enum PackageModel.BuildConfiguration +import class PackageModel.UserToolchain extension BuildSystemProvider.Kind { + @available(*, deprecated, message: "use binPath(for:scrathPath:triple) instead") public func binPathSuffixes(for config: BuildConfiguration) -> [String] { let suffix: String #if os(Linux) suffix = "-linux" #elseif os(Windows) - suffix = "-windows" + suffix = "-windows" #else suffix = "" #endif @@ -34,4 +36,40 @@ extension BuildSystemProvider.Kind { return ["apple", "Products" , "\(config)".capitalized + suffix] } } + + public func binPath( + for config: BuildConfiguration, + scratchPath: [String] = [".build"], + triple: String? = nil, + ) throws -> [String] { + let suffix: String + + #if os(Linux) + suffix = "-linux" + #elseif os(Windows) + suffix = "-windows" + #else + suffix = "" + #endif + + let tripleString: String + if let triple { + tripleString = triple + } else { + do { + tripleString = try UserToolchain.default.targetTriple.platformBuildPathComponent + } catch { + tripleString = "" + } + } + switch self { + case .native: + return scratchPath + [tripleString, "\(config)".lowercased()] + case .swiftbuild: + return scratchPath + [tripleString, "Products", "\(config)".capitalized + suffix] + case .xcode: + return scratchPath + ["apple", "Products", "\(config)".capitalized + suffix] + } + } + } diff --git a/Sources/_InternalTestSupport/SwiftTesting+TraitConditional.swift b/Sources/_InternalTestSupport/SwiftTesting+TraitConditional.swift index 9f7802e69fa..995391b89d7 100644 --- a/Sources/_InternalTestSupport/SwiftTesting+TraitConditional.swift +++ b/Sources/_InternalTestSupport/SwiftTesting+TraitConditional.swift @@ -79,6 +79,13 @@ extension Trait where Self == Testing.ConditionTrait { } } + /// Enabled if toolsupm suported SDK Dependent Tests + public static var requiresSDKDependentTestsSupport: Self { + enabled("skipping because test environment doesn't support this test") { + (try? UserToolchain.default)!.supportsSDKDependentTests() + } + } + // Enabled if the toolchain has supported features public static var supportsSupportedFeatures: Self { enabled("skipping because test environment compiler doesn't support `-print-supported-features`") { diff --git a/Tests/BuildTests/BuildSystemDelegateTests.swift b/Tests/BuildTests/BuildSystemDelegateTests.swift index 364df5cf7c7..52108d01e62 100644 --- a/Tests/BuildTests/BuildSystemDelegateTests.swift +++ b/Tests/BuildTests/BuildSystemDelegateTests.swift @@ -2,50 +2,94 @@ // // This source file is part of the Swift open source project // -// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Copyright (c) 2024-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 Foundation import PackageModel import _InternalTestSupport -import XCTest +import Testing import var TSCBasic.localFileSystem -final class BuildSystemDelegateTests: XCTestCase { - func testDoNotFilterLinkerDiagnostics() async throws { - try XCTSkipIf(!UserToolchain.default.supportsSDKDependentTests(), "skipping because test environment doesn't support this test") - try await fixtureXCTest(name: "Miscellaneous/DoNotFilterLinkerDiagnostics") { fixturePath in - #if !os(macOS) - // These linker diagnostics are only produced on macOS. - try XCTSkipIf(true, "test is only supported on macOS") - #endif - let (fullLog, _) = try await executeSwiftBuild(fixturePath, buildSystem: .native) - XCTAssertTrue(fullLog.contains("ld: warning: search path 'foobar' not found"), "log didn't contain expected linker diagnostics") +@Suite( + .tags( + .TestSize.large, + ) +) +struct BuildSystemDelegateTests { + @Test( + .requiresSDKDependentTestsSupport, + .requireHostOS(.macOS), // These linker diagnostics are only produced on macOS. + arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), + ) + func doNotFilterLinkerDiagnostics( + data: BuildData, + ) async throws { + try await fixture(name: "Miscellaneous/DoNotFilterLinkerDiagnostics") { fixturePath in + let (stdout, stderr) = try await executeSwiftBuild( + fixturePath, + configuration: data.config, + // extraArgs: ["--verbose"], + buildSystem: data.buildSystem, + ) + switch data.buildSystem { + case .native: + #expect( + stdout.contains("ld: warning: search path 'foobar' not found"), + "log didn't contain expected linker diagnostics. stderr: '\(stderr)')", + ) + case .swiftbuild: + #expect( + stderr.contains("warning: Search path 'foobar' not found"), + "log didn't contain expected linker diagnostics. stderr: '\(stdout)", + ) + #expect( + !stdout.contains("warning: Search path 'foobar' not found"), + "log didn't contain expected linker diagnostics. stderr: '\(stderr)')", + ) + case .xcode: + Issue.record("Test expectation has not be implemented") + } } } - func testFilterNonFatalCodesignMessages() async throws { - try XCTSkipOnWindows(because: "https://github.com/swiftlang/swift-package-manager/issues/8540: Package fails to build when the test is being executed") - - try XCTSkipIf(!UserToolchain.default.supportsSDKDependentTests(), "skipping because test environment doesn't support this test") - // Note: we can re-use the `TestableExe` fixture here since we just need an executable. - #if os(Windows) - let executableExt = ".exe" - #else - let executableExt = "" - #endif - try await fixtureXCTest(name: "Miscellaneous/TestableExe") { fixturePath in - _ = try await executeSwiftBuild(fixturePath, buildSystem: .native) - let execPath = fixturePath.appending(components: ".build", "debug", "TestableExe1\(executableExt)") - XCTAssertTrue(localFileSystem.exists(execPath), "executable not found at '\(execPath)'") - try localFileSystem.removeFileTree(execPath) - let (fullLog, _) = try await executeSwiftBuild(fixturePath, buildSystem: .native) - XCTAssertFalse(fullLog.contains("replacing existing signature"), "log contained non-fatal codesigning messages") + @Test( + .issue("https://github.com/swiftlang/swift-package-manager/issues/8540", relationship: .defect), // Package fails to build when the test is being executed" + .requiresSDKDependentTestsSupport, + arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), + ) + func filterNonFatalCodesignMessages( + data: BuildData, + ) async throws { + try await withKnownIssue(isIntermittent: true) { + // Note: we can re-use the `TestableExe` fixture here since we just need an executable. + try await fixture(name: "Miscellaneous/TestableExe") { fixturePath in + _ = try await executeSwiftBuild( + fixturePath, + configuration: data.config, + buildSystem: data.buildSystem, + ) + let execPath = try fixturePath.appending( + components: data.buildSystem.binPath(for: data.config) + [executableName("TestableExe1")] + ) + expectFileExists(at: execPath) + try localFileSystem.removeFileTree(execPath) + let (stdout, stderr) = try await executeSwiftBuild( + fixturePath, + configuration: data.config, + buildSystem: data.buildSystem, + ) + #expect(!stdout.contains("replacing existing signature"), "log contained non-fatal codesigning messages stderr: '\(stderr)'") + #expect(!stderr.contains("replacing existing signature"), "log contained non-fatal codesigning messages. stdout: '\(stdout)'") + } + } when: { + ProcessInfo.hostOperatingSystem == .windows } } } diff --git a/Tests/CommandsTests/BuildCommandTests.swift b/Tests/CommandsTests/BuildCommandTests.swift index e47f1044cc7..6a6a1c6547f 100644 --- a/Tests/CommandsTests/BuildCommandTests.swift +++ b/Tests/CommandsTests/BuildCommandTests.swift @@ -52,7 +52,6 @@ struct SanitierTests { } @Suite( - .serialized, // to limit the number of swift executable running. .tags( Tag.TestSize.large, Tag.Feature.Command.Build, @@ -165,18 +164,11 @@ struct BuildCommandTestCases { // Test is not implemented for Xcode build system try await fixture(name: "ValidLayouts/SingleModule/ExecutableNew") { fixturePath in let fullPath = try resolveSymlinks(fixturePath) - - let rootScrathPath = fullPath.appending(component: ".build") - let targetPath: AbsolutePath - if buildSystem == .xcode { - targetPath = rootScrathPath - } else { - targetPath = try rootScrathPath.appending(component: UserToolchain.default.targetTriple.platformBuildPathComponent) - } + + let targetPath = try fullPath.appending(components: buildSystem.binPath(for: configuration)) let path = try await self.execute(["--show-bin-path"], packagePath: fullPath, configuration: configuration, buildSystem: buildSystem).stdout.trimmingCharacters(in: .whitespacesAndNewlines) #expect( - AbsolutePath(path).pathString == targetPath - .appending(components: buildSystem.binPathSuffixes(for: configuration)).pathString + AbsolutePath(path).pathString == targetPath.pathString ) } } @@ -288,29 +280,24 @@ struct BuildCommandTestCases { } @Test( - .SWBINTTODO("Test fails because of a difference in the build layout. This needs to be updated to the expected path"), - arguments: SupportedBuildSystemOnPlatform, BuildConfiguration.allCases + arguments: getBuildData(for: SupportedBuildSystemOnPlatform), ) func symlink( - buildSystem: BuildSystemProvider.Kind, - configuration: BuildConfiguration, + data: BuildData, ) async throws { - try await withKnownIssue { + let buildSystem = data.buildSystem + let configuration = data.config + try await withKnownIssue(isIntermittent: true) { try await fixture(name: "ValidLayouts/SingleModule/ExecutableNew") { fixturePath in let fullPath = try resolveSymlinks(fixturePath) - let targetPath = try fullPath.appending(components: - ".build", - UserToolchain.default.targetTriple.platformBuildPathComponent - ) // Test symlink. - let buildDir = fullPath.appending(components: ".build") try await self.execute(packagePath: fullPath, configuration: configuration, buildSystem: buildSystem) - let actualDebug = try resolveSymlinks(buildDir.appending(components: buildSystem.binPathSuffixes(for: configuration))) - let expectedDebug = targetPath.appending(components: buildSystem.binPathSuffixes(for: configuration)) + let actualDebug = try resolveSymlinks(fullPath.appending(components: buildSystem.binPath(for: configuration))) + let expectedDebug = try fullPath.appending(components: buildSystem.binPath(for: configuration)) #expect(actualDebug == expectedDebug) } } when: { - buildSystem != .native + ProcessInfo.hostOperatingSystem == .windows } } @@ -1125,10 +1112,9 @@ struct BuildCommandTestCases { return try SupportedBuildSystemOnPlatform.map { buildSystem in let triple = try UserToolchain.default.targetTriple.withoutVersion() let base = try RelativePath(validating: ".build") - let debugFolderComponents = buildSystem.binPathSuffixes(for: .debug) + let path = try base.appending(components: buildSystem.binPath(for: .debug, scratchPath: [])) switch buildSystem { case .xcode: - let path = base.appending(components: debugFolderComponents) return ( buildSystem, triple.platformName() == "macosx" ? path.appending("ExecutableNew") : path @@ -1137,8 +1123,6 @@ struct BuildCommandTestCases { .appending("\(triple).swiftsourceinfo") ) case .swiftbuild: - let path = base.appending(triple.tripleString) - .appending(components: debugFolderComponents) return ( buildSystem, triple.platformName() == "macosx" ? path.appending("ExecutableNew") : path @@ -1149,8 +1133,7 @@ struct BuildCommandTestCases { case .native: return ( buildSystem, - base.appending(components: debugFolderComponents) - .appending("ExecutableNew.build") + path.appending("ExecutableNew.build") .appending("main.swift.o") ) } diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index 492360468af..2e9f61dbfe1 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -2461,12 +2461,9 @@ struct PackageCommandTests { ) // Path to the executable. + let binPath = try fooPath.appending(components: data.buildSystem.binPath(for: data.config)) let exec = [ - fooPath.appending( - components: [ - ".build", try UserToolchain.default.targetTriple.platformBuildPathComponent, - ] + data.buildSystem.binPathSuffixes(for: data.config) + ["foo"] - ).pathString + binPath.appending("foo").pathString ] // We should see it now in packages directory. @@ -2602,10 +2599,8 @@ struct PackageCommandTests { buildSystem: data.buildSystem, ) let buildPath = packageRoot.appending(".build") - let binFile = buildPath.appending( - components: [try UserToolchain.default.targetTriple.platformBuildPathComponent] - + data.buildSystem.binPathSuffixes(for: data.config) + [executableName("Bar")] - ) + let binPath = try buildPath.appending(components: data.buildSystem.binPath(for: data.config, scratchPath: [])) + let binFile = binPath.appending(executableName("Bar")) expectFileExists(at: binFile) #expect(localFileSystem.isDirectory(buildPath)) @@ -2653,10 +2648,8 @@ struct PackageCommandTests { buildSystem: data.buildSystem ) let buildPath = packageRoot.appending(".build") - let binFile = buildPath.appending( - components: [try UserToolchain.default.targetTriple.platformBuildPathComponent] - + data.buildSystem.binPathSuffixes(for: data.config) + [executableName("Bar")] - ) + let binPath = try buildPath.appending(components: data.buildSystem.binPath(for: data.config, scratchPath: [], )) + let binFile = binPath.appending(executableName("Bar")) expectFileExists(at: binFile) #expect(localFileSystem.isDirectory(buildPath)) // Clean, and check for removal of the build directory but not Packages. @@ -2782,15 +2775,9 @@ struct PackageCommandTests { try await withKnownIssue(isIntermittent: (ProcessInfo.hostOperatingSystem == .linux)) { try await fixture(name: "Miscellaneous/PackageEdit") { fixturePath in let fooPath = fixturePath.appending("foo") + let binPath = try fooPath.appending(components: data.buildSystem.binPath(for: data.config)) let exec = [ - fooPath.appending( - components: [ - ".build", - try UserToolchain.default.targetTriple.platformBuildPathComponent, - ] + data.buildSystem.binPathSuffixes(for: data.config) + [ - "foo" - ] - ).pathString + binPath.appending("foo").pathString ] // Build and check. @@ -5015,15 +5002,8 @@ struct PackageCommandTests { func commandPluginTargetBuilds_BinaryIsBuildinDebugByDefault( buildData: BuildData, ) async throws { - let tripleString = try UserToolchain.default.targetTriple.platformBuildPathComponent - let debugTarget = - [".build", tripleString] + buildData.buildSystem.binPathSuffixes(for: .debug) + [ - executableName("placeholder") - ] - let releaseTarget = - [".build", tripleString] + buildData.buildSystem.binPathSuffixes(for: .release) + [ - executableName("placeholder") - ] + let debugTarget = try buildData.buildSystem.binPath(for: .debug) + [executableName("placeholder")] + let releaseTarget = try buildData.buildSystem.binPath(for: .release) + [executableName("placeholder")] try await withKnownIssue { // By default, a plugin-requested build produces a debug binary try await fixture(name: "Miscellaneous/Plugins/CommandPluginTestStub") { fixturePath in @@ -5053,15 +5033,8 @@ struct PackageCommandTests { func commandPluginTargetBuilds_BinaryWillBeBuiltInDebugIfPluginSpecifiesDebugBuild( buildData: BuildData, ) async throws { - let tripleString = try UserToolchain.default.targetTriple.platformBuildPathComponent - let debugTarget = - [".build", tripleString] + buildData.buildSystem.binPathSuffixes(for: .debug) + [ - executableName("placeholder") - ] - let releaseTarget = - [".build", tripleString] + buildData.buildSystem.binPathSuffixes(for: .release) + [ - executableName("placeholder") - ] + let debugTarget = try buildData.buildSystem.binPath(for: .debug) + [executableName("placeholder")] + let releaseTarget = try buildData.buildSystem.binPath(for: .release) + [executableName("placeholder")] try await withKnownIssue { // If the plugin specifies a debug binary, that is what will be built, regardless of overall configuration try await fixture(name: "Miscellaneous/Plugins/CommandPluginTestStub") { fixturePath in @@ -5095,17 +5068,9 @@ struct PackageCommandTests { func commandPluginTargetBuilds_BinaryWillBeBuiltInReleaseIfPluginSpecifiesReleaseBuild( buildData: BuildData, ) async throws { - let tripleString = try UserToolchain.default.targetTriple.platformBuildPathComponent - let debugTarget = - [".build", tripleString] + buildData.buildSystem.binPathSuffixes(for: .debug) + [ - executableName("placeholder") - ] - let releaseTarget = - [".build", tripleString] + buildData.buildSystem.binPathSuffixes(for: .release) + [ - executableName("placeholder") - ] + let debugTarget = try buildData.buildSystem.binPath(for: .debug) + [executableName("placeholder")] + let releaseTarget = try buildData.buildSystem.binPath(for: .release) + [executableName("placeholder")] try await withKnownIssue { - // If the plugin requests a release binary, that is what will be built, regardless of overall configuration try await fixture(name: "Miscellaneous/Plugins/CommandPluginTestStub") { fixturePath in let _ = try await execute( @@ -5137,15 +5102,8 @@ struct PackageCommandTests { func commandPluginTargetBuilds_BinaryWillBeBuiltCorrectlyIfPluginSpecifiesInheritBuild( buildData: BuildData, ) async throws { - let tripleString = try UserToolchain.default.targetTriple.platformBuildPathComponent - let debugTarget = - [".build", tripleString] + buildData.buildSystem.binPathSuffixes(for: .debug) + [ - executableName("placeholder") - ] - let releaseTarget = - [".build", tripleString] + buildData.buildSystem.binPathSuffixes(for: .release) + [ - executableName("placeholder") - ] + let debugTarget = try buildData.buildSystem.binPath(for: .debug) + [executableName("placeholder")] + let releaseTarget = try buildData.buildSystem.binPath(for: .release) + [executableName("placeholder")] try await withKnownIssue { // If the plugin inherits the overall build configuration, that is what will be built try await fixture(name: "Miscellaneous/Plugins/CommandPluginTestStub") { fixturePath in @@ -5246,7 +5204,7 @@ struct PackageCommandTests { } } when: { ProcessInfo.hostOperatingSystem == .windows - || (ProcessInfo.hostOperatingSystem == .linux && data.buildSystem == .swiftbuild) + || (ProcessInfo.hostOperatingSystem == .linux && data.buildSystem == .swiftbuild) } } @@ -5491,6 +5449,9 @@ struct PackageCommandTests { .tags( .Feature.Command.Package.CommandPlugin, ), + .IssueWindowsRelativePathAssert, + .IssueWindowsLongPath, + .IssueWindowsPathLastConponent, arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), Self.getCommandPluginNetworkingPermissionTestData() ) @@ -5498,57 +5459,61 @@ struct PackageCommandTests { buildData: BuildData, testData: CommandPluginNetworkingPermissionsTestData, ) async throws { - try await testWithTemporaryDirectory { tmpPath in - // Create a sample package with a library target and a plugin. - let packageDir = tmpPath.appending(components: "MyPackage") - try localFileSystem.writeFileContents( - packageDir.appending(components: "Package.swift"), - string: - """ - // swift-tools-version: 5.9 - import PackageDescription - let package = Package( - name: "MyPackage", - targets: [ - .target(name: "MyLibrary"), - .plugin(name: "MyPlugin", capability: .command(intent: .custom(verb: "Network", description: "Help description"), permissions: \(testData.permissionsManifestFragment))), - ] - ) - """ - ) - try localFileSystem.writeFileContents( - packageDir.appending(components: "Sources", "MyLibrary", "library.swift"), - string: "public func Foo() { }" - ) - try localFileSystem.writeFileContents( - packageDir.appending(components: "Plugins", "MyPlugin", "plugin.swift"), - string: - """ - import PackagePlugin + try await withKnownIssue(isIntermittent: true) { + try await testWithTemporaryDirectory { tmpPath in + // Create a sample package with a library target and a plugin. + let packageDir = tmpPath.appending(components: "MyPackage") + try localFileSystem.writeFileContents( + packageDir.appending(components: "Package.swift"), + string: + """ + // swift-tools-version: 5.9 + import PackageDescription + let package = Package( + name: "MyPackage", + targets: [ + .target(name: "MyLibrary"), + .plugin(name: "MyPlugin", capability: .command(intent: .custom(verb: "Network", description: "Help description"), permissions: \(testData.permissionsManifestFragment))), + ] + ) + """ + ) + try localFileSystem.writeFileContents( + packageDir.appending(components: "Sources", "MyLibrary", "library.swift"), + string: "public func Foo() { }" + ) + try localFileSystem.writeFileContents( + packageDir.appending(components: "Plugins", "MyPlugin", "plugin.swift"), + string: + """ + import PackagePlugin - @main - struct MyCommandPlugin: CommandPlugin { - func performCommand(context: PluginContext, arguments: [String]) throws { - print("hello world") + @main + struct MyCommandPlugin: CommandPlugin { + func performCommand(context: PluginContext, arguments: [String]) throws { + print("hello world") + } } - } - """ - ) - - // Check that we don't get an error (and also are allowed to write to the package directory) if we pass `--allow-writing-to-package-directory`. - do { - let (stdout, _) = try await execute( - ["plugin"] + testData.remedy + ["Network"], - packagePath: packageDir, - configuration: buildData.config, - buildSystem: buildData.buildSystem, + """ ) - withKnownIssue(isIntermittent: true) { - #expect(stdout.contains("hello world")) - } when: { - ProcessInfo.hostOperatingSystem == .windows && buildData.buildSystem == .swiftbuild && buildData.config == .debug && testData.permissionError == Self.allNetworkConnectionPermissionError + + // Check that we don't get an error (and also are allowed to write to the package directory) if we pass `--allow-writing-to-package-directory`. + do { + let (stdout, _) = try await execute( + ["plugin"] + testData.remedy + ["Network"], + packagePath: packageDir, + configuration: buildData.config, + buildSystem: buildData.buildSystem, + ) + withKnownIssue(isIntermittent: true) { + #expect(stdout.contains("hello world")) + } when: { + ProcessInfo.hostOperatingSystem == .windows && buildData.buildSystem == .swiftbuild && buildData.config == .debug && testData.permissionError == Self.allNetworkConnectionPermissionError + } } } + } when: { + ProcessInfo.hostOperatingSystem == .windows } } @@ -6802,7 +6767,7 @@ struct PackageCommandTests { } #expect(stdout.contains("Building for \(data.config.buildFor)...")) } - } when : { + } when: { ProcessInfo.hostOperatingSystem == .windows && data.buildSystem == .swiftbuild } diff --git a/Tests/FunctionalTests/DependencyResolutionTests.swift b/Tests/FunctionalTests/DependencyResolutionTests.swift index 37e9842f8f2..32823fa2307 100644 --- a/Tests/FunctionalTests/DependencyResolutionTests.swift +++ b/Tests/FunctionalTests/DependencyResolutionTests.swift @@ -50,10 +50,8 @@ struct DependencyResolutionTests { buildSystem: buildSystem, ) - let executablePath = try fixturePath.appending( - components: [".build", UserToolchain.default.targetTriple.platformBuildPathComponent] - + buildSystem.binPathSuffixes(for: configuration) + ["Foo"] - ) + let binPath = try fixturePath.appending(components: buildSystem.binPath(for: configuration)) + let executablePath = binPath.appending(components: "Foo") let output = try await AsyncProcess.checkNonZeroExit(args: executablePath.pathString).withSwiftLineEnding #expect(output == "Foo\nBar\n") } @@ -111,10 +109,8 @@ struct DependencyResolutionTests { buildSystem: buildSystem, ) - let executablePath = try fixturePath.appending( - components: [".build", UserToolchain.default.targetTriple.platformBuildPathComponent] - + buildSystem.binPathSuffixes(for: configuration) + ["Foo"] - ) + let binPath = try fixturePath.appending(components: buildSystem.binPath(for: configuration)) + let executablePath = binPath.appending(components: "Foo") let output = try await AsyncProcess.checkNonZeroExit(args: executablePath.pathString) .withSwiftLineEnding #expect(output == "meiow Baz\n") @@ -152,12 +148,8 @@ struct DependencyResolutionTests { configuration: configuration, buildSystem: buildSystem, ) - let executablePath = packageRoot.appending( - components: [ - ".build", - try UserToolchain.default.targetTriple.platformBuildPathComponent, - ] + buildSystem.binPathSuffixes(for: configuration) + [executableName("Bar")] - ) + let binPath = try packageRoot.appending(components: buildSystem.binPath(for: configuration)) + let executablePath = binPath.appending(components: executableName("Bar")) #expect( localFileSystem.exists(executablePath), "Path \(executablePath) does not exist", @@ -185,22 +177,22 @@ struct DependencyResolutionTests { ) async throws { try await withKnownIssue(isIntermittent: ProcessInfo.hostOperatingSystem == .windows){ try await fixture(name: "DependencyResolution/External/Complex") { fixturePath in + let packageRoot = fixturePath.appending("app") try await executeSwiftBuild( - fixturePath.appending("app"), + packageRoot, configuration: configuration, buildSystem: buildSystem, ) - let executablePath = try fixturePath.appending( - components: [ - "app", ".build", UserToolchain.default.targetTriple.platformBuildPathComponent, - ] + buildSystem.binPathSuffixes(for: configuration) + ["Dealer"] - ) + let binPath = try packageRoot.appending(components: buildSystem.binPath(for: configuration)) + let executablePath = binPath.appending(components: "Dealer") + expectFileExists(at: executablePath) let output = try await AsyncProcess.checkNonZeroExit(args: executablePath.pathString) .withSwiftLineEnding #expect(output == "♣︎K\n♣︎Q\n♣︎J\n♣︎10\n♣︎9\n♣︎8\n♣︎7\n♣︎6\n♣︎5\n♣︎4\n") } } when: { - [.linux, .windows].contains(ProcessInfo.hostOperatingSystem) && buildSystem == .swiftbuild + ([.linux, .windows].contains(ProcessInfo.hostOperatingSystem) && buildSystem == .swiftbuild) + || (ProcessInfo.hostOperatingSystem == .windows) // due to long path isues } } diff --git a/Tests/FunctionalTests/ModuleAliasingFixtureTests.swift b/Tests/FunctionalTests/ModuleAliasingFixtureTests.swift index 88bb27b6e6a..bdefcfe9fcf 100644 --- a/Tests/FunctionalTests/ModuleAliasingFixtureTests.swift +++ b/Tests/FunctionalTests/ModuleAliasingFixtureTests.swift @@ -39,7 +39,7 @@ struct ModuleAliasingFixtureTests { try await withKnownIssue(isIntermittent: true) { try await fixture(name: "ModuleAliasing/DirectDeps1") { fixturePath in let pkgPath = fixturePath.appending(components: "AppPkg") - let buildPath = pkgPath.appending(components: [".build", try UserToolchain.default.targetTriple.platformBuildPathComponent] + buildSystem.binPathSuffixes(for: configuration)) + let buildPath = try pkgPath.appending(components: buildSystem.binPath(for: configuration)) try await executeSwiftBuild( pkgPath, configuration: configuration, @@ -82,7 +82,7 @@ struct ModuleAliasingFixtureTests { try await withKnownIssue { try await fixture(name: "ModuleAliasing/DirectDeps2") { fixturePath in let pkgPath = fixturePath.appending(components: "AppPkg") - let buildPath = pkgPath.appending(components: [".build", try UserToolchain.default.targetTriple.platformBuildPathComponent] + buildSystem.binPathSuffixes(for: configuration)) + let buildPath = try pkgPath.appending(components: buildSystem.binPath(for: configuration)) try await executeSwiftBuild( pkgPath, configuration: configuration, @@ -117,7 +117,7 @@ struct ModuleAliasingFixtureTests { try await withKnownIssue { try await fixture(name: "ModuleAliasing/NestedDeps1") { fixturePath in let pkgPath = fixturePath.appending(components: "AppPkg") - let buildPath = pkgPath.appending(components: [".build", try UserToolchain.default.targetTriple.platformBuildPathComponent] + buildSystem.binPathSuffixes(for: configuration)) + let buildPath = try pkgPath.appending(components: buildSystem.binPath(for: configuration)) try await executeSwiftBuild( pkgPath, configuration: configuration, @@ -156,7 +156,7 @@ struct ModuleAliasingFixtureTests { try await withKnownIssue { try await fixture(name: "ModuleAliasing/NestedDeps2") { fixturePath in let pkgPath = fixturePath.appending(components: "AppPkg") - let buildPath = pkgPath.appending(components: [".build", try UserToolchain.default.targetTriple.platformBuildPathComponent] + buildSystem.binPathSuffixes(for: configuration)) + let buildPath = try pkgPath.appending(components: buildSystem.binPath(for: configuration)) try await executeSwiftBuild( pkgPath, configuration: configuration, diff --git a/Tests/FunctionalTests/ToolsVersionTests.swift b/Tests/FunctionalTests/ToolsVersionTests.swift index 8d611e0f22a..6bc5d00f677 100644 --- a/Tests/FunctionalTests/ToolsVersionTests.swift +++ b/Tests/FunctionalTests/ToolsVersionTests.swift @@ -124,7 +124,8 @@ struct ToolsVersionTests { configuration: configuration, buildSystem: buildSystem, ) - let exe: String = primaryPath.appending(components: [".build", try UserToolchain.default.targetTriple.platformBuildPathComponent] + buildSystem.binPathSuffixes(for: configuration) + ["Primary"]).pathString + let binPath = try primaryPath.appending(components: buildSystem.binPath(for: configuration)) + let exe: String = binPath.appending(components: "Primary").pathString // v1 should get selected because v1.0.1 depends on a (way) higher set of tools. try await withKnownIssue { let executableActualOutput = try await AsyncProcess.checkNonZeroExit(args: exe).spm_chomp() From 477f48825b5b9a17114100d99740072869723957 Mon Sep 17 00:00:00 2001 From: Owen Voorhees Date: Tue, 2 Sep 2025 21:47:07 -0700 Subject: [PATCH 104/225] Stage in fix for toolsetRunner test (#9086) Ensure this test doesn't start failing when I merge https://github.com/swiftlang/swift-build/pull/765 --- Tests/CommandsTests/TestCommandTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/CommandsTests/TestCommandTests.swift b/Tests/CommandsTests/TestCommandTests.swift index dadf7dc3282..452526c9b12 100644 --- a/Tests/CommandsTests/TestCommandTests.swift +++ b/Tests/CommandsTests/TestCommandTests.swift @@ -124,6 +124,7 @@ struct TestCommandTests { ) async throws { try await withKnownIssue( "Windows: Driver threw unable to load output file map", + isIntermittent: true ) { try await fixture(name: "Miscellaneous/EchoExecutable") { fixturePath in #if os(Windows) From 5fb029c10ec8793af45d8078fc2842aaf711c5b5 Mon Sep 17 00:00:00 2001 From: Joseph Heck Date: Wed, 3 Sep 2025 10:18:21 -0700 Subject: [PATCH 105/225] initial, simple SDK documentation (#9004) Provides some basic detail for Swift cross-compilation SDKs, referencing the installation links on swift.org, the evolution proposal, and the SDK generator code. resolves #8864, to finalize the documentation of the CLI commands --- .../Documentation.docc/SwiftSDKCommands.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/PackageManagerDocs/Documentation.docc/SwiftSDKCommands.md b/Sources/PackageManagerDocs/Documentation.docc/SwiftSDKCommands.md index 5c8f695cd3c..ac930046468 100644 --- a/Sources/PackageManagerDocs/Documentation.docc/SwiftSDKCommands.md +++ b/Sources/PackageManagerDocs/Documentation.docc/SwiftSDKCommands.md @@ -9,9 +9,15 @@ Perform operations on Swift SDKs. ## Overview -Overview of package manager commands here... +By default, Swift Package Manager compiles code for the host platform on which you run it. +Swift 6.1 introduced SDKs (through +[SE-0387](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0387-cross-compilation-destinations.md)) +to support cross-compilation. - +SDKs are tightly coupled with the toolchain used to create them. +Supported SDKs are distributed by the Swift project with links on the [installation page](https://www.swift.org/install/) for macOS and Linux, and included in the distribution for Windows. + +Additionally, the Swift project provides the tooling repository [swift-sdk-generator](https://github.com/swiftlang/swift-sdk-generator) that you can use to create a custom SDK for your preferred platform. ## Topics From 0dcdd8b05eed6db1f97f2f4f42fd496553300f98 Mon Sep 17 00:00:00 2001 From: Joseph Heck Date: Wed, 3 Sep 2025 11:01:33 -0700 Subject: [PATCH 106/225] Traits docs (#9048) Adds documentation for the Swift 6.1 traits feature ### Motivation: - Show how to consume packages that provide traits - Show how to present traits from your package(s) - Updating PackageDescription API to use slightly more readable content related to traits. ### Modifications: - updated PackageDescription API around traits - added curation (organization) for Traits and Package/Dependency/Traits - extended `Using Dependencies` article to touch on traits and how to consume packages that provide them - added an article to share patterns to use when providing a traits from your own package ### Result: Updated content and one additional article in the central documentation for Swift Package Manager that illustrates how to consume and provide packages with traits. --------- Co-authored-by: Franz Busch Co-authored-by: Bri Peticca --- .../PackageDependency.swift | 118 +++++++++------ .../PackageDependencyTrait.swift | 22 +-- .../Curation/Dependency-Trait.md | 19 +++ .../Curation/Dependency.md | 6 +- .../Curation/Package.md | 3 +- .../PackageDescription.docc/Curation/Trait.md | 20 +++ .../PackageDescription.swift | 4 +- Sources/PackageDescription/Trait.swift | 58 ++++++- .../Documentation.docc/AddingDependencies.md | 52 ++++++- .../Dependencies/PackageTraits.md | 141 ++++++++++++++++++ 10 files changed, 367 insertions(+), 76 deletions(-) create mode 100644 Sources/PackageDescription/PackageDescription.docc/Curation/Dependency-Trait.md create mode 100644 Sources/PackageDescription/PackageDescription.docc/Curation/Trait.md create mode 100644 Sources/PackageManagerDocs/Documentation.docc/Dependencies/PackageTraits.md diff --git a/Sources/PackageDescription/PackageDependency.swift b/Sources/PackageDescription/PackageDependency.swift index a71e44dffbc..06cf9912343 100644 --- a/Sources/PackageDescription/PackageDependency.swift +++ b/Sources/PackageDescription/PackageDependency.swift @@ -201,7 +201,9 @@ extension Package { // MARK: - file system extension Package.Dependency { - /// Adds a dependency to a package located at the given path. + /// Adds a local dependency to a package located at the path you provide. + /// + /// If the package you depend on defines traits, the package manager uses the dependency with its default set of traits. /// /// The Swift Package Manager uses the package dependency as-is /// and does not perform any source control access. Local package dependencies @@ -217,7 +219,7 @@ extension Package.Dependency { return .init(name: nil, path: path, traits: nil) } - /// Adds a dependency to a package located at the given path. + /// Adds a local dependency to a package located at the path and with an optional set of traits you provide. /// /// The Swift Package Manager uses the package dependency as-is /// and does not perform any source control access. Local package dependencies @@ -225,7 +227,7 @@ extension Package.Dependency { /// on multiple tightly coupled packages. /// /// - Parameter path: The file system path to the package. - /// - Parameter traits: The trait configuration of this dependency. Defaults to enabling the default traits. + /// - Parameter traits: The trait configuration of this dependency. The default value enables the default traits of the package. /// /// - Returns: A package dependency. @available(_PackageDescription, introduced: 6.1) @@ -236,7 +238,9 @@ extension Package.Dependency { return .init(name: nil, path: path, traits: traits) } - /// Adds a dependency to a package located at the given path on the filesystem. + /// Adds a local dependency to a named package located at the path you provide. + /// + /// If the package you depend on defines traits, the package manager uses the dependency with its default set of traits. /// /// Swift Package Manager uses the package dependency as-is and doesn't perform any source /// control access. Local package dependencies are especially useful during @@ -256,7 +260,7 @@ extension Package.Dependency { return .init(name: name, path: path, traits: nil) } - /// Adds a dependency to a package located at the given path on the filesystem. + /// Adds a local dependency to a named package located at the path and with an optional set of traits you provide. /// /// Swift Package Manager uses the package dependency as-is and doesn't perform any source /// control access. Local package dependencies are especially useful during @@ -266,7 +270,7 @@ extension Package.Dependency { /// - Parameters: /// - name: The name of the Swift package. /// - path: The file system path to the package. - /// - traits: The trait configuration of this dependency. Defaults to enabling the default traits. + /// - traits: The trait configuration of this dependency. The default value enables the default traits of the package. /// /// - Returns: A package dependency. @available(_PackageDescription, introduced: 6.1) @@ -282,7 +286,7 @@ extension Package.Dependency { // MARK: - source control extension Package.Dependency { - /// Adds a package dependency that uses the version requirement, starting with the given minimum version, + /// Adds a remote package dependency with a version requirement, starting with the given minimum version, /// going up to the next major version. /// /// This is the recommended way to specify a remote package dependency. @@ -299,6 +303,8 @@ extension Package.Dependency { ///.package(url: "https://example.com/example-package.git", from: "1.2.3"), ///``` /// + /// If the package you depend on defines traits, the package manager uses the dependency with its default set of traits. + /// /// - Parameters: /// - url: The valid Git URL of the package. /// - version: The minimum version requirement. @@ -311,7 +317,7 @@ extension Package.Dependency { return .package(url: url, .upToNextMajor(from: version)) } - /// Adds a package dependency that uses the version requirement, starting with the given minimum version, + /// Adds a remote package dependency with a version requirement, starting with the given minimum version, /// going up to the next major version. /// /// This is the recommended way to specify a remote package dependency. @@ -331,7 +337,7 @@ extension Package.Dependency { /// - Parameters: /// - url: The valid Git URL of the package. /// - version: The minimum version requirement. - /// - traits: The trait configuration of this dependency. Defaults to enabling the default traits. + /// - traits: The trait configuration of this dependency. The default value enables the default traits of the package. /// /// - Returns: A `Package.Dependency` instance. @available(_PackageDescription, introduced: 6.1) @@ -343,7 +349,7 @@ extension Package.Dependency { return .package(url: url, .upToNextMajor(from: version), traits: traits) } - /// Adds a package dependency that uses the version requirement, starting + /// Adds a remote package dependency with a version requirement, starting /// with the given minimum version, going up to the next major version. /// /// This is the recommended way to specify a remote package dependency. It @@ -378,12 +384,14 @@ extension Package.Dependency { return .package(name: name, url: url, .upToNextMajor(from: version)) } - /// Adds a remote package dependency given a branch requirement. + /// Adds a remote package dependency with a branch requirement you provide. /// ///```swift /// .package(url: "https://example.com/example-package.git", branch: "main"), /// ``` /// + /// If the package you depend on defines traits, the package manager uses the dependency with its default set of traits. + /// /// - Parameters: /// - url: The valid Git URL of the package. /// - branch: A dependency requirement. See static methods on ``Requirement-swift.enum`` for available options. @@ -397,7 +405,7 @@ extension Package.Dependency { return .package(url: url, requirement: .branch(branch)) } - /// Adds a remote package dependency given a branch requirement. + /// Adds a remote package dependency with a branch requirement you provide. /// ///```swift /// .package(url: "https://example.com/example-package.git", branch: "main"), @@ -406,7 +414,7 @@ extension Package.Dependency { /// - Parameters: /// - url: The valid Git URL of the package. /// - branch: A dependency requirement. See static methods on ``Requirement-swift.enum`` for available options. - /// - traits: The trait configuration of this dependency. Defaults to enabling the default traits. + /// - traits: The trait configuration of this dependency. The default value enables the default traits of the package. /// /// - Returns: A `Package.Dependency` instance. @available(_PackageDescription, introduced: 6.1) @@ -418,7 +426,7 @@ extension Package.Dependency { return .package(url: url, requirement: .branch(branch), traits: traits) } - /// Adds a remote package dependency given a branch requirement. + /// Adds a remote package dependency with a branch requirement you provide. /// /// ```swift /// .package(url: "https://example.com/example-package.git", branch: "main"), @@ -439,12 +447,14 @@ extension Package.Dependency { return .package(name: name, url: url, requirement: .branch(branch)) } - /// Adds a remote package dependency given a revision requirement. + /// Adds a remote package dependency with a specific revision requirement. /// /// ```swift /// .package(url: "https://example.com/example-package.git", revision: "aa681bd6c61e22df0fd808044a886fc4a7ed3a65"), /// ``` /// + /// If the package you depend on defines traits, the package manager uses the dependency with its default set of traits. + /// /// - Parameters: /// - url: The valid Git URL of the package. /// - revision: A dependency requirement. See static methods on ``Requirement-swift.enum`` for available options. @@ -458,7 +468,7 @@ extension Package.Dependency { return .package(url: url, requirement: .revision(revision)) } - /// Adds a remote package dependency given a revision requirement. + /// Adds a remote package dependency with a specific revision requirement. /// /// ```swift /// .package(url: "https://example.com/example-package.git", revision: "aa681bd6c61e22df0fd808044a886fc4a7ed3a65"), @@ -467,7 +477,7 @@ extension Package.Dependency { /// - Parameters: /// - url: The valid Git URL of the package. /// - revision: A dependency requirement. See static methods on ``Requirement-swift.enum`` for available options. - /// - traits: The trait configuration of this dependency. Defaults to enabling the default traits. + /// - traits: The trait configuration of this dependency. The default value enables the default traits of the package. /// /// - Returns: A `Package.Dependency` instance. @available(_PackageDescription, introduced: 6.1) @@ -479,7 +489,7 @@ extension Package.Dependency { return .package(url: url, requirement: .revision(revision), traits: traits) } - /// Adds a remote package dependency given a revision requirement. + /// Adds a remote package dependency with a specific revision requirement. /// /// ```swift /// .package(url: "https://example.com/example-package.git", revision: "aa681bd6c61e22df0fd808044a886fc4a7ed3a65"), @@ -500,7 +510,7 @@ extension Package.Dependency { return .package(name: name, url: url, requirement: .revision(revision)) } - /// Adds a package dependency starting with a specific minimum version, up to + /// Adds a remote package dependency starting with a specific minimum version, up to /// but not including a specified maximum version. /// /// The following example allows the Swift Package Manager to pick @@ -510,6 +520,8 @@ extension Package.Dependency { /// .package(url: "https://example.com/example-package.git", "1.2.3"..<"1.2.6"), /// ``` /// + /// If the package you depend on defines traits, the package manager uses the dependency with its default set of traits. + /// /// - Parameters: /// - url: The valid Git URL of the package. /// - range: The custom version range requirement. @@ -522,7 +534,7 @@ extension Package.Dependency { return .package(name: nil, url: url, requirement: .range(range)) } - /// Adds a package dependency starting with a specific minimum version, up to + /// Adds a remote package dependency starting with a specific minimum version, up to /// but not including a specified maximum version. /// /// The following example allows the Swift Package Manager to pick @@ -535,7 +547,7 @@ extension Package.Dependency { /// - Parameters: /// - url: The valid Git URL of the package. /// - range: The custom version range requirement. - /// - traits: The trait configuration of this dependency. Defaults to enabling the default traits. + /// - traits: The trait configuration of this dependency. The default value enables the default traits of the package. /// /// - Returns: A `Package.Dependency` instance. @available(_PackageDescription, introduced: 6.1) @@ -547,7 +559,7 @@ extension Package.Dependency { return .package(name: nil, url: url, requirement: .range(range), traits: traits) } - /// Adds a package dependency starting with a specific minimum version, up to + /// Adds a remote package dependency starting with a specific minimum version, up to /// but not including a specified maximum version. /// /// The following example allows the Swift Package Manager to pick @@ -557,6 +569,8 @@ extension Package.Dependency { /// .package(url: "https://example.com/example-package.git", "1.2.3"..<"1.2.6"), /// ``` /// + /// If the package you depend on defines traits, the package manager uses the dependency with its default set of traits. + /// /// - Parameters: /// - name: The name of the package, or `nil` to deduce it from the URL. /// - url: The valid Git URL of the package. @@ -572,7 +586,7 @@ extension Package.Dependency { return .package(name: name, url: url, requirement: .range(range)) } - /// Adds a package dependency starting with a specific minimum version, going + /// Adds a remote package dependency starting with a specific minimum version, going /// up to and including a specific maximum version. /// /// The following example allows the Swift Package Manager to pick @@ -582,6 +596,8 @@ extension Package.Dependency { /// .package(url: "https://example.com/example-package.git", "1.2.3"..."1.2.6"), /// ``` /// + /// If the package you depend on defines traits, the package manager uses the dependency with its default set of traits. + /// /// - Parameters: /// - url: The valid Git URL of the package. /// - range: The closed version range requirement. @@ -594,7 +610,7 @@ extension Package.Dependency { return .package(name: nil, url: url, closedRange: range) } - /// Adds a package dependency starting with a specific minimum version, going + /// Adds a remote package dependency starting with a specific minimum version, going /// up to and including a specific maximum version. /// /// The following example allows the Swift Package Manager to pick @@ -607,7 +623,7 @@ extension Package.Dependency { /// - Parameters: /// - url: The valid Git URL of the package. /// - range: The closed version range requirement. - /// - traits: The trait configuration of this dependency. Defaults to enabling the default traits. + /// - traits: The trait configuration of this dependency. The default value enables the default traits of the package. /// /// - Returns: A `Package.Dependency` instance. @available(_PackageDescription, introduced: 6.1) @@ -619,7 +635,7 @@ extension Package.Dependency { return .package(name: nil, url: url, closedRange: range, traits: traits) } - /// Adds a package dependency starting with a specific minimum version, going + /// Adds a remote package dependency starting with a specific minimum version, going /// up to and including a specific maximum version. /// /// The following example allows the Swift Package Manager to pick @@ -643,6 +659,8 @@ extension Package.Dependency { /// .package(url: "https://example.com/example-package.git", .upToNextMinor(from: "1.0.0"), /// ``` /// + /// If the package you depend on defines traits, the package manager uses the dependency with its default set of traits. + /// /// - Parameters: /// - name: The name of the package, or `nil` to deduce it from the URL. /// - url: The valid Git URL of the package. @@ -658,7 +676,7 @@ extension Package.Dependency { return .package(name: name, url: url, closedRange: range) } - /// Adds a package dependency starting with a specific minimum version, going + /// Adds a remote package dependency starting with a specific minimum version, going /// up to and including a specific maximum version. /// /// The following example allows the Swift Package Manager to pick @@ -668,6 +686,8 @@ extension Package.Dependency { /// .package(url: "https://example.com/example-package.git", "1.2.3"..."1.2.6"), /// ``` /// + /// If the package you depend on defines traits, the package manager uses the dependency with its default set of traits. + /// /// - Parameters: /// - name: The name of the package, or `nil` to deduce it from the URL. /// - url: The valid Git URL of the package. @@ -688,7 +708,7 @@ extension Package.Dependency { return .package(name: name, url: url, requirement: .range(closedRange.lowerBound ..< upperBound)) } - /// Adds a package dependency starting with a specific minimum version, going + /// Adds a remote package dependency starting with a specific minimum version, going /// up to and including a specific maximum version. /// /// The following example allows the Swift Package Manager to pick @@ -702,7 +722,7 @@ extension Package.Dependency { /// - name: The name of the package, or `nil` to deduce it from the URL. /// - url: The valid Git URL of the package. /// - range: The closed version range requirement. - /// - traits: The trait configuration of this dependency. Defaults to enabling the default traits. + /// - traits: The trait configuration of this dependency. The default value enables the default traits of the package. /// /// - Returns: A `Package.Dependency` instance. private static func package( @@ -727,7 +747,7 @@ extension Package.Dependency { ) } - /// Adds a package dependency that uses the exact version requirement. + /// Adds a remote package dependency that uses an exact version requirement. /// /// Specifying exact version requirements are not recommended as /// they can cause conflicts in your dependency graph when other packages depend on this package. @@ -740,6 +760,8 @@ extension Package.Dependency { /// .package(url: "https://example.com/example-package.git", exact: "1.2.3"), /// ``` /// + /// If the package you depend on defines traits, the package manager uses the dependency with its default set of traits. + /// /// - Parameters: /// - url: The valid Git URL of the package. /// - version: The exact version of the dependency for this requirement. @@ -753,7 +775,7 @@ extension Package.Dependency { return .package(url: url, requirement: .exact(version)) } - /// Adds a package dependency that uses the exact version requirement. + /// Adds a remote package dependency that uses an exact version requirement. /// /// Specifying exact version requirements are not recommended as /// they can cause conflicts in your dependency graph when other packages depend on this package. @@ -769,7 +791,7 @@ extension Package.Dependency { /// - Parameters: /// - url: The valid Git URL of the package. /// - version: The exact version of the dependency for this requirement. - /// - traits: The trait configuration of this dependency. Defaults to enabling the default traits. + /// - traits: The trait configuration of this dependency. The default value enables the default traits of the package. /// /// - Returns: A `Package.Dependency` instance. @available(_PackageDescription, introduced: 6.1) @@ -838,7 +860,7 @@ extension Package.Dependency { // MARK: - registry extension Package.Dependency { - /// Adds a package dependency that uses the version requirement, starting with the given minimum version, + /// Adds a remote package dependency that uses the version requirement, starting with the given minimum version, /// going up to the next major version. /// /// This is the recommended way to specify a remote package dependency. @@ -855,6 +877,8 @@ extension Package.Dependency { /// .package(id: "scope.name", from: "1.2.3"), /// ``` /// + /// If the package you depend on defines traits, the package manager uses the dependency with its default set of traits. + /// /// - Parameters: /// - id: The identity of the package. /// - version: The minimum version requirement. @@ -868,7 +892,7 @@ extension Package.Dependency { return .package(id: id, .upToNextMajor(from: version)) } - /// Adds a package dependency that uses the version requirement, starting with the given minimum version, + /// Adds a remote package dependency that uses the version requirement, starting with the given minimum version, /// going up to the next major version. /// /// This is the recommended way to specify a remote package dependency. @@ -888,7 +912,7 @@ extension Package.Dependency { /// - Parameters: /// - id: The identity of the package. /// - version: The minimum version requirement. - /// - traits: The trait configuration of this dependency. Defaults to enabling the default traits. + /// - traits: The trait configuration of this dependency. The default value enables the default traits of the package. /// /// - Returns: A `Package.Dependency` instance. @available(_PackageDescription, introduced: 6.1) @@ -900,7 +924,7 @@ extension Package.Dependency { return .package(id: id, .upToNextMajor(from: version), traits: traits) } - /// Adds a package dependency that uses the exact version requirement. + /// Adds a remote package dependency with an exact version requirement. /// /// Specifying exact version requirements are not recommended as /// they can cause conflicts in your dependency graph when multiple other packages depend on a package. @@ -913,6 +937,8 @@ extension Package.Dependency { /// .package(id: "scope.name", exact: "1.2.3"), /// ``` /// + /// If the package you depend on defines traits, the package manager uses the dependency with its default set of traits. + /// /// - Parameters: /// - id: The identity of the package. /// - version: The exact version of the dependency for this requirement. @@ -926,7 +952,7 @@ extension Package.Dependency { return .package(id: id, requirement: .exact(version), traits: nil) } - /// Adds a package dependency that uses the exact version requirement. + /// Adds a remote package dependency with an exact version requirement. /// /// Specifying exact version requirements are not recommended as /// they can cause conflicts in your dependency graph when multiple other packages depend on a package. @@ -942,7 +968,7 @@ extension Package.Dependency { /// - Parameters: /// - id: The identity of the package. /// - version: The exact version of the dependency for this requirement. - /// - traits: The trait configuration of this dependency. Defaults to enabling the default traits. + /// - traits: The trait configuration of this dependency. The default value enables the default traits of the package. /// /// - Returns: A `Package.Dependency` instance. @available(_PackageDescription, introduced: 6.1) @@ -954,7 +980,7 @@ extension Package.Dependency { return .package(id: id, requirement: .exact(version), traits: traits) } - /// Adds a package dependency starting with a specific minimum version, up to + /// Adds a remote package dependency starting with a specific minimum version, up to /// but not including a specified maximum version. /// /// The following example allows the Swift Package Manager to pick @@ -978,6 +1004,8 @@ extension Package.Dependency { /// .package(id: "scope.name", .upToNextMinor(from: "1.0.0"), /// ``` /// + /// If the package you depend on defines traits, the package manager uses the dependency with its default set of traits. + /// /// - Parameters: /// - id: The identity of the package. /// - range: The custom version range requirement. @@ -991,7 +1019,7 @@ extension Package.Dependency { return .package(id: id, requirement: .range(range), traits: nil) } - /// Adds a package dependency starting with a specific minimum version, up to + /// Adds a remote package dependency starting with a specific minimum version, up to /// but not including a specified maximum version. /// /// The following example allows the Swift Package Manager to pick @@ -1018,7 +1046,7 @@ extension Package.Dependency { /// - Parameters: /// - id: The identity of the package. /// - range: The custom version range requirement. - /// - traits: The trait configuration of this dependency. Defaults to enabling the default traits. + /// - traits: The trait configuration of this dependency. The default value enables the default traits of the package. /// /// - Returns: A `Package.Dependency` instance. @available(_PackageDescription, introduced: 6.1) @@ -1030,7 +1058,7 @@ extension Package.Dependency { return .package(id: id, requirement: .range(range), traits: traits) } - /// Adds a package dependency starting with a specific minimum version, going + /// Adds a remote package dependency starting with a specific minimum version, going /// up to and including a specific maximum version. /// /// The following example allows the Swift Package Manager to pick @@ -1039,6 +1067,8 @@ extension Package.Dependency { /// ```swift /// .package(id: "scope.name", "1.2.3"..."1.2.6"), /// ``` + /// + /// If the package you depend on defines traits, the package manager uses the dependency with its default set of traits. /// /// - Parameters: /// - id: The identity of the package. @@ -1059,7 +1089,7 @@ extension Package.Dependency { return .package(id: id, range.lowerBound ..< upperBound) } - /// Adds a package dependency starting with a specific minimum version, going + /// Adds a remote package dependency starting with a specific minimum version, going /// up to and including a specific maximum version. /// /// The following example allows the Swift Package Manager to pick @@ -1072,7 +1102,7 @@ extension Package.Dependency { /// - Parameters: /// - id: The identity of the package. /// - range: The closed version range requirement. - /// - traits: The trait configuration of this dependency. Defaults to enabling the default traits. + /// - traits: The trait configuration of this dependency. The default value enables the default traits of the package. /// /// - Returns: A `Package.Dependency` instance. @available(_PackageDescription, introduced: 6.1) diff --git a/Sources/PackageDescription/PackageDependencyTrait.swift b/Sources/PackageDescription/PackageDependencyTrait.swift index a35d3eefd26..188667da441 100644 --- a/Sources/PackageDescription/PackageDependencyTrait.swift +++ b/Sources/PackageDescription/PackageDependencyTrait.swift @@ -11,21 +11,22 @@ //===----------------------------------------------------------------------===// extension Package.Dependency { - /// A struct representing an enabled trait of a dependency. + /// An enabled trait of a dependency. @available(_PackageDescription, introduced: 6.1) public struct Trait: Hashable, Sendable, ExpressibleByStringLiteral { - /// Enables all default traits of a package. + /// Enables all default traits of the dependency. public static let defaults = Self.init(name: "default") - /// A condition that limits the application of a dependencies trait. + /// A condition that limits the application of a trait for a dependency. public struct Condition: Hashable, Sendable { - /// The set of traits of this package that enable the dependencie's trait. + /// The set of traits that enable the dependencies trait. let traits: Set? /// Creates a package dependency trait condition. /// - /// - Parameter traits: The set of traits that enable the dependencies trait. If any of the traits are enabled on this package - /// the dependencies trait will be enabled. + /// If the depending package enables any of the traits you provide, the package manager enables the dependency to which this condition applies. + /// + /// - Parameter traits: The set of traits that enable the dependencies trait. public static func when( traits: Set ) -> Self? { @@ -36,10 +37,10 @@ extension Package.Dependency { /// The name of the enabled trait. public var name: String - /// The condition under which the trait is enabled. + /// The condition under which the package manager enables the dependency. public var condition: Condition? - /// Initializes a new enabled trait. + /// Creates a new enabled trait. /// /// - Parameters: /// - name: The name of the enabled trait. @@ -52,11 +53,14 @@ extension Package.Dependency { self.condition = condition } + /// Creates a new enabled trait. + /// + /// - Parameter value: The name of the enabled trait. public init(stringLiteral value: StringLiteralType) { self.init(name: value) } - /// Initializes a new enabled trait. + /// Creates a new enabled trait. /// /// - Parameters: /// - name: The name of the enabled trait. diff --git a/Sources/PackageDescription/PackageDescription.docc/Curation/Dependency-Trait.md b/Sources/PackageDescription/PackageDescription.docc/Curation/Dependency-Trait.md new file mode 100644 index 00000000000..da40fbcedb0 --- /dev/null +++ b/Sources/PackageDescription/PackageDescription.docc/Curation/Dependency-Trait.md @@ -0,0 +1,19 @@ +# ``PackageDescription/Package/Dependency/Trait`` + +## Topics + +### Declaring a Dependency Trait + +- ``trait(name:condition:)`` +- ``defaults`` + +### Creating a Dependency Trait + +- ``init(name:condition:)`` +- ``init(stringLiteral:)`` +- ``Condition`` + +### Inspecting a Dependency Trait + +- ``name`` +- ``condition`` diff --git a/Sources/PackageDescription/PackageDescription.docc/Curation/Dependency.md b/Sources/PackageDescription/PackageDescription.docc/Curation/Dependency.md index d400176b3e5..bf7eddaab71 100644 --- a/Sources/PackageDescription/PackageDescription.docc/Curation/Dependency.md +++ b/Sources/PackageDescription/PackageDescription.docc/Curation/Dependency.md @@ -41,16 +41,12 @@ - ``Trait`` - ``RegistryRequirement`` - ``SourceControlRequirement`` -- ``requirement-swift.property`` -- ``Requirement-swift.enum`` ### Describing a Package Dependency - ``kind-swift.property`` - ``Kind`` - ``Version`` -- ``name`` -- ``url`` ### Deprecated methods @@ -63,3 +59,5 @@ - ``package(url:_:)-(_,Package.Dependency.Requirement)`` - ``name`` - ``url`` +- ``requirement-swift.property`` +- ``Requirement-swift.enum`` diff --git a/Sources/PackageDescription/PackageDescription.docc/Curation/Package.md b/Sources/PackageDescription/PackageDescription.docc/Curation/Package.md index a4e52b12a81..b92aa0e3e5a 100644 --- a/Sources/PackageDescription/PackageDescription.docc/Curation/Package.md +++ b/Sources/PackageDescription/PackageDescription.docc/Curation/Package.md @@ -6,11 +6,10 @@ - ``Package/init(name:defaultLocalization:platforms:pkgConfig:providers:products:dependencies:targets:swiftLanguageModes:cLanguageStandard:cxxLanguageStandard:)`` - ``Package/init(name:defaultLocalization:platforms:pkgConfig:providers:products:traits:dependencies:targets:swiftLanguageModes:cLanguageStandard:cxxLanguageStandard:)`` -- ``Package/init(name:defaultLocalization:platforms:pkgConfig:providers:products:dependencies:targets:swiftLanguageVersions:cLanguageStandard:cxxLanguageStandard:)`` - ``Package/init(name:platforms:pkgConfig:providers:products:dependencies:targets:swiftLanguageVersions:cLanguageStandard:cxxLanguageStandard:)`` - ``Package/init(name:pkgConfig:providers:products:dependencies:targets:swiftLanguageVersions:cLanguageStandard:cxxLanguageStandard:)-(_,_,_,_,_,_,[Int]?,_,_)`` - ``Package/init(name:pkgConfig:providers:products:dependencies:targets:swiftLanguageVersions:cLanguageStandard:cxxLanguageStandard:)-(_,_,_,_,_,_,[SwiftVersion]?,_,_)`` - +- ``Package/init(name:defaultLocalization:platforms:pkgConfig:providers:products:dependencies:targets:swiftLanguageVersions:cLanguageStandard:cxxLanguageStandard:)`` ### Naming the Package diff --git a/Sources/PackageDescription/PackageDescription.docc/Curation/Trait.md b/Sources/PackageDescription/PackageDescription.docc/Curation/Trait.md new file mode 100644 index 00000000000..b4677af9fc4 --- /dev/null +++ b/Sources/PackageDescription/PackageDescription.docc/Curation/Trait.md @@ -0,0 +1,20 @@ +# ``PackageDescription/Trait`` + +## Topics + +### Declaring Traits + +- ``trait(name:description:enabledTraits:)`` +- ``default(enabledTraits:)`` + +### Creating Traits + +- ``init(name:description:enabledTraits:)`` +- ``init(stringLiteral:)`` + +### Inspecting Traits + +- ``name`` +- ``description`` +- ``enabledTraits`` + diff --git a/Sources/PackageDescription/PackageDescription.swift b/Sources/PackageDescription/PackageDescription.swift index 88234087637..60e2d769583 100644 --- a/Sources/PackageDescription/PackageDescription.swift +++ b/Sources/PackageDescription/PackageDescription.swift @@ -97,7 +97,7 @@ public final class Package { /// The list of products that this package vends and that clients can use. public var products: [Product] - /// The set of traits of this package. + /// The set of traits this package provides. @available(_PackageDescription, introduced: 6.1) public var traits: Set @@ -344,7 +344,7 @@ public final class Package { /// `.pc` file to get the additional flags required for a system target. /// - providers: The package providers for a system target. /// - products: The list of products that this package makes available for clients to use. - /// - traits: The set of traits of this package. + /// - traits: The set of traits this package provides. /// - dependencies: The list of package dependencies. /// - targets: The list of targets that are part of this package. /// - swiftLanguageModes: The list of Swift language modes with which this package is compatible. diff --git a/Sources/PackageDescription/Trait.swift b/Sources/PackageDescription/Trait.swift index 449e0eaeaad..bb1ac1a6948 100644 --- a/Sources/PackageDescription/Trait.swift +++ b/Sources/PackageDescription/Trait.swift @@ -10,11 +10,50 @@ // //===----------------------------------------------------------------------===// -/// A struct representing a package's trait. +/// A package trait. /// -/// Traits can be used for expressing conditional compilation and optional dependencies. +/// A trait is a package feature that expresses conditional compilation and potentially optional dependencies. +/// It is typically used to expose additional or extended API for the package. /// -/// - Important: Traits must be strictly additive and enabling a trait **must not** remove API. +/// When you define a trait on a package, the package manager uses the name of that trait as a conditional block for the package's code. +/// Use the conditional block to enable imports or code paths for that trait. +/// For example, a trait with the canonical name `MyTrait` allows you to use the name as a conditional block: +/// +/// ```swift +/// #if MyTrait +/// // additional imports or APIs that MyTrait enables +/// #endif // MyTrait +/// ``` +/// +/// - Important: Traits must be strictly additive. Enabling a trait **must not** remove API. +/// +/// If your conditional code requires a dependency that you want to enable only when the trait is enabled, +/// add a conditional declaration to the target dependencies, +/// then include the import statement within the conditional block. +/// The following example illustrates enabling the dependency `MyDependency` when the trait `Trait1` is enabled: +/// +/// ```swift +/// targets: [ +/// .target( +/// name: "MyTarget", +/// dependencies: [ +/// .product( +/// name: "MyAPI", +/// package: "MyDependency", +/// condition: .when(traits: ["Trait1"]) +/// ) +/// ] +/// ), +/// ] +/// ``` +/// +/// Coordinate a declaration like the example above with code that imports the dependency in a conditional block: +/// +/// ```swift +/// #if Trait1 +/// import MyAPI +/// #endif // Trait1 +/// ``` @available(_PackageDescription, introduced: 6.1) public struct Trait: Hashable, ExpressibleByStringLiteral { /// Declares the default traits for this package. @@ -28,25 +67,26 @@ public struct Trait: Hashable, ExpressibleByStringLiteral { /// The trait's canonical name. /// - /// This is used when enabling the trait or when referring to it from other modifiers in the manifest. + /// Use the trait's name to enable the trait or when referring to it from other modifiers in the manifest. + /// The trait's name also defines the conditional block that the compiler supports when the trait is active. /// /// The following rules are enforced on trait names: /// - The first character must be a [Unicode XID start character](https://unicode.org/reports/tr31/#Figure_Code_Point_Categories_for_Identifier_Parsing) /// (most letters), a digit, or `_`. /// - Subsequent characters must be a [Unicode XID continue character](https://unicode.org/reports/tr31/#Figure_Code_Point_Categories_for_Identifier_Parsing) /// (a digit, `_`, or most letters), `-`, or `+`. - /// - `default` and `defaults` (in any letter casing combination) are not allowed as trait names to avoid confusion with default traits. + /// - The names `default` and `defaults` (in any letter casing combination) aren't allowed as trait names to avoid confusion with default traits. public var name: String /// The trait's description. /// - /// Use this to explain what functionality this trait enables. + /// Use the description to explain the additional functionality that the trait enables. public var description: String? /// A set of other traits of this package that this trait enables. public var enabledTraits: Set - /// Initializes a new trait. + /// Creates a trait with a name, a description, and set of additional traits it enables. /// /// - Parameters: /// - name: The trait's canonical name. @@ -62,11 +102,13 @@ public struct Trait: Hashable, ExpressibleByStringLiteral { self.enabledTraits = enabledTraits } + /// Creates a trait with the name you provide. + /// - Parameter value: The trait's canonical name. public init(stringLiteral value: StringLiteralType) { self.init(name: value) } - /// Initializes a new trait. + /// Creates a trait with a name, a description, and set of additional traits it enables. /// /// - Parameters: /// - name: The trait's canonical name. diff --git a/Sources/PackageManagerDocs/Documentation.docc/AddingDependencies.md b/Sources/PackageManagerDocs/Documentation.docc/AddingDependencies.md index 9a8bbc14164..b1a667a3ef8 100644 --- a/Sources/PackageManagerDocs/Documentation.docc/AddingDependencies.md +++ b/Sources/PackageManagerDocs/Documentation.docc/AddingDependencies.md @@ -1,6 +1,6 @@ # Adding dependencies to a Swift package -Use other swift packages, system libraries, or binary dependencies in your package. +Use other Swift packages, system libraries, or binary dependencies in your package. ## Overview @@ -8,7 +8,7 @@ To depend on another Swift package, define a dependency and the requirements for A remote dependency requires a location, represented by a URL, and a requirement on the versions the package manager may use. -The following example illustrates a package that depends on [PlayingCard](https://github.com/apple/example-package-playingcard), using `from` to require at least version `3.0.4`, and allow any other version up to the next major version that is available at the time of dependency resolution. +The following example illustrates a package that depends on [PlayingCard](https://github.com/apple/example-package-playingcard), using `from` to require at least version `4.0.0`, and allow any other version up to the next major version that is available at the time of dependency resolution. It then uses the product `PlayingCard` as a dependency for the target `MyPackage`: ```swift @@ -19,7 +19,7 @@ let package = Package( name: "MyPackage", dependencies: [ .package(url: "https://github.com/apple/example-package-playingcard.git", - from: "3.0.4"), + from: "4.0.0"), ], targets: [ .target( @@ -43,15 +43,52 @@ For more information on resolving package versions, see Note: tags for package versions should include all three components of a semantic version: major, minor, and patch. +> Note: tags for package versions should include all three components of a semantic version: major, minor, and patch. > Tags that only include one or two of those components are not interpreted as semantic versions. Use the version requirement when you declare the dependency to limit what the package manager can choose. The version requirement can be a range of possible semantic versions, a specific semantic version, a branch name, or a commit hash. -The API reference documentation for [Package.Dependency](https://developer.apple.com/documentation/packagedescription/package/dependency) defines the methods to use. +The API reference documentation for [Package.Dependency](https://docs.swift.org/swiftpm/documentation/packagedescription/package/dependency) defines the methods to use. + +### Packages with Traits + +Traits, introduced with Swift 6.1, allow packages to offer additional API that may include optional dependencies. +Packages should offer traits to provide API beyond the core of a package. +For example, a package may provide an experimental API, an optional API that requires additional dependencies, or functionality that isn't critical that a developer may want to enable only in specific circumstances. + +If a package offers traits and you depend on it without defining the traits to use, the package uses its default set of traits. +In the following example, the dependency `example-package-playingcard` uses its default traits, if it offers any: +```swift +dependencies: [ + .package(url: "https://github.com/swiftlang/example-package-playingcard", + from: "4.0.0") +] +``` + +To determine what traits a package offers, including its defaults, either inspect its `Package.swift` manifest or use to print out the resolved dependencies and their traits. + +Enabling a trait should only expand the API offered by a package. +If a package offers default traits, you can choose to not use those traits by declaring an empty set of traits when you declare the dependency. +The following example dependency declaration uses the dependency with no traits, even if the package normally provides a set of default traits to enable: + +```swift +dependencies: [ + .package(url: "https://github.com/swiftlang/example-package-playingcard", + from: "4.0.0", + traits: []) +] +``` + +Swift package manager determines the traits to enable using the entire graph of dependencies in a project. +The traits enabled for a dependency is the union of all of the traits that for packages that depend upon it. +For example, if you opt out of all traits, but a dependency you use uses the same package with some trait enabled, the package will use the depdendency with the requested traits enabled. + +> Note: By disabling any default traits, you may be removing available APIs from the dependency you use. + +To learn how to provide packages with traits, see . ### Local Dependencies @@ -77,6 +114,7 @@ For more information on creating a binary target, see [Creating a multiplatform ## Topics - +- - - - diff --git a/Sources/PackageManagerDocs/Documentation.docc/Dependencies/PackageTraits.md b/Sources/PackageManagerDocs/Documentation.docc/Dependencies/PackageTraits.md new file mode 100644 index 00000000000..0450fea8632 --- /dev/null +++ b/Sources/PackageManagerDocs/Documentation.docc/Dependencies/PackageTraits.md @@ -0,0 +1,141 @@ +# Provide configurable packages using traits. + +Define one or more traits to offer default and configurable features for a package. + +Swift packages prior to Swift 6.1 offered a non-configurable API surface for each version. +With Swift 6.1, packages may offer traits, which express a configurable API surface for a package. + +Use traits to enable additional API beyond the core API of the package. +For example, a trait may enable an experimental API, optional extended functionality that requires additional dependencies, or functionality that isn't critical that a developer may want to enable only in specific circumstances. + +> Note: Traits should never "remove" or disable public API when a trait is enabled. + +Within the package, traits express conditional compilation, and may be used to declare additional dependencies that are enabled when that trait is active. + +Traits are identified by their names, which are name-spaced within the package that hosts them. +Trait names must be [valid swift identifiers](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/lexicalstructure#Identifiers) with the addition of the characters of `-` and `+`. +The trait names `default` and `defaults` (regardless of any capitalization) aren't allowed to avoid confusion with the default traits that a package defines. + +Enabled traits are exposed as conditional blocks (for example, `#if YourTrait`) that you can use to conditionally enable imports or different compilation paths in code. + +## Overview + +### Declaring Traits + +Create a trait to define a discrete amount of additional functionality, and define it in the [traits](https://docs.swift.org/swiftpm/documentation/packagedescription/package/traits) property of the package manifest. +Use [`.default(enabledTraits:)`](https://docs.swift.org/swiftpm/documentation/packagedescription/trait/default(enabledtraits:)) to provide the set of traits that the package uses as a default. +If you don't define a default set of traits to enable, no traits are enabled by default. + +The following example illustrates a single trait, `FeatureA`, that is enabled by default: + +```swift +// ... +traits: [ + .trait(name: "FeatureA"), + .default(enabledTraits: ["FeatureA"]), +], +// ... +``` + +Traits may also be used to represent a set of other traits, which allows you to group features together. +The following example illustrates defining three traits, and an additional trait (`B-and-C`) that enables both traits `FeatureB` and `FeatureC`: + +```swift +// ... +traits: [ + .trait(name: "FeatureA"), + .trait(name: "FeatureB"), + .trait(name: "FeatureC"), + .trait(name: "B-and-C", enabledTraits: ["FeatureB", "FeatureC"]). + .default(enabledTraits: ["FeatureA"]), +], +// ... +``` + +The traits enabled by default for the example above is `FeatureA`. + +> Note: Changing the default set of traits for your package is a major semantic version change if it removes API surface. +> Adding additional traits is not a major version change. + +#### Mutually Exclusive Traits + +The package manifest format doesn't support declaring mutually exclusive traits. +In the rare case that you need to offer mutually exclusive traits, protect that scenario in code: + +```swift +#if FeatureA && FeatureC +#error("FeatureA and FeatureC are mutually exclusive") +#endif // FeatureA && FeatureC +``` + +> Note: Providing mutually exclusive traits can result in compilation errors when a developer enables the mutually exclusive traits. + +### Using traits in your code + +Use the name of a trait for conditional compilation. +Wrap the additional API surface for that trait within a conditional compilation block. +For example, if the trait `FeatureA` is defined and enabled, the compiler see and compile the function `additionalAPI()`: + +```swift +#if FeatureA +public func additionalAPI() { + // ... +} +#endif // FeatureA +``` + +### Using a trait to enable conditional dependencies + +You can use a trait to optionally include a dependency, or a dependency with specific traits enabled, to support the functionality you expose with a trait. +To do so, add the dependency you need to the manifest's `dependencies` declaration, +then use a conditional dependency for a trait or traits defined in the package to add that dependency to a target. + +The following example illustrates the relevant portions of a package manifest that defines a trait `FeatureB`, a local dependency that is used only when the trait is enabled: + +```swift +// ... +traits: [ + "FeatureB" + // this trait exists only within *this* package +], +dependencies: [ + .package( + path: "../some/local/path", + traits: ["DependencyFeatureTrait"] + // this enables the trait DependencyFeatureTrait on + // the local dependency at ../some/local/path. + ) +] +// ... +targets: [ + .target( + name: "MyTarget", + dependencies: [ + .product( + name: "MyAPI", + package: "MyDependency", + condition: .when(traits: ["FeatureB"]) + // if the FeatureB trait is enabled in *this* package, then + // the `MyAPI` product is included as a dependency for `MyTarget`. + ) + ] + ), +] +``` + +With the above example, the following code illustrates wrapping the import with the trait's conditional compilation, and later defines more API that uses the dependency: + +```swift +#if FeatureB + import MyAPI +#endif // FeatureB + +// ... + +#if FeatureB + public func additionalAPI() { + MyAPI.provideExtraFunctionality() + // ... + } +#endif // MyTrait +``` From eba40bbc3c7480aa9a3bdc5ec20a9f546129ad3f Mon Sep 17 00:00:00 2001 From: 3405691582 Date: Tue, 19 Aug 2025 20:14:48 -0400 Subject: [PATCH 107/225] Add bootstrap option to install without building. This allows the build action to be decoupled from the install action, so packaging systems that expect to be able to do discrete installation steps post-build can do so cheaply, without having to effectively start the build over from scratch. This is only intended solely for that use-case, so this is not wired up to other part of the swiftpm build process and defaults to being disabled. --- Utilities/bootstrap | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Utilities/bootstrap b/Utilities/bootstrap index d6bdd3d2f2f..a39d8ca25e0 100755 --- a/Utilities/bootstrap +++ b/Utilities/bootstrap @@ -121,6 +121,10 @@ def add_global_args(parser): "--reconfigure", action="store_true", help="whether to always reconfigure cmake") + parser.add_argument( + "--install-only", + action="store_true", + default=False) @log_entry_exit def add_build_args(parser): @@ -494,7 +498,10 @@ def test(args): @log_entry_exit def install(args): """Builds SwiftPM, then installs its build products.""" - build(args) + if args.install_only: + parse_build_args(args) + else: + build(args) # Install swiftpm content in all of the passed prefixes. for prefix in args.install_prefixes: From e1e4a547ee0da8323af3961ea2ac5e01ffc23650 Mon Sep 17 00:00:00 2001 From: Mishal Shah Date: Wed, 3 Sep 2025 21:17:51 -0700 Subject: [PATCH 108/225] Bump the Swift version to 6.3 (#9028) --- Sources/Basics/SwiftVersion.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Basics/SwiftVersion.swift b/Sources/Basics/SwiftVersion.swift index 81f6b27b07f..6b0f6599882 100644 --- a/Sources/Basics/SwiftVersion.swift +++ b/Sources/Basics/SwiftVersion.swift @@ -58,7 +58,7 @@ public struct SwiftVersion: Sendable { extension SwiftVersion { /// The current version of the package manager. public static let current = SwiftVersion( - version: (6, 2, 0), + version: (6, 3, 0), isDevelopment: true, buildIdentifier: getBuildIdentifier() ) From 5bc140ae00bc34ad39aa2a94946592190d2dc0d1 Mon Sep 17 00:00:00 2001 From: Owen Voorhees Date: Thu, 4 Sep 2025 11:18:37 -0700 Subject: [PATCH 109/225] Drop unknown platforms when generating PIF (#9021) Some existing packages use `.when(platforms: [.custom("DoesNotExist")])` to unconditionally deactivate settings. Stop asserting on these unknown platforms --- .../PIFBuilder/UnknownPlatforms/Package.swift | 17 +++ .../UnknownPlatforms/UnknownPlatforms.swift | 9 ++ Package.swift | 4 + Sources/SwiftBuildSupport/PIFBuilder.swift | 28 ++-- .../PackagePIFBuilder+Helpers.swift | 23 ++- .../SwiftBuildSupport/PackagePIFBuilder.swift | 15 +- .../PackagePIFProjectBuilder+Modules.swift | 4 +- .../PackagePIFProjectBuilder+Products.swift | 2 +- .../PIFBuilderTests.swift | 138 ++++++++++++++++++ 9 files changed, 215 insertions(+), 25 deletions(-) create mode 100644 Fixtures/PIFBuilder/UnknownPlatforms/Package.swift create mode 100644 Fixtures/PIFBuilder/UnknownPlatforms/Sources/UnknownPlatforms/UnknownPlatforms.swift create mode 100644 Tests/SwiftBuildSupportTests/PIFBuilderTests.swift diff --git a/Fixtures/PIFBuilder/UnknownPlatforms/Package.swift b/Fixtures/PIFBuilder/UnknownPlatforms/Package.swift new file mode 100644 index 00000000000..3aedee44976 --- /dev/null +++ b/Fixtures/PIFBuilder/UnknownPlatforms/Package.swift @@ -0,0 +1,17 @@ +// swift-tools-version: 6.2 + +import PackageDescription + +let package = Package( + name: "UnknownPlatforms", + targets: [ + .executableTarget( + name: "UnknownPlatforms", + swiftSettings: [ + .define("FOO", .when(platforms: [.custom("DoesNotExist")])), + .define("BAR", .when(platforms: [.linux])), + .define("BAZ", .when(platforms: [.macOS])), + ], + ), + ] +) diff --git a/Fixtures/PIFBuilder/UnknownPlatforms/Sources/UnknownPlatforms/UnknownPlatforms.swift b/Fixtures/PIFBuilder/UnknownPlatforms/Sources/UnknownPlatforms/UnknownPlatforms.swift new file mode 100644 index 00000000000..7ab2b4f88fa --- /dev/null +++ b/Fixtures/PIFBuilder/UnknownPlatforms/Sources/UnknownPlatforms/UnknownPlatforms.swift @@ -0,0 +1,9 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +@main +struct UnknownPlatforms { + static func main() { + print("Hello, world!") + } +} diff --git a/Package.swift b/Package.swift index e8292a38f0f..0b565527562 100644 --- a/Package.swift +++ b/Package.swift @@ -985,6 +985,10 @@ let package = Package( "_InternalTestSupport", ] ), + .testTarget( + name: "SwiftBuildSupportTests", + dependencies: ["SwiftBuildSupport", "_InternalTestSupport", "_InternalBuildTestSupport"] + ), // Examples (These are built to ensure they stay up to date with the API.) .executableTarget( name: "package-info", diff --git a/Sources/SwiftBuildSupport/PIFBuilder.swift b/Sources/SwiftBuildSupport/PIFBuilder.swift index dc91af90b1f..9b86b7ac79c 100644 --- a/Sources/SwiftBuildSupport/PIFBuilder.swift +++ b/Sources/SwiftBuildSupport/PIFBuilder.swift @@ -83,9 +83,7 @@ extension ModulesGraph { } /// The parameters required by `PIFBuilder`. -struct PIFBuilderParameters { - let triple: Basics.Triple - +package struct PIFBuilderParameters { /// Whether the toolchain supports `-package-name` option. let isPackageAccessModifierSupported: Bool @@ -101,9 +99,6 @@ struct PIFBuilderParameters { /// An array of paths to search for pkg-config `.pc` files. let pkgConfigDirectories: [AbsolutePath] - /// The toolchain's SDK root path. - let sdkRootPath: AbsolutePath? - /// The Swift language versions supported by the SwiftBuild being used for the build. let supportedSwiftVersions: [SwiftLanguageVersion] @@ -118,6 +113,19 @@ struct PIFBuilderParameters { /// Additional rules for including a source or resource file in a target let additionalFileRules: [FileRuleDescription] + + package init(isPackageAccessModifierSupported: Bool, enableTestability: Bool, shouldCreateDylibForDynamicProducts: Bool, toolchainLibDir: AbsolutePath, pkgConfigDirectories: [AbsolutePath], supportedSwiftVersions: [SwiftLanguageVersion], pluginScriptRunner: PluginScriptRunner, disableSandbox: Bool, pluginWorkingDirectory: AbsolutePath, additionalFileRules: [FileRuleDescription]) { + self.isPackageAccessModifierSupported = isPackageAccessModifierSupported + self.enableTestability = enableTestability + self.shouldCreateDylibForDynamicProducts = shouldCreateDylibForDynamicProducts + self.toolchainLibDir = toolchainLibDir + self.pkgConfigDirectories = pkgConfigDirectories + self.supportedSwiftVersions = supportedSwiftVersions + self.pluginScriptRunner = pluginScriptRunner + self.disableSandbox = disableSandbox + self.pluginWorkingDirectory = pluginWorkingDirectory + self.additionalFileRules = additionalFileRules + } } /// PIF object builder for a package graph. @@ -146,7 +154,7 @@ public final class PIFBuilder { /// - parameters: The parameters used to configure the PIF. /// - fileSystem: The file system to read from. /// - observabilityScope: The ObservabilityScope to emit diagnostics to. - init( + package init( graph: ModulesGraph, parameters: PIFBuilderParameters, fileSystem: FileSystem, @@ -163,7 +171,7 @@ public final class PIFBuilder { /// - prettyPrint: Whether to return a formatted JSON. /// - preservePIFModelStructure: Whether to preserve model structure. /// - Returns: The package graph in the JSON PIF format. - func generatePIF( + package func generatePIF( prettyPrint: Bool = true, preservePIFModelStructure: Bool = false, printPIFManifestGraphviz: Bool = false, @@ -227,7 +235,7 @@ public final class PIFBuilder { } /// Constructs a `PIF.TopLevelObject` representing the package graph. - private func constructPIF(buildParameters: BuildParameters) async throws -> PIF.TopLevelObject { + package func constructPIF(buildParameters: BuildParameters) async throws -> PIF.TopLevelObject { let pluginScriptRunner = self.parameters.pluginScriptRunner let outputDir = self.parameters.pluginWorkingDirectory.appending("outputs") @@ -727,13 +735,11 @@ extension PIFBuilderParameters { additionalFileRules: [FileRuleDescription] ) { self.init( - triple: buildParameters.triple, isPackageAccessModifierSupported: buildParameters.driverParameters.isPackageAccessModifierSupported, enableTestability: buildParameters.enableTestability, shouldCreateDylibForDynamicProducts: buildParameters.shouldCreateDylibForDynamicProducts, toolchainLibDir: (try? buildParameters.toolchain.toolchainLibDir) ?? .root, pkgConfigDirectories: buildParameters.pkgConfigDirectories, - sdkRootPath: buildParameters.toolchain.sdkRootPath, supportedSwiftVersions: supportedSwiftVersions, pluginScriptRunner: pluginScriptRunner, disableSandbox: disableSandbox, diff --git a/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift b/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift index d0bcc4eff7a..fcd0453308b 100644 --- a/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift +++ b/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift @@ -234,7 +234,7 @@ extension Sequence { } var pifPlatformsForCondition: [ProjectModel.BuildSettings.Platform] = platforms - .map { ProjectModel.BuildSettings.Platform(from: $0) } + .compactMap { try? ProjectModel.BuildSettings.Platform(from: $0) } // Treat catalyst like macOS for backwards compatibility with older tools versions. if pifPlatformsForCondition.contains(.macOS), toolsVersion < ToolsVersion.v5_5 { @@ -537,7 +537,7 @@ extension PackageGraph.ResolvedModule { /// Collect the build settings defined in the package manifest. /// Some of them apply *only* to the target itself, while others are also imparted to clients. /// Note that the platform is *optional*; unconditional settings have no platform condition. - var allBuildSettings: AllBuildSettings { + func computeAllBuildSettings(observabilityScope: ObservabilityScope) -> AllBuildSettings { var allSettings = AllBuildSettings() for (declaration, settingsAssigments) in self.underlying.buildSettings.assignments { @@ -565,7 +565,16 @@ extension PackageGraph.ResolvedModule { let (platforms, configurations, _) = settingAssignment.conditions.splitIntoConcreteConditions for platform in platforms { - let pifPlatform = platform.map { ProjectModel.BuildSettings.Platform(from: $0) } + let pifPlatform: ProjectModel.BuildSettings.Platform? + if let platform { + guard let computedPifPlatform = try? ProjectModel.BuildSettings.Platform(from: platform) else { + observabilityScope.logPIF(.warning, "Ignoring settings assignments for unknown platform '\(platform.name)'") + continue + } + pifPlatform = computedPifPlatform + } else { + pifPlatform = nil + } if pifDeclaration == .OTHER_LDFLAGS { var settingsByDeclaration: [ProjectModel.BuildSettings.Declaration: [String]] @@ -962,7 +971,11 @@ extension ProjectModel.BuildSettings.MultipleValueSetting { } extension ProjectModel.BuildSettings.Platform { - init(from platform: PackageModel.Platform) { + enum Error: Swift.Error { + case unknownPlatform(String) + } + + init(from platform: PackageModel.Platform) throws { self = switch platform { case .macOS: .macOS case .macCatalyst: .macCatalyst @@ -977,7 +990,7 @@ extension ProjectModel.BuildSettings.Platform { case .wasi: .wasi case .openbsd: .openbsd case .freebsd: .freebsd - default: preconditionFailure("Unexpected platform: \(platform.name)") + default: throw Error.unknownPlatform(platform.name) } } } diff --git a/Sources/SwiftBuildSupport/PackagePIFBuilder.swift b/Sources/SwiftBuildSupport/PackagePIFBuilder.swift index 510be26ce39..8964cb87072 100644 --- a/Sources/SwiftBuildSupport/PackagePIFBuilder.swift +++ b/Sources/SwiftBuildSupport/PackagePIFBuilder.swift @@ -562,7 +562,10 @@ public final class PackagePIFBuilder { self.delegate.configureProjectBuildSettings(&settings) for (platform, platformOptions) in self.package.sdkOptions(delegate: self.delegate) { - let pifPlatform = ProjectModel.BuildSettings.Platform(from: platform) + guard let pifPlatform = try? ProjectModel.BuildSettings.Platform(from: platform) else { + log(.warning, "Ignoring options '\(platformOptions.joined(separator: " "))' specified for unknown platform \(platform.name)") + continue + } settings.platformSpecificSettings[pifPlatform]![.SPECIALIZATION_SDK_OPTIONS]! .append(contentsOf: platformOptions) } @@ -584,11 +587,11 @@ public final class PackagePIFBuilder { let arm64ePlatforms: [PackageModel.Platform] = [.iOS, .macOS, .visionOS] for arm64ePlatform in arm64ePlatforms { if self.delegate.shouldPackagesBuildForARM64e(platform: arm64ePlatform) { - let pifPlatform: ProjectModel.BuildSettings.Platform = switch arm64ePlatform { - case .iOS: - ._iOSDevice - default: - .init(from: arm64ePlatform) + let pifPlatform: ProjectModel.BuildSettings.Platform + do { + pifPlatform = try .init(from: arm64ePlatform) + } catch { + preconditionFailure("Unhandled arm64e platform: \(error)") } settings.platformSpecificSettings[pifPlatform]![.ARCHS, default: []].append(contentsOf: ["arm64e"]) } diff --git a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift index 81a9c42ff60..d490e37fa3b 100644 --- a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift +++ b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift @@ -738,7 +738,7 @@ extension PackagePIFProjectBuilder { var debugSettings = settings var releaseSettings = settings - let allBuildSettings = sourceModule.allBuildSettings + let allBuildSettings = sourceModule.computeAllBuildSettings(observabilityScope: pifBuilder.observabilityScope) // Apply target-specific build settings defined in the manifest. for (buildConfig, declarationsByPlatform) in allBuildSettings.targetSettings { @@ -756,7 +756,7 @@ extension PackagePIFProjectBuilder { } // Impart the linker flags. - for (platform, settingsByDeclaration) in sourceModule.allBuildSettings.impartedSettings { + for (platform, settingsByDeclaration) in sourceModule.computeAllBuildSettings(observabilityScope: pifBuilder.observabilityScope).impartedSettings { // Note: A `nil` platform means that the declaration applies to *all* platforms. for (declaration, stringValues) in settingsByDeclaration { impartedSettings.append(values: stringValues, to: declaration, platform: platform) diff --git a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift index 90d47bd2830..5a889cae186 100644 --- a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift +++ b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift @@ -470,7 +470,7 @@ extension PackagePIFProjectBuilder { var releaseSettings: ProjectModel.BuildSettings = settings // Apply target-specific build settings defined in the manifest. - for (buildConfig, declarationsByPlatform) in mainModule.allBuildSettings.targetSettings { + for (buildConfig, declarationsByPlatform) in mainModule.computeAllBuildSettings(observabilityScope: pifBuilder.observabilityScope).targetSettings { for (platform, declarations) in declarationsByPlatform { // A `nil` platform means that the declaration applies to *all* platforms. for (declaration, stringValues) in declarations { diff --git a/Tests/SwiftBuildSupportTests/PIFBuilderTests.swift b/Tests/SwiftBuildSupportTests/PIFBuilderTests.swift new file mode 100644 index 00000000000..0d1fa537072 --- /dev/null +++ b/Tests/SwiftBuildSupportTests/PIFBuilderTests.swift @@ -0,0 +1,138 @@ +//===----------------------------------------------------------------------===// +// +// 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 Testing +import PackageGraph +import PackageLoading +import PackageModel +import SPMBuildCore +import SwiftBuild +import SwiftBuildSupport +import _InternalTestSupport +import Workspace + +extension PIFBuilderParameters { + fileprivate static func constructDefaultParametersForTesting(temporaryDirectory: Basics.AbsolutePath) throws -> Self { + self.init( + isPackageAccessModifierSupported: true, + enableTestability: false, + shouldCreateDylibForDynamicProducts: false, + toolchainLibDir: temporaryDirectory.appending(component: "toolchain-lib-dir"), + pkgConfigDirectories: [], + supportedSwiftVersions: [.v4, .v4_2, .v5, .v6], + pluginScriptRunner: DefaultPluginScriptRunner( + fileSystem: localFileSystem, + cacheDir: temporaryDirectory.appending(component: "plugin-cache-dir"), + toolchain: try UserToolchain.default + ), + disableSandbox: false, + pluginWorkingDirectory: temporaryDirectory.appending(component: "plugin-working-dir"), + additionalFileRules: [] + ) + } +} + +fileprivate func withGeneratedPIF(fromFixture fixtureName: String, do doIt: (SwiftBuildSupport.PIF.TopLevelObject, TestingObservability) async throws -> ()) async throws { + try await fixture(name: fixtureName) { fixturePath in + let observabilitySystem = ObservabilitySystem.makeForTesting() + let workspace = try Workspace( + fileSystem: localFileSystem, + forRootPackage: fixturePath, + customManifestLoader: ManifestLoader(toolchain: UserToolchain.default), + delegate: MockWorkspaceDelegate() + ) + let rootInput = PackageGraphRootInput(packages: [fixturePath], dependencies: []) + let graph = try await workspace.loadPackageGraph( + rootInput: rootInput, + observabilityScope: observabilitySystem.topScope + ) + let builder = PIFBuilder( + graph: graph, + parameters: try PIFBuilderParameters.constructDefaultParametersForTesting(temporaryDirectory: fixturePath), + fileSystem: localFileSystem, + observabilityScope: observabilitySystem.topScope + ) + let pif = try await builder.constructPIF( + buildParameters: mockBuildParameters(destination: .host) + ) + try await doIt(pif, observabilitySystem) + } +} + +extension SwiftBuildSupport.PIF.Workspace { + fileprivate func project(named name: String) throws -> SwiftBuildSupport.PIF.Project { + let matchingProjects = projects.filter { + $0.underlying.name == name + } + if matchingProjects.isEmpty { + throw StringError("No project named \(name) in PIF workspace") + } else if matchingProjects.count > 1 { + throw StringError("Multiple projects named \(name) in PIF workspace") + } else { + return matchingProjects[0] + } + } +} + +extension SwiftBuildSupport.PIF.Project { + fileprivate func target(named name: String) throws -> ProjectModel.BaseTarget { + let matchingTargets = underlying.targets.filter { + $0.common.name == name + } + if matchingTargets.isEmpty { + throw StringError("No target named \(name) in PIF project") + } else if matchingTargets.count > 1 { + throw StringError("Multiple target named \(name) in PIF project") + } else { + return matchingTargets[0] + } + } +} + +extension SwiftBuild.ProjectModel.BaseTarget { + fileprivate func buildConfig(named name: String) throws -> SwiftBuild.ProjectModel.BuildConfig { + let matchingConfigs = common.buildConfigs.filter { + $0.name == name + } + if matchingConfigs.isEmpty { + throw StringError("No config named \(name) in PIF target") + } else if matchingConfigs.count > 1 { + throw StringError("Multiple configs named \(name) in PIF target") + } else { + return matchingConfigs[0] + } + } +} + +@Suite +struct PIFBuilderTests { + @Test func platformConditionBasics() async throws { + try await withGeneratedPIF(fromFixture: "PIFBuilder/UnknownPlatforms") { pif, observabilitySystem in + // We should emit a warning to the PIF log about the unknown platform + #expect(observabilitySystem.diagnostics.filter { + $0.severity == .warning && $0.message.contains("Ignoring settings assignments for unknown platform 'DoesNotExist'") + }.count > 0) + + let releaseConfig = try pif.workspace + .project(named: "UnknownPlatforms") + .target(named: "UnknownPlatforms") + .buildConfig(named: "Release") + + // The platforms with conditional settings should have those propagated to the PIF. + #expect(releaseConfig.settings.platformSpecificSettings[.linux]?[.SWIFT_ACTIVE_COMPILATION_CONDITIONS] == ["$(inherited)", "BAR"]) + #expect(releaseConfig.settings.platformSpecificSettings[.macOS]?[.SWIFT_ACTIVE_COMPILATION_CONDITIONS] == ["$(inherited)", "BAZ"]) + // Platforms without conditional settings should get the default. + #expect(releaseConfig.settings.platformSpecificSettings[.windows]?[.SWIFT_ACTIVE_COMPILATION_CONDITIONS] == ["$(inherited)"]) + } + } +} From a5587f0950a83b214e88ecce8ec3d41a07863d38 Mon Sep 17 00:00:00 2001 From: "Bassam (Sam) Khouri" Date: Thu, 4 Sep 2025 16:58:01 -0400 Subject: [PATCH 110/225] Test: Add issue to test (#9084) Add an issue to a test so we can track it's withKnownIssue resolution --- Tests/CommandsTests/PackageCommandTests.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index 2e9f61dbfe1..0c6cf09e023 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -5452,6 +5452,10 @@ struct PackageCommandTests { .IssueWindowsRelativePathAssert, .IssueWindowsLongPath, .IssueWindowsPathLastConponent, + .issue( + "https://github.com/swiftlang/swift-package-manager/issues/9083", + relationship: .defect, + ), arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), Self.getCommandPluginNetworkingPermissionTestData() ) From 4815f1c0b063e8851705ccabc18ebe1c7da2fc6d Mon Sep 17 00:00:00 2001 From: "Bassam (Sam) Khouri" Date: Mon, 8 Sep 2025 15:24:33 -0400 Subject: [PATCH 111/225] Test: Migrate CFamilyTargetTests to Swift Testing and augment (#9014) Migrate the `CFamilyTargetTests` test to Swift Testing and augment the test to run against both the Native and SwiftBUild build system, in addition to the `debug` and `release` build configuration. Depends on: #9013 Relates to: #8997 issue: rdar://157669245 --- .../SwiftTesting+TraitsBug.swift | 8 + .../FunctionalTests/CFamilyTargetTests.swift | 271 ++++++++++++++---- 2 files changed, 218 insertions(+), 61 deletions(-) diff --git a/Sources/_InternalTestSupport/SwiftTesting+TraitsBug.swift b/Sources/_InternalTestSupport/SwiftTesting+TraitsBug.swift index f2fa5605c64..99de658066a 100644 --- a/Sources/_InternalTestSupport/SwiftTesting+TraitsBug.swift +++ b/Sources/_InternalTestSupport/SwiftTesting+TraitsBug.swift @@ -54,6 +54,14 @@ extension Trait where Self == Testing.Bug { ) } + public static var IssueWindowsCannotSaveAttachment: Self { + // error: unable to write file 'C:\Users\ContainerAdministrator\AppData\Local\Temp\CFamilyTargets_CDynamicLookup.hNxGHC\CFamilyTargets_CDynamicLookup\.build\x86_64-unknown-windows-msvc\Intermediates.noindex\CDynamicLookup.build\Release-windows\CDynamicLookup.build\Objects-normal\x86_64\CDynamicLookup.LinkFileList': No such file or directory (2) + .issue( + "https://github.com/swiftlang/swift-foundation/issues/1486", + relationship: .defect, + ) + } + public static var IssueProductTypeForObjectLibraries: Self { .issue( "https://github.com/swiftlang/swift-build/issues/609", diff --git a/Tests/FunctionalTests/CFamilyTargetTests.swift b/Tests/FunctionalTests/CFamilyTargetTests.swift index a3f9d15d223..0a218b9b2fc 100644 --- a/Tests/FunctionalTests/CFamilyTargetTests.swift +++ b/Tests/FunctionalTests/CFamilyTargetTests.swift @@ -2,13 +2,14 @@ // // This source file is part of the Swift open source project // -// Copyright (c) 2014-2024 Apple Inc. and the Swift project authors +// 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 Foundation import Basics import Commands @@ -18,98 +19,246 @@ import PackageModel import SourceControl import _InternalTestSupport import Workspace -import XCTest +import Testing import class Basics.AsyncProcess -/// Asserts if a directory (recursively) contains a file. -private func XCTAssertDirectoryContainsFile(dir: AbsolutePath, filename: String, file: StaticString = #file, line: UInt = #line) { +/// Expects a directory (recursively) contains a file. +fileprivate func expectDirectoryContainsFile( + dir: AbsolutePath, + filename: String, + sourceLocation: SourceLocation = #_sourceLocation, +) { do { for entry in try walk(dir) { if entry.basename == filename { return } } } catch { - XCTFail("Failed with error \(error)", file: file, line: line) + Issue.record("Failed with error \(error)", sourceLocation: sourceLocation) } - XCTFail("Directory \(dir) does not contain \(file)", file: file, line: line) + Issue.record("Directory \(dir) does not contain \(filename)", sourceLocation: sourceLocation) } -final class CFamilyTargetTestCase: XCTestCase { - func testCLibraryWithSpaces() async throws { - try await fixtureXCTest(name: "CFamilyTargets/CLibraryWithSpaces") { fixturePath in - await XCTAssertBuilds(fixturePath, buildSystem: .native) - let debugPath = fixturePath.appending(components: ".build", try UserToolchain.default.targetTriple.platformBuildPathComponent, "debug") - XCTAssertDirectoryContainsFile(dir: debugPath, filename: "Bar.c.o") - XCTAssertDirectoryContainsFile(dir: debugPath, filename: "Foo.c.o") +@Suite( + .tags( + .TestSize.large, + ), +) +struct CFamilyTargetTestCase { + @Test( + .issue("https://github.com/swiftlang/swift-build/issues/333", relationship: .defect), + .tags( + .Feature.Command.Build, + ), + arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), + ) + func cLibraryWithSpaces( + data: BuildData, + ) async throws { + try await withKnownIssue(isIntermittent: true) { + try await fixture(name: "CFamilyTargets/CLibraryWithSpaces") { fixturePath in + try await executeSwiftBuild( + fixturePath, + configuration: data.config, + buildSystem: data.buildSystem, + ) + if data.buildSystem == .native { + let binPath = try fixturePath.appending(components: data.buildSystem.binPath(for: data.config)) + expectDirectoryContainsFile(dir: binPath, filename: "Bar.c.o") + expectDirectoryContainsFile(dir: binPath, filename: "Foo.c.o") + } + } + } when: { + data.buildSystem == .swiftbuild } } - func testCUsingCAndSwiftDep() async throws { - try await fixtureXCTest(name: "DependencyResolution/External/CUsingCDep") { fixturePath in - let packageRoot = fixturePath.appending("Bar") - await XCTAssertBuilds(packageRoot, buildSystem: .native) - let debugPath = fixturePath.appending(components: "Bar", ".build", try UserToolchain.default.targetTriple.platformBuildPathComponent, "debug") - XCTAssertDirectoryContainsFile(dir: debugPath, filename: "Sea.c.o") - XCTAssertDirectoryContainsFile(dir: debugPath, filename: "Foo.c.o") - let path = try SwiftPM.packagePath(for: "Foo", packageRoot: packageRoot) - XCTAssertEqual(try GitRepository(path: path).getTags(), ["1.2.3"]) + @Test( + .tags( + .Feature.Command.Build, + ), + .IssueWindowsLongPath, + .IssueWindowsPathLastConponent, + .IssueWindowsRelativePathAssert, + .IssueWindowsCannotSaveAttachment, + arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), + ) + func cUsingCAndSwiftDep( + data: BuildData, + ) async throws { + try await withKnownIssue(isIntermittent: true) { + try await fixture(name: "DependencyResolution/External/CUsingCDep") { fixturePath in + let packageRoot = fixturePath.appending("Bar") + try await executeSwiftBuild( + packageRoot, + configuration: data.config, + buildSystem: data.buildSystem, + ) + if data.buildSystem == .native { + let binPath = try packageRoot.appending(components: data.buildSystem.binPath(for: data.config)) + expectDirectoryContainsFile(dir: binPath, filename: "Sea.c.o") + expectDirectoryContainsFile(dir: binPath, filename: "Foo.c.o") + } + let path = try SwiftPM.packagePath(for: "Foo", packageRoot: packageRoot) + let actualTags = try GitRepository(path: path).getTags() + #expect(actualTags == ["1.2.3"]) + } + } when: { + ProcessInfo.hostOperatingSystem == .windows && data.buildSystem == .swiftbuild } } - func testModuleMapGenerationCases() async throws { - try await fixtureXCTest(name: "CFamilyTargets/ModuleMapGenerationCases") { fixturePath in - await XCTAssertBuilds(fixturePath, buildSystem: .native) - let debugPath = fixturePath.appending(components: ".build", try UserToolchain.default.targetTriple.platformBuildPathComponent, "debug") - XCTAssertDirectoryContainsFile(dir: debugPath, filename: "Jaz.c.o") - XCTAssertDirectoryContainsFile(dir: debugPath, filename: "main.swift.o") - XCTAssertDirectoryContainsFile(dir: debugPath, filename: "FlatInclude.c.o") - XCTAssertDirectoryContainsFile(dir: debugPath, filename: "UmbrellaHeader.c.o") + @Test( + .tags( + .Feature.Command.Build, + ), + .IssueWindowsLongPath, + .IssueWindowsPathLastConponent, + .IssueWindowsRelativePathAssert, + .IssueWindowsCannotSaveAttachment, + arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), + ) + func moduleMapGenerationCases( + data: BuildData, + ) async throws { + try await withKnownIssue(isIntermittent: true) { + try await fixture(name: "CFamilyTargets/ModuleMapGenerationCases") { fixturePath in + try await executeSwiftBuild( + fixturePath, + configuration: data.config, + buildSystem: data.buildSystem, + ) + if data.buildSystem == .native { + let binPath = try fixturePath.appending(components: data.buildSystem.binPath(for: data.config)) + expectDirectoryContainsFile(dir: binPath, filename: "Jaz.c.o") + expectDirectoryContainsFile(dir: binPath, filename: "main.swift.o") + expectDirectoryContainsFile(dir: binPath, filename: "FlatInclude.c.o") + expectDirectoryContainsFile(dir: binPath, filename: "UmbrellaHeader.c.o") + } + } + } when: { + ProcessInfo.hostOperatingSystem == .windows && data.buildSystem == .swiftbuild } } - - func testNoIncludeDirCheck() async throws { - try await fixtureXCTest(name: "CFamilyTargets/CLibraryNoIncludeDir") { fixturePath in - await XCTAssertAsyncThrowsError( + + @Test( + .tags( + .Feature.Command.Build, + ), + arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), + ) + func noIncludeDirCheck( + data: BuildData, + ) async throws { + try await fixture(name: "CFamilyTargets/CLibraryNoIncludeDir") { fixturePath in + let error = try await #require(throws: (any Error).self) { try await executeSwiftBuild( fixturePath, - buildSystem: .native, - ), - "This build should throw an error", - ) { err in - // The err.localizedDescription doesn't capture the detailed error string so interpolate - let errStr = "\(err)" - let missingIncludeDirStr = "\(ModuleError.invalidPublicHeadersDirectory("Cfactorial"))" - XCTAssert(errStr.contains(missingIncludeDirStr)) + configuration: data.config, + buildSystem: data.buildSystem, + ) } + + let errString = "\(error)" + let missingIncludeDirStr = "\(ModuleError.invalidPublicHeadersDirectory("Cfactorial"))" + #expect(errString.contains(missingIncludeDirStr)) } } - func testCanForwardExtraFlagsToClang() async throws { - // Try building a fixture which needs extra flags to be able to build. - try await fixtureXCTest(name: "CFamilyTargets/CDynamicLookup") { fixturePath in - await XCTAssertBuilds(fixturePath, Xld: ["-undefined", "dynamic_lookup"], buildSystem: .native) - let debugPath = fixturePath.appending(components: ".build", try UserToolchain.default.targetTriple.platformBuildPathComponent, "debug") - XCTAssertDirectoryContainsFile(dir: debugPath, filename: "Foo.c.o") + @Test( + .tags( + .Feature.Command.Build, + ), + .IssueWindowsLongPath, + .IssueWindowsPathLastConponent, + .IssueWindowsRelativePathAssert, + .IssueWindowsCannotSaveAttachment, + arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), + ) + func canForwardExtraFlagsToClang( + data: BuildData, + ) async throws { + try await withKnownIssue(isIntermittent: true) { + try await fixture(name: "CFamilyTargets/CDynamicLookup") { fixturePath in + try await executeSwiftBuild( + fixturePath, + configuration: data.config, + Xld: ["-undefined", "dynamic_lookup"], + buildSystem: data.buildSystem, + ) + if data.buildSystem == .native { + let binPath = try fixturePath.appending(components: data.buildSystem.binPath(for: data.config)) + expectDirectoryContainsFile(dir: binPath, filename: "Foo.c.o") + } + } + } when: { + ProcessInfo.hostOperatingSystem == .windows && data.buildSystem == .swiftbuild } } - func testObjectiveCPackageWithTestTarget() async throws { - #if !os(macOS) - try XCTSkipIf(true, "test is only supported on macOS") - #endif - try await fixtureXCTest(name: "CFamilyTargets/ObjCmacOSPackage") { fixturePath in + @Test( + .tags( + .Feature.Command.Build, + .Feature.Command.Test, + ), + .requireHostOS(.macOS), + arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), + + ) + func objectiveCPackageWithTestTarget( + data: BuildData, + + ) async throws { + try await fixture(name: "CFamilyTargets/ObjCmacOSPackage") { fixturePath in // Build the package. - await XCTAssertBuilds(fixturePath, buildSystem: .native) - XCTAssertDirectoryContainsFile(dir: fixturePath.appending(components: ".build", try UserToolchain.default.targetTriple.platformBuildPathComponent, "debug"), filename: "HelloWorldExample.m.o") + try await executeSwiftBuild( + fixturePath, + configuration: data.config, + buildSystem: data.buildSystem, + ) + if data.buildSystem == .native { + let binPath = try fixturePath.appending(components: data.buildSystem.binPath(for: data.config)) + expectDirectoryContainsFile(dir: binPath, filename: "HelloWorldExample.m.o") + expectDirectoryContainsFile(dir: binPath, filename: "HelloWorldExample.m.o") + } // Run swift-test on package. - await XCTAssertSwiftTest(fixturePath, buildSystem: .native) + try await executeSwiftTest( + fixturePath, + configuration: data.config, + buildSystem: data.buildSystem, + ) + } } - - func testCanBuildRelativeHeaderSearchPaths() async throws { - try await fixtureXCTest(name: "CFamilyTargets/CLibraryParentSearchPath") { fixturePath in - await XCTAssertBuilds(fixturePath, buildSystem: .native) - XCTAssertDirectoryContainsFile(dir: fixturePath.appending(components: ".build", try UserToolchain.default.targetTriple.platformBuildPathComponent, "debug"), filename: "HeaderInclude.swiftmodule") + + @Test( + .tags( + .Feature.Command.Build, + ), + .IssueWindowsLongPath, + .IssueWindowsPathLastConponent, + .IssueWindowsRelativePathAssert, + .IssueWindowsCannotSaveAttachment, + arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), + ) + func canBuildRelativeHeaderSearchPaths( + data: BuildData, + + ) async throws { + try await withKnownIssue(isIntermittent: true) { + try await fixture(name: "CFamilyTargets/CLibraryParentSearchPath") { fixturePath in + try await executeSwiftBuild( + fixturePath, + configuration: data.config, + buildSystem: data.buildSystem, + ) + if data.buildSystem == .native { + let binPath = try fixturePath.appending(components: data.buildSystem.binPath(for: data.config)) + expectDirectoryContainsFile(dir: binPath, filename: "HeaderInclude.swiftmodule") + } + } + } when: { + ProcessInfo.hostOperatingSystem == .windows && data.buildSystem == .swiftbuild } } } From 395c54594f96c4d19a89e4f785f43d8c0d8dd08f Mon Sep 17 00:00:00 2001 From: "Bassam (Sam) Khouri" Date: Mon, 8 Sep 2025 16:32:31 -0400 Subject: [PATCH 112/225] Tests: Update helpers to support both test libraries (#9082) The `_InternalTestSupport` and `_InternalBuildTestSupport` modules have XCTest assert helper functions, that, when called from a Swift Testing test, is essentially a no-op. During migration to Swift Testing, this could lead to false positives if the helpers function are not converted to use Swift Testing APIs. During the transition, update all helper functions to detect whether the caller is executing in the XCTest or Swift Testing context, and call the appropriate API given the library. --- .../MockBuildTestHelper.swift | 27 +++++- .../_InternalBuildTestSupport/PIFTester.swift | 92 ++++++++++++++++--- .../MockBuildTestHelper.swift | 1 - .../MockDependencyGraph.swift | 22 ++++- .../MockPackageContainer.swift | 1 - .../XCTAssertHelpers.swift | 28 ++++++ Sources/_InternalTestSupport/misc.swift | 51 ++++++---- Tests/BuildTests/BuildPlanTests.swift | 1 + .../CrossCompilationBuildPlanTests.swift | 1 + .../BuildTests/ModuleAliasingBuildTests.swift | 1 + Tests/BuildTests/PluginInvocationTests.swift | 1 + Tests/CommandsTests/PackageCommandTests.swift | 10 +- .../DependencyResolverPerfTests.swift | 1 + .../RegistryClientTests.swift | 4 +- .../XCBuildSupportTests/PIFBuilderTests.swift | 1 + 15 files changed, 198 insertions(+), 44 deletions(-) diff --git a/Sources/_InternalBuildTestSupport/MockBuildTestHelper.swift b/Sources/_InternalBuildTestSupport/MockBuildTestHelper.swift index bcdc42a519a..af0bfac2369 100644 --- a/Sources/_InternalBuildTestSupport/MockBuildTestHelper.swift +++ b/Sources/_InternalBuildTestSupport/MockBuildTestHelper.swift @@ -23,6 +23,7 @@ import PackageModel import SPMBuildCore import TSCUtility import XCTest +import Testing public func mockBuildPlan( buildPath: AbsolutePath? = nil, @@ -174,12 +175,30 @@ public struct BuildPlanResult { self.plan = plan } - public func checkTargetsCount(_ count: Int, file: StaticString = #file, line: UInt = #line) { - XCTAssertEqual(self.targetMap.count, count, file: file, line: line) + public func checkTargetsCount( + _ count: Int, + file: StaticString = #file, + line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation, + ) { + if Test.current != nil { + #expect(self.targetMap.count == count, sourceLocation: sourceLocation) + } else { + XCTAssertEqual(self.targetMap.count, count, file: file, line: line) + } } - public func checkProductsCount(_ count: Int, file: StaticString = #file, line: UInt = #line) { - XCTAssertEqual(self.productMap.count, count, file: file, line: line) + public func checkProductsCount( + _ count: Int, + file: StaticString = #file, + line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation, + ) { + if Test.current != nil { + #expect(self.productMap.count == count, sourceLocation: sourceLocation) + } else { + XCTAssertEqual(self.productMap.count, count, file: file, line: line) + } } public func moduleBuildDescription(for name: String) throws -> Build.ModuleBuildDescription { diff --git a/Sources/_InternalBuildTestSupport/PIFTester.swift b/Sources/_InternalBuildTestSupport/PIFTester.swift index 85b64cbf6e5..24fd1e7b716 100644 --- a/Sources/_InternalBuildTestSupport/PIFTester.swift +++ b/Sources/_InternalBuildTestSupport/PIFTester.swift @@ -13,6 +13,7 @@ import Basics import XCBuildSupport import XCTest +import Testing public func PIFTester(_ pif: PIF.TopLevelObject, _ body: (PIFWorkspaceTester) throws -> Void) throws { try body(PIFWorkspaceTester(workspace: pif.workspace)) @@ -36,10 +37,16 @@ public final class PIFWorkspaceTester { _ guid: PIF.GUID, file: StaticString = #file, line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation, body: (PIFProjectTester) -> Void ) throws { guard let project = projectMap[guid] else { - return XCTFail("project \(guid) not found", file: file, line: line) + if Test.current != nil { + Issue.record("project \(guid) not found", sourceLocation: sourceLocation) + return + } else { + return XCTFail("project \(guid) not found", file: file, line: line) + } } body(try PIFProjectTester(project: project, targetMap: targetMap)) @@ -72,15 +79,26 @@ public final class PIFProjectTester { _ guid: PIF.GUID, file: StaticString = #file, line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation, body: ((PIFTargetTester) -> Void)? = nil ) { guard let baseTarget = baseTarget(withGUID: guid) else { let guids = project.targets.map { $0.guid }.joined(separator: ", ") - return XCTFail("target \(guid) not found among \(guids)", file: file, line: line) + if Test.current != nil { + Issue.record("target \(guid) not found among \(guids)", sourceLocation: sourceLocation) + return + } else { + return XCTFail("target \(guid) not found among \(guids)", file: file, line: line) + } } guard let target = baseTarget as? PIF.Target else { - return XCTFail("target \(guid) is not a standard target", file: file, line: line) + if Test.current != nil { + Issue.record("target \(guid) is not a standard target", sourceLocation: sourceLocation) + return + } else { + return XCTFail("target \(guid) is not a standard target", file: file, line: line) + } } body?(PIFTargetTester(target: target, targetMap: targetMap, fileMap: fileMap)) @@ -90,10 +108,15 @@ public final class PIFProjectTester { _ guid: PIF.GUID, file: StaticString = #file, line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation, body: ((PIFTargetTester) -> Void)? = nil ) { if baseTarget(withGUID: guid) != nil { - XCTFail("target \(guid) found", file: file, line: line) + if Test.current != nil { + Issue.record("target \(guid) found", sourceLocation: sourceLocation) + } else { + XCTFail("target \(guid) found", file: file, line: line) + } } } @@ -101,15 +124,26 @@ public final class PIFProjectTester { _ guid: PIF.GUID, file: StaticString = #file, line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation, body: ((PIFAggregateTargetTester) -> Void)? = nil ) { guard let baseTarget = baseTarget(withGUID: guid) else { let guids = project.targets.map { $0.guid }.joined(separator: ", ") - return XCTFail("target \(guid) not found among \(guids)", file: file, line: line) + if Test.current != nil { + Issue.record("target \(guid) not found among \(guids)", sourceLocation: sourceLocation) + return + } else { + return XCTFail("target \(guid) not found among \(guids)", file: file, line: line) + } } guard let target = baseTarget as? PIF.AggregateTarget else { - return XCTFail("target \(guid) is not an aggregate target", file: file, line: line) + if Test.current != nil { + Issue.record("target \(guid) is not an aggregate target", sourceLocation: sourceLocation) + return + } else { + return XCTFail("target \(guid) is not an aggregate target", file: file, line: line) + } } body?(PIFAggregateTargetTester(target: target, targetMap: targetMap, fileMap: fileMap)) @@ -119,11 +153,17 @@ public final class PIFProjectTester { _ name: String, file: StaticString = #file, line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation, body: (PIFBuildConfigurationTester) -> Void ) { guard let configuration = buildConfiguration(withName: name) else { let names = project.buildConfigurations.map { $0.name }.joined(separator: ", ") - return XCTFail("build configuration \(name) not found among \(names)", file: file, line: line) + if Test.current != nil { + Issue.record("build configuration \(name) not found among \(names)", sourceLocation: sourceLocation) + return + } else { + return XCTFail("build configuration \(name) not found among \(names)", file: file, line: line) + } } body(PIFBuildConfigurationTester(buildConfiguration: configuration)) @@ -185,10 +225,16 @@ public class PIFBaseTargetTester { _ name: String, file: StaticString = #file, line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation, body: (PIFBuildConfigurationTester) -> Void ) { guard let configuration = buildConfiguration(withName: name) else { - return XCTFail("build configuration \(name) not found", file: file, line: line) + if Test.current != nil { + Issue.record("build configuration \(name) not found", sourceLocation: #_sourceLocation) + return + } else { + return XCTFail("build configuration \(name) not found", file: file, line: line) + } } body(PIFBuildConfigurationTester(buildConfiguration: configuration)) @@ -319,20 +365,42 @@ public final class PIFBuildSettingsTester { } } - public func checkUncheckedSettings(file: StaticString = #file, line: UInt = #line) { + public func checkUncheckedSettings(file: StaticString = #file, line: UInt = #line, sourceLocation: SourceLocation = #_sourceLocation) { let uncheckedKeys = Array(buildSettings.singleValueSettings.keys.map { $0.rawValue }) + Array(buildSettings.multipleValueSettings.keys.map { $0.rawValue }) - XCTAssert(uncheckedKeys.isEmpty, "settings are left unchecked: \(uncheckedKeys)", file: file, line: line) + if Test.current != nil { + #expect( + uncheckedKeys.isEmpty, + "settings are left unchecked: \(uncheckedKeys)", + sourceLocation: sourceLocation, + ) + } else { + XCTAssert(uncheckedKeys.isEmpty, "settings are left unchecked: \(uncheckedKeys)", file: file, line: line) + } for (platform, settings) in buildSettings.platformSpecificSingleValueSettings { let uncheckedKeys = Array(settings.keys.map { $0.rawValue }) - XCTAssert(uncheckedKeys.isEmpty, "\(platform) settings are left unchecked: \(uncheckedKeys)", file: file, line: line) + if Test.current != nil { + #expect( + uncheckedKeys.isEmpty, "\(platform) settings are left unchecked: \(uncheckedKeys)", + sourceLocation: sourceLocation, + ) + } else { + XCTAssert(uncheckedKeys.isEmpty, "\(platform) settings are left unchecked: \(uncheckedKeys)", file: file, line: line) + } } for (platform, settings) in buildSettings.platformSpecificMultipleValueSettings { let uncheckedKeys = Array(settings.keys.map { $0.rawValue }) - XCTAssert(uncheckedKeys.isEmpty, "\(platform) settings are left unchecked: \(uncheckedKeys)", file: file, line: line) + if Test.current != nil { + #expect( + uncheckedKeys.isEmpty, "\(platform) settings are left unchecked: \(uncheckedKeys)", + sourceLocation: sourceLocation, + ) + } else { + XCTAssert(uncheckedKeys.isEmpty, "\(platform) settings are left unchecked: \(uncheckedKeys)", file: file, line: line) + } } } } diff --git a/Sources/_InternalTestSupport/MockBuildTestHelper.swift b/Sources/_InternalTestSupport/MockBuildTestHelper.swift index 3738791f3cd..78820551777 100644 --- a/Sources/_InternalTestSupport/MockBuildTestHelper.swift +++ b/Sources/_InternalTestSupport/MockBuildTestHelper.swift @@ -18,7 +18,6 @@ import struct PackageGraph.ResolvedProduct import PackageModel import SPMBuildCore import TSCUtility -import XCTest public struct MockToolchain: PackageModel.Toolchain { #if os(Windows) diff --git a/Sources/_InternalTestSupport/MockDependencyGraph.swift b/Sources/_InternalTestSupport/MockDependencyGraph.swift index 6a17968ab0b..b182e216bee 100644 --- a/Sources/_InternalTestSupport/MockDependencyGraph.swift +++ b/Sources/_InternalTestSupport/MockDependencyGraph.swift @@ -10,6 +10,7 @@ // //===----------------------------------------------------------------------===// import XCTest +import Testing import PackageGraph import PackageModel @@ -32,15 +33,30 @@ public struct MockDependencyGraph { public func checkResult( _ output: [(container: PackageReference, version: Version)], file: StaticString = #file, - line: UInt = #line + line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation ) { var result = self.result for item in output { - XCTAssertEqual(result[item.container], item.version, file: file, line: line) + if Test.current != nil { + #expect( + result[item.container] == item.version, + sourceLocation: sourceLocation, + ) + } else { + XCTAssertEqual(result[item.container], item.version, file: file, line: line) + } result[item.container] = nil } if !result.isEmpty { - XCTFail("Unchecked containers: \(result)", file: file, line: line) + if Test.current != nil { + Issue.record( + "Unchecked containers: \(result)", + sourceLocation: sourceLocation, + ) + } else { + XCTFail("Unchecked containers: \(result)", file: file, line: line) + } } } } diff --git a/Sources/_InternalTestSupport/MockPackageContainer.swift b/Sources/_InternalTestSupport/MockPackageContainer.swift index 9780ae4ab10..92a243f98c0 100644 --- a/Sources/_InternalTestSupport/MockPackageContainer.swift +++ b/Sources/_InternalTestSupport/MockPackageContainer.swift @@ -15,7 +15,6 @@ import Dispatch import PackageGraph import PackageModel import SourceControl -import XCTest import struct TSCUtility.Version diff --git a/Sources/_InternalTestSupport/XCTAssertHelpers.swift b/Sources/_InternalTestSupport/XCTAssertHelpers.swift index dcce28dbfc0..822a305a6b2 100644 --- a/Sources/_InternalTestSupport/XCTAssertHelpers.swift +++ b/Sources/_InternalTestSupport/XCTAssertHelpers.swift @@ -19,6 +19,7 @@ import SPMBuildCore import enum PackageModel.BuildConfiguration import TSCTestSupport import XCTest +import Testing import struct Basics.AsyncProcessResult @@ -29,21 +30,31 @@ import struct TSCUtility.Version @_exported import func TSCTestSupport.XCTAssertResultSuccess @_exported import func TSCTestSupport.XCTAssertThrows +fileprivate func swiftTestingTestCalledAnXCTestAPI() { + if Test.current != nil { + Issue.record("Swift Testing Test called a XCTest API.") + } +} + public func XCTAssertFileExists(_ path: AbsolutePath, file: StaticString = #file, line: UInt = #line) { TSCTestSupport.XCTAssertFileExists(TSCAbsolutePath(path), file: file, line: line) + swiftTestingTestCalledAnXCTestAPI() } public func XCTAssertDirectoryExists(_ path: AbsolutePath, file: StaticString = #file, line: UInt = #line) { TSCTestSupport.XCTAssertDirectoryExists(TSCAbsolutePath(path), file: file, line: line) + swiftTestingTestCalledAnXCTestAPI() } public func XCTAssertNoSuchPath(_ path: AbsolutePath, file: StaticString = #file, line: UInt = #line) { TSCTestSupport.XCTAssertNoSuchPath(TSCAbsolutePath(path), file: file, line: line) + swiftTestingTestCalledAnXCTestAPI() } public func XCTAssertEqual (_ lhs:(T,U), _ rhs:(T,U), file: StaticString = #file, line: UInt = #line) { TSCTestSupport.XCTAssertEqual(lhs, rhs, file: file, line: line) + swiftTestingTestCalledAnXCTestAPI() } public func XCTSkipIfPlatformCI(because reason: String? = nil, file: StaticString = #filePath, line: UInt = #line) throws { @@ -52,6 +63,7 @@ public func XCTSkipIfPlatformCI(because reason: String? = nil, file: StaticStrin let failureCause = reason ?? "Skipping because the test is being run on CI" throw XCTSkip(failureCause, file: file, line: line) } + swiftTestingTestCalledAnXCTestAPI() } public func XCTSkipIfselfHostedCI(because reason: String, file: StaticString = #filePath, line: UInt = #line) throws { @@ -59,9 +71,11 @@ public func XCTSkipIfselfHostedCI(because reason: String, file: StaticString = # if CiEnvironment.runningInSelfHostedPipeline { throw XCTSkip(reason, file: file, line: line) } + swiftTestingTestCalledAnXCTestAPI() } public func XCTSkipOnWindows(because reason: String? = nil, skipPlatformCi: Bool = false, skipSelfHostedCI: Bool = false , file: StaticString = #filePath, line: UInt = #line) throws { + swiftTestingTestCalledAnXCTestAPI() #if os(Windows) let failureCause: String if let reason { @@ -99,6 +113,7 @@ public func XCTRequires( file: StaticString = #filePath, line: UInt = #line ) throws { + swiftTestingTestCalledAnXCTestAPI() do { try _requiresTools(executable) @@ -109,6 +124,7 @@ public func XCTRequires( } public func XCTSkipIfCompilerLessThan6_2() throws { + swiftTestingTestCalledAnXCTestAPI() #if compiler(>=6.2) #else throw XCTSkip("Skipping as compiler version is less thann 6.2") @@ -123,6 +139,7 @@ public func XCTAssertAsyncThrowsError( line: UInt = #line, _ errorHandler: (_ error: any Error) -> Void = { _ in } ) async { + swiftTestingTestCalledAnXCTestAPI() do { _ = try await expression() XCTFail(message(), file: file, line: line) @@ -137,6 +154,7 @@ package func XCTAssertAsyncNoThrow( file: StaticString = #filePath, line: UInt = #line ) async { + swiftTestingTestCalledAnXCTestAPI() do { _ = try await expression() } catch { @@ -156,6 +174,7 @@ public func XCTAssertBuilds( line: UInt = #line, buildSystem: BuildSystemProvider.Kind, ) async { + swiftTestingTestCalledAnXCTestAPI() for conf in configurations { await XCTAssertAsyncNoThrow( try await executeSwiftBuild( @@ -186,6 +205,7 @@ public func XCTAssertSwiftTest( line: UInt = #line, buildSystem: BuildSystemProvider.Kind, ) async { + swiftTestingTestCalledAnXCTestAPI() await XCTAssertAsyncNoThrow( try await executeSwiftTest( path, @@ -213,6 +233,7 @@ public func XCTAssertBuildFails( line: UInt = #line, buildSystem: BuildSystemProvider.Kind, ) async -> CommandExecutionError? { + swiftTestingTestCalledAnXCTestAPI() var failure: CommandExecutionError? = nil await XCTAssertThrowsCommandExecutionError( try await executeSwiftBuild( @@ -236,6 +257,7 @@ public func XCTAssertEqual( file: StaticString = #file, line: UInt = #line ) where T: Hashable { + swiftTestingTestCalledAnXCTestAPI() var actual = [T: Version]() for (identifier, binding) in assignment { actual[identifier] = binding @@ -249,6 +271,7 @@ public func XCTAssertAsyncTrue( file: StaticString = #filePath, line: UInt = #line ) async rethrows { + swiftTestingTestCalledAnXCTestAPI() let result = try await expression() XCTAssertTrue(result, message(), file: file, line: line) } @@ -259,6 +282,7 @@ public func XCTAssertAsyncFalse( file: StaticString = #filePath, line: UInt = #line ) async rethrows { + swiftTestingTestCalledAnXCTestAPI() let result = try await expression() XCTAssertFalse(result, message(), file: file, line: line) } @@ -269,6 +293,7 @@ package func XCTAssertAsyncNil( file: StaticString = #filePath, line: UInt = #line ) async rethrows { + swiftTestingTestCalledAnXCTestAPI() let result = try await expression() XCTAssertNil(result, message(), file: file, line: line) } @@ -280,6 +305,7 @@ public func XCTAssertThrowsCommandExecutionError( line: UInt = #line, _ errorHandler: (_ error: CommandExecutionError) -> Void = { _ in } ) async { + swiftTestingTestCalledAnXCTestAPI() await XCTAssertAsyncThrowsError(try await expression(), message(), file: file, line: line) { error in guard case SwiftPMError.executionFailure(let processError, let stdout, let stderr) = error, case AsyncProcessResult.Error.nonZeroExit(let processResult) = processError, @@ -297,6 +323,7 @@ public func XCTAssertAsyncEqual( file: StaticString = #file, line: UInt = #line ) async rethrows { + swiftTestingTestCalledAnXCTestAPI() let value1 = try await expression1() let value2 = try await expression2() @@ -311,6 +338,7 @@ public func XCTAsyncUnwrap( file: StaticString = #filePath, line: UInt = #line ) async throws -> T { + swiftTestingTestCalledAnXCTestAPI() guard let result = try await expression() else { throw XCAsyncTestErrorWhileUnwrappingOptional() } diff --git a/Sources/_InternalTestSupport/misc.swift b/Sources/_InternalTestSupport/misc.swift index e04298e5f47..944a9b1c079 100644 --- a/Sources/_InternalTestSupport/misc.swift +++ b/Sources/_InternalTestSupport/misc.swift @@ -135,7 +135,7 @@ public func testWithTemporaryDirectory( try? localFileSystem.removeFileTree(tmpDirPath) } - let fixtureDir = try verifyFixtureExistsXCTest(at: fixtureSubpath, file: file, line: line) + let fixtureDir = try verifyFixtureExists(at: fixtureSubpath, file: file, line: line) let preparedFixture = try setup( fixtureDir: fixtureDir, in: tmpDirPath, @@ -156,6 +156,8 @@ public func testWithTemporaryDirectory( name: String, createGitRepo: Bool = true, removeFixturePathOnDeinit: Bool = true, + file: StaticString = #file, + line: UInt = #line, sourceLocation: SourceLocation = #_sourceLocation, body: (AbsolutePath) throws -> T ) throws -> T { @@ -221,7 +223,7 @@ public enum TestError: Error { try? localFileSystem.removeFileTree(tmpDirPath) } - let fixtureDir = try verifyFixtureExistsXCTest(at: fixtureSubpath, file: file, line: line) + let fixtureDir = try verifyFixtureExists(at: fixtureSubpath, file: file, line: line) let preparedFixture = try setup( fixtureDir: fixtureDir, in: tmpDirPath, @@ -242,6 +244,8 @@ public enum TestError: Error { name: String, createGitRepo: Bool = true, removeFixturePathOnDeinit: Bool = true, + file: StaticString = #file, + line: UInt = #line, sourceLocation: SourceLocation = #_sourceLocation, body: (AbsolutePath) async throws -> T ) async throws -> T { @@ -264,7 +268,7 @@ public enum TestError: Error { } } - let fixtureDir = try verifyFixtureExists(at: fixtureSubpath, sourceLocation: sourceLocation) + let fixtureDir = try verifyFixtureExists(at: fixtureSubpath, file: file, line: line, sourceLocation: sourceLocation) let preparedFixture = try setup( fixtureDir: fixtureDir, in: tmpDirPath, @@ -281,26 +285,25 @@ public enum TestError: Error { } } -fileprivate func verifyFixtureExistsXCTest(at fixtureSubpath: RelativePath, file: StaticString = #file, line: UInt = #line) throws -> AbsolutePath { - let fixtureDir = AbsolutePath("../../../Fixtures", relativeTo: #file) - .appending(fixtureSubpath) - - // Check that the fixture is really there. - guard localFileSystem.isDirectory(fixtureDir) else { - XCTFail("No such fixture: \(fixtureDir)", file: file, line: line) - throw SwiftPMError.packagePathNotFound - } - - return fixtureDir -} - -fileprivate func verifyFixtureExists(at fixtureSubpath: RelativePath, sourceLocation: SourceLocation = #_sourceLocation) throws -> AbsolutePath { +fileprivate func verifyFixtureExists( + at fixtureSubpath: RelativePath, + file: StaticString = #file, + line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation, +) throws -> AbsolutePath { let fixtureDir = AbsolutePath("../../../Fixtures", relativeTo: #file) .appending(fixtureSubpath) // Check that the fixture is really there. guard localFileSystem.isDirectory(fixtureDir) else { - Issue.record("No such fixture: \(fixtureDir)", sourceLocation: sourceLocation) + if Test.current != nil { + Issue.record( + "No such fixture: \(fixtureDir)", + sourceLocation: sourceLocation, + ) + } else { + XCTFail("No such fixture: \(fixtureDir)", file: file, line: line) + } throw SwiftPMError.packagePathNotFound } @@ -365,7 +368,8 @@ public func initGitRepo( tags: [String], addFile: Bool = true, file: StaticString = #file, - line: UInt = #line + line: UInt = #line, + sourceLocation: SourceLocation = #_sourceLocation, ) { do { if addFile { @@ -385,7 +389,14 @@ public func initGitRepo( } try Process.checkNonZeroExit(args: Git.tool, "-C", dir.pathString, "branch", "-m", "main") } catch { - XCTFail("\(error.interpolationDescription)", file: file, line: line) + if Test.current != nil { + Issue.record( + "\(error.interpolationDescription)", + sourceLocation: sourceLocation, + ) + } else { + XCTFail("\(error.interpolationDescription)", file: file, line: line) + } } } diff --git a/Tests/BuildTests/BuildPlanTests.swift b/Tests/BuildTests/BuildPlanTests.swift index 75c84a396c3..f6998aa326e 100644 --- a/Tests/BuildTests/BuildPlanTests.swift +++ b/Tests/BuildTests/BuildPlanTests.swift @@ -32,6 +32,7 @@ import SwiftDriver import TSCTestSupport import Workspace import XCTest +import Testing import struct TSCBasic.ByteString import func TSCBasic.withTemporaryFile diff --git a/Tests/BuildTests/CrossCompilationBuildPlanTests.swift b/Tests/BuildTests/CrossCompilationBuildPlanTests.swift index 27a5412487b..0388f0ad0aa 100644 --- a/Tests/BuildTests/CrossCompilationBuildPlanTests.swift +++ b/Tests/BuildTests/CrossCompilationBuildPlanTests.swift @@ -37,6 +37,7 @@ import func _InternalTestSupport.XCTAssertMatch import func _InternalTestSupport.XCTAssertNoDiagnostics import XCTest +import Testing final class CrossCompilationBuildPlanTests: XCTestCase { func testEmbeddedWasmTarget() async throws { diff --git a/Tests/BuildTests/ModuleAliasingBuildTests.swift b/Tests/BuildTests/ModuleAliasingBuildTests.swift index 32974186802..8fdef715f6c 100644 --- a/Tests/BuildTests/ModuleAliasingBuildTests.swift +++ b/Tests/BuildTests/ModuleAliasingBuildTests.swift @@ -24,6 +24,7 @@ import _InternalTestSupport import SwiftDriver import Workspace import XCTest +import Testing final class ModuleAliasingBuildTests: XCTestCase { func testModuleAliasingEmptyAlias() async throws { diff --git a/Tests/BuildTests/PluginInvocationTests.swift b/Tests/BuildTests/PluginInvocationTests.swift index 63c13ff831b..7569ed86851 100644 --- a/Tests/BuildTests/PluginInvocationTests.swift +++ b/Tests/BuildTests/PluginInvocationTests.swift @@ -26,6 +26,7 @@ import _InternalBuildTestSupport import _InternalTestSupport import Workspace import XCTest +import Testing @testable import class Build.BuildPlan import struct Build.PluginConfiguration diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index 0c6cf09e023..c063a9cb029 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -5526,6 +5526,10 @@ struct PackageCommandTests { "https://github.com/swiftlang/swift-package-manager/issues/8782", relationship: .defect ), + .issue( + "https://github.com/swiftlang/swift-package-manager/issues/9090", + relationship: .defect, + ), .requiresSwiftConcurrencySupport, .tags( .Feature.Command.Package.CommandPlugin, @@ -5639,7 +5643,11 @@ struct PackageCommandTests { configuration: data.config, buildSystem: data.buildSystem, ) - #expect(stdout.contains("successfully created it")) + withKnownIssue(isIntermittent: true) { + #expect(stdout.contains("successfully created it")) + } when: { + ProcessInfo.hostOperatingSystem == .windows && data.buildSystem == .native && data.config == .release + } #expect(!stderr.contains("error: Couldn’t create file at path")) } diff --git a/Tests/PackageGraphPerformanceTests/DependencyResolverPerfTests.swift b/Tests/PackageGraphPerformanceTests/DependencyResolverPerfTests.swift index 1734bd99356..a29c688f3e1 100644 --- a/Tests/PackageGraphPerformanceTests/DependencyResolverPerfTests.swift +++ b/Tests/PackageGraphPerformanceTests/DependencyResolverPerfTests.swift @@ -18,6 +18,7 @@ import PackageModel import SourceControl import _InternalTestSupport import XCTest +import Testing import enum TSCBasic.JSON import protocol TSCBasic.JSONMappable diff --git a/Tests/PackageRegistryTests/RegistryClientTests.swift b/Tests/PackageRegistryTests/RegistryClientTests.swift index ab189d8aaae..f47004e81f1 100644 --- a/Tests/PackageRegistryTests/RegistryClientTests.swift +++ b/Tests/PackageRegistryTests/RegistryClientTests.swift @@ -3697,8 +3697,8 @@ fileprivate var availabilityURL = URL("\(registryURL)/availability") httpClient: httpClient ) - await XCTAssertAsyncThrowsError(try await registryClient.checkAvailability(registry: registry)) { error in - #expect(error as? StringError == StringError("registry \(registry.url) does not support availability checks.")) + await #expect(throws: StringError("registry \(registry.url) does not support availability checks.")) { + try await registryClient.checkAvailability(registry: registry) } } } diff --git a/Tests/XCBuildSupportTests/PIFBuilderTests.swift b/Tests/XCBuildSupportTests/PIFBuilderTests.swift index 4e28ea1c57f..d3ca5fc8bbe 100644 --- a/Tests/XCBuildSupportTests/PIFBuilderTests.swift +++ b/Tests/XCBuildSupportTests/PIFBuilderTests.swift @@ -23,6 +23,7 @@ import _InternalTestSupport import _InternalBuildTestSupport @testable import XCBuildSupport import XCTest +import Testing final class PIFBuilderTests: XCTestCase { let inputsDir = AbsolutePath(#file).parentDirectory.appending(components: "Inputs") From 0df40c4f1bbf110da7f03251a0bc248ea0dd469d Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 10 Sep 2025 10:06:43 -0400 Subject: [PATCH 113/225] refactored tests + added missing ones --- Sources/Commands/PackageCommands/Init.swift | 102 ++++++++- .../PackageCommands/ShowTemplates.swift | 12 +- .../PackageInitializer.swift | 15 -- .../RequirementResolver.swift | 7 +- Tests/CommandsTests/TemplateTests.swift | 211 +++++++++++++++--- 5 files changed, 289 insertions(+), 58 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 1b082e5a900..f93d8c16a35 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -202,6 +202,11 @@ struct PackageInitConfiguration { throw InternalError("Could not find the current working directory") } + let manifest = cwd.appending(component: Manifest.filename) + guard !swiftCommandState.fileSystem.exists(manifest) else { + throw InitError.manifestAlreadyExists + } + self.cwd = cwd self.packageName = name ?? cwd.basename self.swiftCommandState = swiftCommandState @@ -215,14 +220,23 @@ struct PackageInitConfiguration { self.url = url self.packageID = packageID - let sourceResolver = DefaultTemplateSourceResolver() + let sourceResolver = DefaultTemplateSourceResolver(cwd: cwd, fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) + self.templateSource = sourceResolver.resolveSource( directory: directory, url: url, packageID: packageID ) + if templateSource != nil { + //we force wrap as we already do the the nil check. + do { + try sourceResolver.validate(templateSource: templateSource!, directory: self.directory, url: self.url, packageID: self.packageID) + } catch { + swiftCommandState.observabilityScope.emit(error) + } + self.versionResolver = DependencyRequirementResolver( packageIdentity: packageID, swiftCommandState: swiftCommandState, @@ -289,9 +303,21 @@ protocol TemplateSourceResolver { url: String?, packageID: String? ) -> InitTemplatePackage.TemplateSource? + + func validate( + templateSource: InitTemplatePackage.TemplateSource, + directory: Basics.AbsolutePath?, + url: String?, + packageID: String? + ) throws } public struct DefaultTemplateSourceResolver: TemplateSourceResolver { + + let cwd: AbsolutePath + let fileSystem: FileSystem + let observabilityScope: ObservabilityScope + func resolveSource( directory: Basics.AbsolutePath?, url: String?, @@ -302,6 +328,80 @@ public struct DefaultTemplateSourceResolver: TemplateSourceResolver { if directory != nil { return .local } return nil } + + func validate( + templateSource: InitTemplatePackage.TemplateSource, + directory: Basics.AbsolutePath?, + url: String?, + packageID: String? + ) throws { + switch templateSource { + case .git: + guard let url = url, isValidGitSource(url, fileSystem: fileSystem) else { + throw SourceResolverError.invalidGitURL(url ?? "nil") + } + + case .registry: + guard let packageID = packageID, isValidRegistryPackageIdentity(packageID) else { + throw SourceResolverError.invalidRegistryIdentity(packageID ?? "nil") + } + + case .local: + guard let directory = directory else { + throw SourceResolverError.missingLocalPath + } + + try isValidSwiftPackage(path: directory) + } + } + + private func isValidRegistryPackageIdentity(_ packageID: String) -> Bool { + return PackageIdentity.plain(packageID).isRegistry + } + + func isValidGitSource(_ input: String, fileSystem: FileSystem) -> Bool { + if input.hasPrefix("http://") || input.hasPrefix("https://") || input.hasPrefix("git@") || input.hasPrefix("ssh://") { + return true // likely a remote URL + } + + do { + let path = try AbsolutePath(validating: input) + if fileSystem.exists(path) { + let gitDir = path.appending(component: ".git") + return fileSystem.isDirectory(gitDir) + } + } catch { + return false + } + return false + } + + private func isValidSwiftPackage(path: AbsolutePath) throws { + if !fileSystem.exists(path) { + throw SourceResolverError.invalidDirectoryPath(path) + } + } + + enum SourceResolverError: Error, CustomStringConvertible, Equatable { + case invalidDirectoryPath(AbsolutePath) + case invalidGitURL(String) + case invalidRegistryIdentity(String) + case missingLocalPath + + var description: String { + switch self { + case .invalidDirectoryPath(let path): + return "Invalid local path: \(path) does not exist or is not accessible." + case .invalidGitURL(let url): + return "Invalid Git URL: \(url) is not a valid Git source." + case .invalidRegistryIdentity(let id): + return "Invalid registry package identity: \(id) is not a valid registry package." + case .missingLocalPath: + return "Missing local path for template source." + } + } + + } } extension InitPackage.PackageType: ExpressibleByArgument {} diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index f8d1de880eb..d84f355222e 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -74,18 +74,20 @@ struct ShowTemplates: AsyncSwiftCommand { func run(_ swiftCommandState: SwiftCommandState) async throws { - // precheck() needed, extremely similar to the Init precheck, can refactor possibly + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") + } - let cwd = swiftCommandState.fileSystem.currentWorkingDirectory - let source = try resolveSource(cwd: cwd) + // precheck() needed, extremely similar to the Init precheck, can refactor possibly + let source = try resolveSource(cwd: cwd, fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) let resolvedPath = try await resolveTemplatePath(using: swiftCommandState, source: source) let templates = try await loadTemplates(from: resolvedPath, swiftCommandState: swiftCommandState) try await displayTemplates(templates, at: resolvedPath, using: swiftCommandState) try cleanupTemplate(source: source, path: resolvedPath, fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) } - private func resolveSource(cwd: AbsolutePath?) throws -> InitTemplatePackage.TemplateSource { - guard let source = DefaultTemplateSourceResolver().resolveSource( + private func resolveSource(cwd: AbsolutePath, fileSystem: FileSystem, observabilityScope: ObservabilityScope) throws -> InitTemplatePackage.TemplateSource { + guard let source = DefaultTemplateSourceResolver(cwd: cwd, fileSystem: fileSystem, observabilityScope: observabilityScope).resolveSource( directory: cwd, url: self.templateURL, packageID: self.templatePackageID diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index 1026a5c7d13..a37a7f16eb1 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -35,7 +35,6 @@ struct TemplatePackageInitializer: PackageInitializer { func run() async throws { do { - try precheck() var sourceControlRequirement: PackageDependency.SourceControl.Requirement? var registryRequirement: PackageDependency.Registry.Requirement? @@ -123,17 +122,6 @@ struct TemplatePackageInitializer: PackageInitializer { } //Will have to add checking for git + registry too - private func precheck() throws { - let manifest = cwd.appending(component: Manifest.filename) - guard !swiftCommandState.fileSystem.exists(manifest) else { - throw InitError.manifestAlreadyExists - } - - if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { - throw TemplatePackageInitializerError.templateDirectoryNotFound(dir.pathString) - } - } - static func inferPackageType(from templatePath: Basics.AbsolutePath, templateName: String?, swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType { try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { workspace, root in let rootManifests = try await workspace.loadRootManifests( @@ -204,7 +192,6 @@ struct TemplatePackageInitializer: PackageInitializer { } enum TemplatePackageInitializerError: Error, CustomStringConvertible { - case templateDirectoryNotFound(String) case invalidManifestInTemplate(String) case templateNotFound(String) case noTemplatesInManifest @@ -212,8 +199,6 @@ struct TemplatePackageInitializer: PackageInitializer { var description: String { switch self { - case .templateDirectoryNotFound(let path): - return "The specified template path does not exist: \(path)" case .invalidManifestInTemplate(let path): return "Invalid manifest found in template at \(path)." case .templateNotFound(let templateName): diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index f6f478878bb..552d7aa180c 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -184,7 +184,7 @@ enum DependencyType { case registry } -enum DependencyRequirementError: Error, CustomStringConvertible { +enum DependencyRequirementError: Error, CustomStringConvertible, Equatable{ case multipleRequirementsSpecified case noRequirementSpecified case invalidToParameterWithoutFrom @@ -206,5 +206,10 @@ enum DependencyRequirementError: Error, CustomStringConvertible { """ } } + + public static func == (_ lhs: Self, _ rhs: Self) -> Bool { + lhs.description == rhs.description + } + } diff --git a/Tests/CommandsTests/TemplateTests.swift b/Tests/CommandsTests/TemplateTests.swift index 715d5916a43..292adaa7169 100644 --- a/Tests/CommandsTests/TemplateTests.swift +++ b/Tests/CommandsTests/TemplateTests.swift @@ -41,10 +41,19 @@ import class Basics.AsyncProcess //maybe add tags - @Test func resolveSourceTests() { + @Test func resolveSourceTests() throws { + + let options = try GlobalOptions.parse([]) + + let tool = try SwiftCommandState.makeMockState(options: options) + + + guard let cwd = tool.fileSystem.currentWorkingDirectory else {return} + let fileSystem = tool.fileSystem + let observabilityScope = tool.observabilityScope + + let resolver = DefaultTemplateSourceResolver(cwd: cwd, fileSystem: fileSystem, observabilityScope: observabilityScope) - let resolver = DefaultTemplateSourceResolver() - let nilSource = resolver.resolveSource( directory: nil, url: nil, packageID: nil ) @@ -64,28 +73,120 @@ import class Basics.AsyncProcess directory: AbsolutePath("/fake/path/to/template"), url: "https://github.com/foo/bar", packageID: "foo.bar" ) #expect(gitSource == .git) + } + + @Test func testValidGitURL() async throws { + + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + let resolver = DefaultTemplateSourceResolver(cwd: tool.fileSystem.currentWorkingDirectory!, fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope) + + try resolver.validate(templateSource: .git, directory: nil, url: "https://github.com/apple/swift", packageID: nil) + + // Check that nothing was emitted (i.e., no error for valid URL) + #expect(tool.observabilityScope.errorsReportedInAnyScope == false) } - @Test func resolveRegistryDependencyTests() throws { + @Test func testInvalidGitURL() throws { + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + let resolver = DefaultTemplateSourceResolver(cwd: tool.fileSystem.currentWorkingDirectory!, fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope) + + #expect(throws: DefaultTemplateSourceResolver.SourceResolverError.invalidGitURL("invalid-url").self) { + try resolver.validate(templateSource: .git, directory: nil, url: "invalid-url", packageID: nil) + } + } + + @Test func testValidRegistryID() throws { + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + let resolver = DefaultTemplateSourceResolver(cwd: tool.fileSystem.currentWorkingDirectory!, fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope) + + try resolver.validate(templateSource: .registry, directory: nil, url: nil, packageID: "mona.LinkedList") + + // Check that nothing was emitted (i.e., no error for valid URL) + #expect(tool.observabilityScope.errorsReportedInAnyScope == false) + } + + @Test func testInvalidRegistryID() throws { + + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + let resolver = DefaultTemplateSourceResolver(cwd: tool.fileSystem.currentWorkingDirectory!, fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope) + + #expect(throws: DefaultTemplateSourceResolver.SourceResolverError.invalidRegistryIdentity("invalid-id").self) { + try resolver.validate(templateSource: .registry, directory: nil, url: nil, packageID: "invalid-id") + } + } + + @Test func testlocalMissingPath() throws { + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + let resolver = DefaultTemplateSourceResolver(cwd: tool.fileSystem.currentWorkingDirectory!, fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope) + + #expect(throws: DefaultTemplateSourceResolver.SourceResolverError.missingLocalPath.self) { + try resolver.validate(templateSource: .local, directory: nil, url: nil, packageID: nil) + } + } + + @Test func testInvalidPath() throws { + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + let resolver = DefaultTemplateSourceResolver(cwd: tool.fileSystem.currentWorkingDirectory!, fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope) + + #expect(throws: DefaultTemplateSourceResolver.SourceResolverError.invalidDirectoryPath("/fake/path/that/does/not/exist").self) { + try resolver.validate(templateSource: .local, directory: "/fake/path/that/does/not/exist", url: nil, packageID: nil) + } + } + + @Test func resolveRegistryDependencyWithNoVersion() async throws { + + /* Need to do mock up of registry for this + // if exact, from, upToNextMinorFrom and to are nil, then should return nil + let nilRegistryDependency = try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: nil, + revision: "revision", + branch: "branch", + from: nil, + upToNextMinorFrom: nil, + to: nil, + ).resolveRegistry() + + #expect(nilRegistryDependency == nil) + */ + + //TODO: Set it up + } + + @Test func resolveRegistryDependencyTests() async throws { + + let options = try GlobalOptions.parse([]) + + let tool = try SwiftCommandState.makeMockState(options: options) let lowerBoundVersion = Version(stringLiteral: "1.2.0") let higherBoundVersion = Version(stringLiteral: "3.0.0") - // if exact, from, upToNextMinorFrom and to are nil, then should return nil - let nilRegistryDependency = try DependencyRequirementResolver( - exact: nil, - revision: "revision", - branch: "branch", - from: nil, - upToNextMinorFrom: nil, - to: nil - ).resolveRegistry() - - #expect(nilRegistryDependency == nil) + await #expect(throws: DependencyRequirementError.noRequirementSpecified.self) { + try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: nil, + revision: "revision", + branch: "branch", + from: nil, + upToNextMinorFrom: nil, + to: nil, + ).resolveRegistry() + } // test exact specification - let exactRegistryDependency = try DependencyRequirementResolver( + let exactRegistryDependency = try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: lowerBoundVersion, revision: nil, branch: nil, @@ -98,7 +199,9 @@ import class Basics.AsyncProcess // test from to - let fromToRegistryDependency = try DependencyRequirementResolver( + let fromToRegistryDependency = try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: nil, revision: nil, branch: nil, @@ -110,7 +213,9 @@ import class Basics.AsyncProcess #expect(fromToRegistryDependency == PackageDependency.Registry.Requirement.range(lowerBoundVersion ..< higherBoundVersion)) // test up-to-next-minor-from and to - let upToNextMinorFromToRegistryDependency = try DependencyRequirementResolver( + let upToNextMinorFromToRegistryDependency = try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: nil, revision: nil, branch: nil, @@ -122,7 +227,9 @@ import class Basics.AsyncProcess #expect(upToNextMinorFromToRegistryDependency == PackageDependency.Registry.Requirement.range(lowerBoundVersion ..< higherBoundVersion)) // test just from - let fromRegistryDependency = try DependencyRequirementResolver( + let fromRegistryDependency = try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: nil, revision: nil, branch: nil, @@ -134,7 +241,9 @@ import class Basics.AsyncProcess #expect(fromRegistryDependency == PackageDependency.Registry.Requirement.range(.upToNextMajor(from: lowerBoundVersion))) // test just up-to-next-minor-from - let upToNextMinorFromRegistryDependency = try DependencyRequirementResolver( + let upToNextMinorFromRegistryDependency = try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: nil, revision: nil, branch: nil, @@ -146,8 +255,10 @@ import class Basics.AsyncProcess #expect(upToNextMinorFromRegistryDependency == PackageDependency.Registry.Requirement.range(.upToNextMinor(from: lowerBoundVersion))) - #expect(throws: DependencyRequirementError.multipleRequirementsSpecified.self) { - try DependencyRequirementResolver( + await #expect(throws: DependencyRequirementError.multipleRequirementsSpecified.self) { + try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: lowerBoundVersion, revision: nil, branch: nil, @@ -157,8 +268,10 @@ import class Basics.AsyncProcess ).resolveRegistry() } - #expect(throws: DependencyRequirementError.noRequirementSpecified.self) { - try DependencyRequirementResolver( + await #expect(throws: DependencyRequirementError.noRequirementSpecified.self) { + try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: nil, revision: nil, branch: nil, @@ -168,8 +281,10 @@ import class Basics.AsyncProcess ).resolveRegistry() } - #expect(throws: DependencyRequirementError.invalidToParameterWithoutFrom.self) { - try DependencyRequirementResolver( + await #expect(throws: DependencyRequirementError.invalidToParameterWithoutFrom.self) { + try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: lowerBoundVersion, revision: nil, branch: nil, @@ -183,10 +298,16 @@ import class Basics.AsyncProcess // TODO: should we add edge cases to < from and from == from @Test func resolveSourceControlDependencyTests() throws { + let options = try GlobalOptions.parse([]) + + let tool = try SwiftCommandState.makeMockState(options: options) + let lowerBoundVersion = Version(stringLiteral: "1.2.0") let higherBoundVersion = Version(stringLiteral: "3.0.0") let branchSourceControlDependency = try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: nil, revision: nil, branch: "master", @@ -198,6 +319,8 @@ import class Basics.AsyncProcess #expect(branchSourceControlDependency == PackageDependency.SourceControl.Requirement.branch("master")) let revisionSourceControlDependency = try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: nil, revision: "dae86e", branch: nil, @@ -210,6 +333,8 @@ import class Basics.AsyncProcess // test exact specification let exactSourceControlDependency = try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: lowerBoundVersion, revision: nil, branch: nil, @@ -222,6 +347,8 @@ import class Basics.AsyncProcess // test from to let fromToSourceControlDependency = try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: nil, revision: nil, branch: nil, @@ -234,6 +361,8 @@ import class Basics.AsyncProcess // test up-to-next-minor-from and to let upToNextMinorFromToSourceControlDependency = try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: nil, revision: nil, branch: nil, @@ -246,6 +375,8 @@ import class Basics.AsyncProcess // test just from let fromSourceControlDependency = try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: nil, revision: nil, branch: nil, @@ -258,6 +389,8 @@ import class Basics.AsyncProcess // test just up-to-next-minor-from let upToNextMinorFromSourceControlDependency = try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: nil, revision: nil, branch: nil, @@ -271,6 +404,8 @@ import class Basics.AsyncProcess #expect(throws: DependencyRequirementError.multipleRequirementsSpecified.self) { try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: lowerBoundVersion, revision: "dae86e", branch: nil, @@ -282,6 +417,8 @@ import class Basics.AsyncProcess #expect(throws: DependencyRequirementError.noRequirementSpecified.self) { try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: nil, revision: nil, branch: nil, @@ -293,6 +430,8 @@ import class Basics.AsyncProcess #expect(throws: DependencyRequirementError.invalidToParameterWithoutFrom.self) { try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, exact: lowerBoundVersion, revision: nil, branch: nil, @@ -393,7 +532,7 @@ import class Basics.AsyncProcess // Build it. TODO: CHANGE THE XCTAsserts build to the swift testing helper function instead await XCTAssertBuilds(stagingPath) - + let stagingBuildPath = stagingPath.appending(".build") let binFile = stagingBuildPath.appending(components: try UserToolchain.default.targetTriple.platformBuildPathComponent, "debug", "generated-package") #expect(localFileSystem.exists(binFile)) @@ -465,20 +604,20 @@ import class Basics.AsyncProcess //TODO: Fix here, as mocking swiftCommandState resolves to linux triple, but if testing on Darwin, runs into precondition error. /* - @Test func inferInitialPackageType() async throws { + @Test func inferInitialPackageType() async throws { - try await fixture(name: "Miscellaneous/InferPackageType") { fixturePath in + try await fixture(name: "Miscellaneous/InferPackageType") { fixturePath in - let options = try GlobalOptions.parse([]) - let tool = try SwiftCommandState.makeMockState(options: options) + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) - let libraryType = try await TemplatePackageInitializer.inferPackageType(from: fixturePath, templateName: "initialTypeLibrary", swiftCommandState: tool) + let libraryType = try await TemplatePackageInitializer.inferPackageType(from: fixturePath, templateName: "initialTypeLibrary", swiftCommandState: tool) - - #expect(libraryType.rawValue == "library") - } - } + #expect(libraryType.rawValue == "library") + } + + } */ } From 287669b11a3a36a45c7e8234d0a3857a406baac1 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 10 Sep 2025 11:45:28 -0400 Subject: [PATCH 114/225] refactored + fixed small implementation details + tests for make dependencies, and filled some previous test gaps --- .../PackageInitializer.swift | 2 +- .../TemplatePathResolver.swift | 51 ++--- Sources/PackageModel/DependencyMapper.swift | 2 +- Tests/CommandsTests/TemplateTests.swift | 199 +++++++++++++++++- 4 files changed, 224 insertions(+), 30 deletions(-) diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index a37a7f16eb1..622a4860817 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -186,7 +186,7 @@ struct TemplatePackageInitializer: PackageInitializer { destinationPath: stagingPath, installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration ) - try swiftCommandState.fileSystem.createDirectory(stagingPath, recursive: true) + try templatePackage.setupTemplateManifest() return templatePackage } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index ab246b8ab5e..49ef718a70b 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -201,7 +201,7 @@ struct GitTemplateFetcher: TemplateFetcher { if isSSHPermissionError(error) { throw GitTemplateFetcherError.sshAuthenticationRequired(source: source) } - throw GitTemplateFetcherError.cloneFailed(source: source, underlyingError: error) + throw GitTemplateFetcherError.cloneFailed(source: source) } } @@ -265,32 +265,35 @@ struct GitTemplateFetcher: TemplateFetcher { } } - enum GitTemplateFetcherError: Error, LocalizedError { - case cloneFailed(source: String, underlyingError: Error) - case invalidRepositoryDirectory(path: Basics.AbsolutePath) - case createWorkingCopyFailed(path: Basics.AbsolutePath, underlyingError: Error) - case checkoutFailed(requirement: PackageDependency.SourceControl.Requirement, underlyingError: Error) - case noMatchingTagInRange(Range) - case sshAuthenticationRequired(source: String) - - var errorDescription: String? { - switch self { - case .cloneFailed(let source, let error): - return "Failed to clone repository from '\(source)': \(error)" - case .invalidRepositoryDirectory(let path): - return "Invalid Git repository at path: \(path.pathString)" - case .createWorkingCopyFailed(let path, let error): - return "Failed to create working copy at '\(path)': \(error.localizedDescription)" - case .checkoutFailed(let requirement, let error): - return "Failed to checkout using requirement '\(requirement)': \(error.localizedDescription)" - case .noMatchingTagInRange(let range): - return "No Git tags found within version range \(range)" - case .sshAuthenticationRequired(let source): - return "SSH authentication required for '\(source)'.\nEnsure SSH agent is running and key is loaded:\n\nhttps://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent" - } + enum GitTemplateFetcherError: Error, LocalizedError, Equatable { + case cloneFailed(source: String) + case invalidRepositoryDirectory(path: Basics.AbsolutePath) + case createWorkingCopyFailed(path: Basics.AbsolutePath, underlyingError: Error) + case checkoutFailed(requirement: PackageDependency.SourceControl.Requirement, underlyingError: Error) + case noMatchingTagInRange(Range) + case sshAuthenticationRequired(source: String) + + var errorDescription: String? { + switch self { + case .cloneFailed(let source): + return "Failed to clone repository from '\(source)'" + case .invalidRepositoryDirectory(let path): + return "Invalid Git repository at path: \(path.pathString)" + case .createWorkingCopyFailed(let path, let error): + return "Failed to create working copy at '\(path)': \(error.localizedDescription)" + case .checkoutFailed(let requirement, let error): + return "Failed to checkout using requirement '\(requirement)': \(error.localizedDescription)" + case .noMatchingTagInRange(let range): + return "No Git tags found within version range \(range)" + case .sshAuthenticationRequired(let source): + return "SSH authentication required for '\(source)'.\nEnsure SSH agent is running and key is loaded:\n\nhttps://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent" } } + public static func == (lhs: GitTemplateFetcherError, rhs: GitTemplateFetcherError) -> Bool { + lhs.errorDescription == rhs.errorDescription + } + } } /// Fetches a Swift package template from a package registry. diff --git a/Sources/PackageModel/DependencyMapper.swift b/Sources/PackageModel/DependencyMapper.swift index 2d0245e12df..8dce054b00d 100644 --- a/Sources/PackageModel/DependencyMapper.swift +++ b/Sources/PackageModel/DependencyMapper.swift @@ -160,7 +160,7 @@ public struct MappablePackageDependency { ) } - public enum Kind { + public enum Kind: Equatable { case fileSystem(name: String?, path: String) case sourceControl(name: String?, location: String, requirement: PackageDependency.SourceControl.Requirement) case registry(id: String, requirement: PackageDependency.Registry.Requirement) diff --git a/Tests/CommandsTests/TemplateTests.swift b/Tests/CommandsTests/TemplateTests.swift index 292adaa7169..4296c95f66c 100644 --- a/Tests/CommandsTests/TemplateTests.swift +++ b/Tests/CommandsTests/TemplateTests.swift @@ -440,11 +440,20 @@ import class Basics.AsyncProcess to: higherBoundVersion ).resolveSourceControl() } - } - // test local - // test git - // test registry + let range = try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: nil, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: nil, + to: lowerBoundVersion + ).resolveSourceControl() + + #expect(range == .range(lowerBoundVersion ..< lowerBoundVersion)) + } @Test func localTemplatePathResolver() async throws { let mockTemplatePath = AbsolutePath("/fake/path/to/template") @@ -495,6 +504,37 @@ import class Basics.AsyncProcess } } + // Need to add traits of not running on windows, and CI + @Test func gitTemplatePathResolverWithInvalidURL() async throws { + + try await testWithTemporaryDirectory { path in + + let sourceControlRequirement = PackageDependency.SourceControl.Requirement.branch("main") + let options = try GlobalOptions.parse([]) + + let tool = try SwiftCommandState.makeMockState(options: options) + + let templateRepoPath = path.appending(component: "template-repo") + try! makeDirectories(templateRepoPath) + initGitRepo(templateRepoPath, tag: "1.2.3") + + let resolver = try TemplatePathResolver( + source: .git, + templateDirectory: nil, + templateURL: "invalid-git-url", + sourceControlRequirement: sourceControlRequirement, + registryRequirement: nil, + packageIdentity: nil, + swiftCommandState: tool + ) + + await #expect(throws: GitTemplateFetcher.GitTemplateFetcherError.cloneFailed(source: "invalid-git-url")) { + _ = try await resolver.resolve() + } + } + } + + //might need to test @Test func packageRegistryTemplatePathResolver() async throws { //TODO: im too lazy right now } @@ -553,6 +593,157 @@ import class Basics.AsyncProcess } } + @Test func cleanUpTemporaryDirectories() throws { + + try fixture(name: "Miscellaneous/DirectoryManagerFinalize", createGitRepo: false) { fixturePath in + let pathToRemove = fixturePath.appending("targetFolderForRemoval") + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + try TemplateInitializationDirectoryManager( + fileSystem: tool.fileSystem, + observabilityScope: tool.observabilityScope + ).cleanupTemporary(templateSource: .git, path: pathToRemove, temporaryDirectory: nil) + + #expect(!localFileSystem.exists(pathToRemove), "path should be removed") + } + + try fixture(name: "Miscellaneous/DirectoryManagerFinalize", createGitRepo: false) { fixturePath in + let pathToRemove = fixturePath.appending("targetFolderForRemoval") + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + try TemplateInitializationDirectoryManager( + fileSystem: tool.fileSystem, + observabilityScope: tool.observabilityScope + ).cleanupTemporary(templateSource: .registry, path: pathToRemove, temporaryDirectory: nil) + + #expect(!localFileSystem.exists(pathToRemove), "path should be removed") + } + + try fixture(name: "Miscellaneous/DirectoryManagerFinalize", createGitRepo: false) { fixturePath in + let pathToRemove = fixturePath.appending("targetFolderForRemoval") + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + try TemplateInitializationDirectoryManager( + fileSystem: tool.fileSystem, + observabilityScope: tool.observabilityScope + ).cleanupTemporary(templateSource: .local, path: pathToRemove, temporaryDirectory: nil) + + #expect(localFileSystem.exists(pathToRemove), "path should not be removed if local") + } + } + + ///test package builder + @Test func defaultDependencyBuilder() async throws { + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + let packageName = "foo" + let templateURL = "git@github.com:foo/bar" + let templatePackageID = "foo.bar" + + let versionResolver = DependencyRequirementResolver( + packageIdentity: templatePackageID, swiftCommandState: tool, exact: Version(stringLiteral: "1.2.0"), revision: nil, branch: nil, from: nil, upToNextMinorFrom: nil, to: nil + ) + + let sourceControlRequirement: SourceControlRequirement = try versionResolver.resolveSourceControl() + guard let registryRequirement = try await versionResolver.resolveRegistry() else { + Issue.record("Registry ID of template could not be resolved.") + return + } + + let resolvedTemplatePath: AbsolutePath = try AbsolutePath(validating: "/fake/path/to/template") + + //local + + let localDependency = try DefaultPackageDependencyBuilder( + templateSource: .local, + packageName: packageName, + templateURL: templateURL, + templatePackageID: templatePackageID, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ).makePackageDependency() + + #expect(localDependency == MappablePackageDependency.Kind.fileSystem(name: packageName, path: resolvedTemplatePath.asURL.path)) + + // git + #expect(throws: DefaultPackageDependencyBuilder.PackageDependencyBuilderError.missingGitURLOrPath.self) { + try DefaultPackageDependencyBuilder( + templateSource: .git, + packageName: packageName, + templateURL: nil, + templatePackageID: templatePackageID, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ).makePackageDependency() + } + + #expect(throws: DefaultPackageDependencyBuilder.PackageDependencyBuilderError.missingGitRequirement.self) { + try DefaultPackageDependencyBuilder( + templateSource: .git, + packageName: packageName, + templateURL: templateURL, + templatePackageID: templatePackageID, + sourceControlRequirement: nil, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ).makePackageDependency() + } + + let gitDependency = try DefaultPackageDependencyBuilder( + templateSource: .git, + packageName: packageName, + templateURL: templateURL, + templatePackageID: templatePackageID, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ).makePackageDependency() + + #expect(gitDependency == MappablePackageDependency.Kind.sourceControl(name: packageName, location: templateURL, requirement: sourceControlRequirement)) + + #expect(throws: DefaultPackageDependencyBuilder.PackageDependencyBuilderError.missingRegistryIdentity.self) { + try DefaultPackageDependencyBuilder( + templateSource: .registry, + packageName: packageName, + templateURL: templateURL, + templatePackageID: nil, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ).makePackageDependency() + } + + #expect(throws: DefaultPackageDependencyBuilder.PackageDependencyBuilderError.missingRegistryRequirement.self) { + try DefaultPackageDependencyBuilder( + templateSource: .registry, + packageName: packageName, + templateURL: templateURL, + templatePackageID: templatePackageID, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: nil, + resolvedTemplatePath: resolvedTemplatePath + ).makePackageDependency() + } + + let registryDependency = try DefaultPackageDependencyBuilder( + templateSource: .registry, + packageName: packageName, + templateURL: templateURL, + templatePackageID: templatePackageID, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ).makePackageDependency() + + #expect(registryDependency == MappablePackageDependency.Kind.registry(id: templatePackageID, requirement: registryRequirement)) + } + @Test func initPackageInitializer() throws { let globalOptions = try GlobalOptions.parse([]) From 1116d9e0e525b7dcf60e4714c83969f307dad2fa Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 17 Sep 2025 12:09:51 -0400 Subject: [PATCH 115/225] changed template running system to reflect @ParentCommand changes --- Package.swift | 2 +- .../TestCommands/TestTemplateCommand.swift | 54 ++++++++++--------- .../_InternalInitSupport/TemplateBuild.swift | 2 +- .../TemplatePathResolver.swift | 2 +- .../TemplatePluginManager.swift | 8 ++- .../TemplateTesterManager.swift | 49 +++++++++-------- .../InitTemplatePackage.swift | 21 +++----- 7 files changed, 70 insertions(+), 68 deletions(-) diff --git a/Package.swift b/Package.swift index 4f2be439526..bdffc9ab717 100644 --- a/Package.swift +++ b/Package.swift @@ -557,7 +557,7 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), ] + swiftSyntaxDependencies(["SwiftIDEUtils", "SwiftParser", "SwiftRefactor"]), exclude: ["CMakeLists.txt"], - swiftSettings: swift6CompatibleExperimentalFeatures + [ + swiftSettings: commonExperimentalFeatures + [ .unsafeFlags(["-static"]), ] ), diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 05aa4b2e561..714c777b331 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -81,7 +81,7 @@ extension SwiftTestCommand { name: .customLong("branches"), parsing: .upToNextOption, - help: "Specify the branch of the template you want to test.", + help: "Specify the branch of the template you want to test. Format: --branches branch1 branch2", ) public var branches: [String] = [] @@ -96,7 +96,6 @@ extension SwiftTestCommand { func run(_ swiftCommandState: SwiftCommandState) async throws { - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw ValidationError("Could not determine current working directory.") } @@ -128,7 +127,6 @@ extension SwiftTestCommand { } let packageType = try await inferPackageType(swiftCommandState: swiftCommandState, from: cwd) - var buildMatrix: [String: BuildInfo] = [:] for commandLine in commandLineFragments { @@ -299,30 +297,24 @@ extension SwiftTestCommand { try await TemplateBuildSupport.buildForTesting(swiftCommandState: swiftCommandState, buildOptions: buildOptions, testingFolder: destinationAbsolutePath) - var subCommandPath: [String] = [] - for (index, command) in argumentPath.enumerated() { - - subCommandPath.append(contentsOf: (index == 0 ? [] : [command.commandName])) + // Build flat command with all subcommands and arguments + let flatCommand = buildFlatCommand(from: argumentPath) - let commandArgs = command.arguments.flatMap { $0.commandLineFragments } - let fullCommand = subCommandPath + commandArgs + print("Running plugin with args:", flatCommand) - print("Running plugin with args:", fullCommand) + try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in - try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in - - let output = try await TemplatePluginExecutor.execute( - plugin: commandPlugin, - rootPackage: graph.rootPackages.first!, - packageGraph: graph, - buildSystemKind: buildSystem, - arguments: fullCommand, - swiftCommandState: swiftCommandState, - requestPermission: false - ) - pluginOutput = String(data: output, encoding: .utf8) ?? "[Invalid UTF-8 output]" - print(pluginOutput) - } + let output = try await TemplatePluginExecutor.execute( + plugin: commandPlugin, + rootPackage: graph.rootPackages.first!, + packageGraph: graph, + buildSystemKind: buildSystem, + arguments: flatCommand, + swiftCommandState: swiftCommandState, + requestPermission: false + ) + pluginOutput = String(data: output, encoding: .utf8) ?? "[Invalid UTF-8 output]" + print(pluginOutput) } genDuration = startGen.distance(to: .now()) @@ -383,6 +375,20 @@ extension SwiftTestCommand { ) } + private func buildFlatCommand(from argumentPath: [CommandComponent]) -> [String] { + var result: [String] = [] + + for (index, command) in argumentPath.enumerated() { + if index > 0 { + result.append(command.commandName) + } + let commandArgs = command.arguments.flatMap { $0.commandLineFragments } + result.append(contentsOf: commandArgs) + } + + return result + } + private func captureAndWriteError(to path: AbsolutePath, error: Error, context: String) throws -> String { let existingOutput = (try? String(contentsOf: path.asURL)) ?? "" let logContent = diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift index e80461e4c5f..8cdc17c4a6d 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -59,7 +59,7 @@ enum TemplateBuildSupport { try await swiftCommandState.withTemporaryWorkspace(switchingTo: packageRoot) { _, _ in do { try await buildSystem.build(subset: subset, buildOutputs: [.buildPlan]) - } catch let diagnostics as Diagnostics { + } catch { throw ExitCode.failure } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index 302396cf564..ace59bcfac2 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -392,7 +392,7 @@ struct RegistryTemplateFetcher: TemplateFetcher { fatalError("Invalid version string: \(versionString)") } return version - case .range(let lowerBound, let upperBound): + case .range(_, let upperBound): guard let version = Version(upperBound) else { fatalError("Invalid version string: \(upperBound)") } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 7788ebbffe2..d1326cd3a82 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -87,11 +87,9 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { let plugin = try loadTemplatePlugin() let toolInfo = try await coordinator.dumpToolInfo(using: plugin, from: packageGraph, rootPackage: rootPackage) - let cliResponses: [[String]] = try promptUserForTemplateArguments(using: toolInfo) + let cliResponses: [String] = try promptUserForTemplateArguments(using: toolInfo) - for response in cliResponses { - _ = try await runTemplatePlugin(plugin, with: response) - } + _ = try await runTemplatePlugin(plugin, with: cliResponses) } /// Utilizes the prompting system defined by the struct to prompt user. @@ -105,7 +103,7 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// - Parameter toolInfo: The JSON representation of the template's decision tree /// - Returns: A 2D array of arguments provided by the user for template generation /// - Throws: Any errors during user prompting - private func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [[String]] { + private func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [String] { return try TemplatePromptingSystem().promptUser(command: toolInfo.command, arguments: args) } diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index 39ab11593e9..8b36d531c89 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -81,29 +81,30 @@ extension CommandPath { let commandNames = commandChain.map { $0.commandName } let fullPath = commandNames.joined(separator: " ") - var result = "Command Path: \(fullPath) \nExecution Steps: \n\n" + var result = "Command Path: \(fullPath) \nExecution Format: \n\n" - // Build progressive commands - for i in 0.. String { + var result: [String] = [] + + for (index, command) in commandChain.enumerated() { + // Add command name (skip the first command name as it's the root) + if index > 0 { + result.append(command.commandName) } + + // Add all arguments for this command level + let commandArgs = command.arguments.flatMap { $0.commandLineFragments } + result.append(contentsOf: commandArgs) } - - result += "\n\n" - return result + + return result.joined(separator: " ") } private func formatArguments(_ argumentResponses: @@ -148,8 +149,7 @@ public class TemplateTestPromptingSystem { /// - subcommandTrail: An internal list of command names to build the final CLI path (used recursively). /// - inheritedResponses: Argument responses collected from parent commands that should be passed down. /// - /// - Returns: A list of command line invocations (`[[String]]`), each representing a full CLI command. - /// Each entry includes only arguments relevant to the specific command or subcommand level. + /// - Returns: A list of command paths, each representing a full CLI command path with arguments. /// /// - Throws: An error if argument parsing or user prompting fails. @@ -236,6 +236,9 @@ public class TemplateTestPromptingSystem { try dfs(command: rootCommand, path: [], visitedArgs: &visitedArgs, paths: &paths, predefinedArgs: args, branches: branches, branchDepth: 0) + for path in paths{ + print(path.displayFormat()) + } return paths } @@ -286,7 +289,7 @@ public class TemplateTestPromptingSystem { } else if branchDepth < (branches.count - 1) { shouldTraverse = sub.commandName == branches[branchDepth + 1] } else { - shouldTraverse = true + shouldTraverse = sub.commandName == branches[branchDepth] } if shouldTraverse { diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift index 20efa0328e7..5f835b81815 100644 --- a/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift +++ b/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift @@ -230,14 +230,11 @@ public final class TemplatePromptingSystem { /// - subcommandTrail: An internal list of command names to build the final CLI path (used recursively). /// - inheritedResponses: Argument responses collected from parent commands that should be passed down. /// - /// - Returns: A list of command line invocations (`[[String]]`), each representing a full CLI command. - /// Each entry includes only arguments relevant to the specific command or subcommand level. + /// - Returns: A single command line invocation representing the full CLI command with all arguments. /// /// - Throws: An error if argument parsing or user prompting fails. - public func promptUser(command: CommandInfoV0, arguments: [String], subcommandTrail: [String] = [], inheritedResponses: [ArgumentResponse] = []) throws -> [[String]] { - - var commandLines = [[String]]() + public func promptUser(command: CommandInfoV0, arguments: [String], subcommandTrail: [String] = [], inheritedResponses: [ArgumentResponse] = []) throws -> [String] { let allArgs = try convertArguments(from: command) let (providedResponses, leftoverArgs) = try self.parseAndMatchArgumentsWithLeftovers(arguments, definedArgs: allArgs) @@ -254,9 +251,7 @@ public final class TemplatePromptingSystem { let currentCommandResponses = allCurrentResponses.filter { currentArgNames.contains($0.argument.valueName) } let currentArgs = self.buildCommandLine(from: currentCommandResponses) - let fullCommand = subcommandTrail + currentArgs - - commandLines.append(fullCommand) + var fullCommand = subcommandTrail + currentArgs if let subCommands = getSubCommand(from: command) { @@ -278,14 +273,14 @@ public final class TemplatePromptingSystem { var newArgs = leftoverArgs newArgs.remove(at: index) - let subCommandLines = try self.promptUser( + let subCommandLine = try self.promptUser( command: matchedSubcommand, arguments: newArgs, subcommandTrail: newTrail, inheritedResponses: allCurrentResponses ) - commandLines.append(contentsOf: subCommandLines) + return subCommandLine } else { // Fall back to interactive prompt let chosenSubcommand = try self.promptUserForSubcommand(for: subCommands) @@ -293,18 +288,18 @@ public final class TemplatePromptingSystem { var newTrail = subcommandTrail newTrail.append(chosenSubcommand.commandName) - let subCommandLines = try self.promptUser( + let subCommandLine = try self.promptUser( command: chosenSubcommand, arguments: leftoverArgs, subcommandTrail: newTrail, inheritedResponses: allCurrentResponses ) - commandLines.append(contentsOf: subCommandLines) + return subCommandLine } } - return commandLines + return fullCommand } /// Prompts the user to select a subcommand from a list of available options. From b45f3024d75b56c18936bdba7f75f7a3c97b1fe0 Mon Sep 17 00:00:00 2001 From: John Bute Date: Fri, 19 Sep 2025 12:05:52 -0400 Subject: [PATCH 116/225] tests + revamped prompting systems --- Package.swift | 2 +- .../TestCommands/TestTemplateCommand.swift | 1 - .../PackageInitializer.swift | 4 +- .../TemplatePluginManager.swift | 2 +- .../TemplateTesterManager.swift | 574 +++++-- .../InitTemplatePackage.swift | 380 ++++- Tests/CommandsTests/PackageCommandTests.swift | 43 + Tests/CommandsTests/TemplateTests.swift | 1331 ++++++++++++++++- 8 files changed, 2117 insertions(+), 220 deletions(-) diff --git a/Package.swift b/Package.swift index bdffc9ab717..775cc39739a 100644 --- a/Package.swift +++ b/Package.swift @@ -1107,7 +1107,7 @@ if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil { // The 'swift-argument-parser' version declared here must match that // used by 'swift-driver' and 'sourcekit-lsp'. Please coordinate // dependency version changes here with those projects. - .package(url: "https://github.com/apple/swift-argument-parser.git", .upToNextMinor(from: "1.5.1")), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.6.1"), .package(url: "https://github.com/apple/swift-crypto.git", .upToNextMinor(from: "3.0.0")), .package(url: "https://github.com/swiftlang/swift-syntax.git", branch: relatedDependenciesBranch), .package(url: "https://github.com/apple/swift-system.git", from: "1.1.1"), diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 714c777b331..34fc3f1ec53 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -281,7 +281,6 @@ extension SwiftTestCommand { let initTemplate = try InitTemplatePackage( name: testingFolderName, initMode: .fileSystem(.init(path: cwd.pathString)), - templatePath: cwd, fileSystem: swiftCommandState.fileSystem, packageType: initialPackageType, supportedTestingLibraries: [], diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index 032401655b8..6e29846ac77 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -70,6 +70,7 @@ struct TemplatePackageInitializer: PackageInitializer { swiftCommandState.observabilityScope.emit(debug: "Inferring initial type of consumer's package based on template's specifications.") let packageType = try await TemplatePackageInitializer.inferPackageType(from: resolvedTemplatePath, templateName: templateName, swiftCommandState: swiftCommandState) + let builder = DefaultPackageDependencyBuilder( templateSource: templateSource, packageName: packageName, @@ -80,7 +81,6 @@ struct TemplatePackageInitializer: PackageInitializer { resolvedTemplatePath: resolvedTemplatePath ) - let templatePackage = try setUpPackage(builder: builder, packageType: packageType, stagingPath: stagingPath) swiftCommandState.observabilityScope.emit(debug: "Finished setting up initial package: \(templatePackage.packageName).") @@ -152,7 +152,6 @@ struct TemplatePackageInitializer: PackageInitializer { return try .init(from: type) } } - throw TemplatePackageInitializerError.templateNotFound(templateName ?? "") } } @@ -185,7 +184,6 @@ struct TemplatePackageInitializer: PackageInitializer { let templatePackage = try InitTemplatePackage( name: packageName, initMode: try builder.makePackageDependency(), - templatePath: builder.resolvedTemplatePath, fileSystem: swiftCommandState.fileSystem, packageType: packageType, supportedTestingLibraries: [], diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index d1326cd3a82..9e14f788c18 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -104,7 +104,7 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// - Returns: A 2D array of arguments provided by the user for template generation /// - Throws: Any errors during user prompting private func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [String] { - return try TemplatePromptingSystem().promptUser(command: toolInfo.command, arguments: args) + return try TemplatePromptingSystem(hasTTY: swiftCommandState.outputStream.isTTY).promptUser(command: toolInfo.command, arguments: args) } diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index 8b36d531c89..3ad0f34377b 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -57,7 +57,7 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { } private func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [CommandPath] { - try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command, args: args, branches: branches) + try TemplateTestPromptingSystem(hasTTY: swiftCommandState.outputStream.isTTY).generateCommandPaths(rootCommand: toolInfo.command, args: args, branches: branches) } public func loadTemplatePlugin() throws -> ResolvedModule { @@ -127,9 +127,11 @@ extension CommandPath { public class TemplateTestPromptingSystem { + private let hasTTY: Bool - - public init() {} + public init(hasTTY: Bool = true) { + self.hasTTY = hasTTY + } /// Prompts the user for input based on the given command definition and arguments. /// /// This method collects responses for a command's arguments by first validating any user-provided @@ -160,155 +162,368 @@ public class TemplateTestPromptingSystem { // if subcommands exist, then for each subcommand, pass the function again, where we deepCopy a path // if not, then jointhe command names of all the paths, and append CommandPath() - private func parseAndMatchArguments(_ input: [String], definedArgs: [ArgumentInfoV0]) throws -> (Set, [String]) { + private func parseAndMatchArguments(_ input: [String], definedArgs: [ArgumentInfoV0], subcommands: [CommandInfoV0] = []) throws -> (Set, [String]) { var responses = Set() var providedMap: [String: [String]] = [:] - var leftover: [String] = [] - - var index = 0 - - while index < input.count { - let token = input[index] - + var tokens = input + var terminatorSeen = false + var postTerminatorArgs: [String] = [] + + let subcommandNames = Set(subcommands.map { $0.commandName }) + let positionalArgs = definedArgs.filter { $0.kind == .positional } + + // Handle terminator (--) for post-terminator parsing + if let terminatorIndex = tokens.firstIndex(of: "--") { + postTerminatorArgs = Array(tokens[(terminatorIndex + 1)...]) + tokens = Array(tokens[.. [String] { + var values: [String] = [] + + switch arg.parsingStrategy { + case .default: + // Expect the next token to be a value and parse it + guard currentIndex < tokens.count && !tokens[currentIndex].starts(with: "-") else { + if arg.isOptional && arg.defaultValue != nil { + // Use default value for optional arguments + return arg.defaultValue.map { [$0] } ?? [] + } + throw TemplateError.missingValueForOption(name: arg.valueName ?? "") + } + values.append(tokens[currentIndex]) + tokens.remove(at: currentIndex) + + case .scanningForValue: + // Parse the next token as a value if it exists and isn't an option + if currentIndex < tokens.count && !tokens[currentIndex].starts(with: "--") { + values.append(tokens[currentIndex]) + tokens.remove(at: currentIndex) + } else if let defaultValue = arg.defaultValue { + values.append(defaultValue) + } + + case .unconditional: + // Parse the next token as a value, regardless of its type + guard currentIndex < tokens.count else { + if let defaultValue = arg.defaultValue { + return [defaultValue] + } + throw TemplateError.missingValueForOption(name: arg.valueName ?? "") + } + values.append(tokens[currentIndex]) + tokens.remove(at: currentIndex) + + case .upToNextOption: + // Parse multiple values up to the next option + while currentIndex < tokens.count && !tokens[currentIndex].starts(with: "--") { + values.append(tokens[currentIndex]) + tokens.remove(at: currentIndex) + } + // If no values found and there's a default, use it + if values.isEmpty && arg.defaultValue != nil { + values.append(arg.defaultValue!) + } + + case .allRemainingInput: + // Collect all remaining tokens + values = Array(tokens[currentIndex...]) + tokens.removeSubrange(currentIndex...) + + case .postTerminator, .allUnrecognized: + // These are handled separately in the main parsing logic + if currentIndex < tokens.count { + values.append(tokens[currentIndex]) + tokens.remove(at: currentIndex) + } + } + + // Validate values against allowed values if specified + if let allowed = arg.allValues { + let invalid = values.filter { !allowed.contains($0) } + if !invalid.isEmpty { + throw TemplateError.invalidValue( + argument: arg.valueName ?? "", + invalidValues: invalid, + allowed: allowed + ) + } + } + + return values } - public func generateCommandPaths(rootCommand: CommandInfoV0, args: [String], branches: [String]) throws -> [CommandPath] { + public func generateCommandPaths(rootCommand: CommandInfoV0, args: [String], branches: [String]) throws -> [CommandPath] { var paths: [CommandPath] = [] var visitedArgs = Set() + var inheritedResponses: [ArgumentResponse] = [] - try dfs(command: rootCommand, path: [], visitedArgs: &visitedArgs, paths: &paths, predefinedArgs: args, branches: branches, branchDepth: 0) + try dfsWithInheritance(command: rootCommand, path: [], visitedArgs: &visitedArgs, inheritedResponses: &inheritedResponses, paths: &paths, predefinedArgs: args, branches: branches, branchDepth: 0) - for path in paths{ + for path in paths { print(path.displayFormat()) } return paths } - func dfs(command: CommandInfoV0, path: [CommandComponent], visitedArgs: inout Set, paths: inout [CommandPath], predefinedArgs: [String], branches: [String], branchDepth: Int = 0) throws{ + func dfsWithInheritance(command: CommandInfoV0, path: [CommandComponent], visitedArgs: inout Set, inheritedResponses: inout [ArgumentResponse], paths: inout [CommandPath], predefinedArgs: [String], branches: [String], branchDepth: Int = 0) throws { let allArgs = try convertArguments(from: command) + let subCommands = getSubCommand(from: command) ?? [] - let (answeredArgs, leftoverArgs) = try - parseAndMatchArguments(predefinedArgs, definedArgs: allArgs) + let (answeredArgs, leftoverArgs) = try parseAndMatchArguments(predefinedArgs, definedArgs: allArgs, subcommands: subCommands) + // Combine inherited responses with current parsed responses + let currentArgNames = Set(allArgs.map { $0.valueName }) + let relevantInheritedResponses = inheritedResponses.filter { !currentArgNames.contains($0.argument.valueName) } + + var allCurrentResponses = Array(answeredArgs) + relevantInheritedResponses visitedArgs.formUnion(answeredArgs) - // Separate args into already answered and new ones - var finalArgs: [TemplateTestPromptingSystem.ArgumentResponse] = [] - var newArgs: [ArgumentInfoV0] = [] - - for arg in allArgs { - if let existingArg = visitedArgs.first(where: { $0.argument.valueName == arg.valueName }) { - // Reuse the previously answered argument - finalArgs.append(existingArg) - } else { - // This is a new argument that needs prompting - newArgs.append(arg) - } + // Find missing arguments that need prompting + let providedArgNames = Set(allCurrentResponses.map { $0.argument.valueName }) + let missingArgs = allArgs.filter { arg in + !providedArgNames.contains(arg.valueName) && arg.valueName != "help" && arg.shouldDisplay } - // Only prompt for new arguments + // Only prompt for missing arguments var collected: [String: ArgumentResponse] = [:] - let newResolvedArgs = UserPrompter.prompt(for: newArgs, collected: &collected) + let newResolvedArgs = try UserPrompter.prompt(for: missingArgs, collected: &collected, hasTTY: self.hasTTY) - // Add new arguments to final list and visited set - finalArgs.append(contentsOf: newResolvedArgs) + // Add new arguments to current responses and visited set + allCurrentResponses.append(contentsOf: newResolvedArgs) newResolvedArgs.forEach { visitedArgs.insert($0) } + // Filter to only include arguments defined at this command level + let currentLevelResponses = allCurrentResponses.filter { currentArgNames.contains($0.argument.valueName) } + let currentComponent = CommandComponent( - commandName: command.commandName, arguments: finalArgs + commandName: command.commandName, arguments: currentLevelResponses ) var newPath = path - newPath.append(currentComponent) + // Update inherited responses for next level (pass down all responses for potential inheritance) + var newInheritedResponses = allCurrentResponses + + // Handle subcommands with auto-detection logic if let subcommands = getSubCommand(from: command) { - for sub in subcommands { + // Try to auto-detect a subcommand from leftover args + if let (index, matchedSubcommand) = leftoverArgs + .enumerated() + .compactMap({ (i, token) -> (Int, CommandInfoV0)? in + if let match = subcommands.first(where: { $0.commandName == token }) { + print("Detected subcommand '\(match.commandName)' from user input.") + return (i, match) + } + return nil + }) + .first { + + var newLeftoverArgs = leftoverArgs + newLeftoverArgs.remove(at: index) + let shouldTraverse: Bool if branches.isEmpty { shouldTraverse = true } else if branchDepth < (branches.count - 1) { - shouldTraverse = sub.commandName == branches[branchDepth + 1] + shouldTraverse = matchedSubcommand.commandName == branches[branchDepth + 1] } else { - shouldTraverse = sub.commandName == branches[branchDepth] + shouldTraverse = matchedSubcommand.commandName == branches[branchDepth] } if shouldTraverse { - try dfs(command: sub, path: newPath, visitedArgs: &visitedArgs, paths: &paths, predefinedArgs: leftoverArgs, branches: branches, branchDepth: branchDepth + 1) + try dfsWithInheritance(command: matchedSubcommand, path: newPath, visitedArgs: &visitedArgs, inheritedResponses: &newInheritedResponses, paths: &paths, predefinedArgs: newLeftoverArgs, branches: branches, branchDepth: branchDepth + 1) + } + } else { + // No subcommand detected, process all available subcommands based on branch filter + for sub in subcommands { + let shouldTraverse: Bool + if branches.isEmpty { + shouldTraverse = true + } else if branchDepth < (branches.count - 1) { + shouldTraverse = sub.commandName == branches[branchDepth + 1] + } else { + shouldTraverse = sub.commandName == branches[branchDepth] + } + + if shouldTraverse { + var branchInheritedResponses = newInheritedResponses + try dfsWithInheritance(command: sub, path: newPath, visitedArgs: &visitedArgs, inheritedResponses: &branchInheritedResponses, paths: &paths, predefinedArgs: leftoverArgs, branches: branches, branchDepth: branchDepth + 1) + } } } } else { + // No subcommands, this is a leaf command - add to paths let fullPathKey = joinCommandNames(newPath) let commandPath = CommandPath( fullPathKey: fullPathKey, commandChain: newPath ) - paths.append(commandPath) } func joinCommandNames(_ path: [CommandComponent]) -> String { path.map { $0.commandName }.joined(separator: "-") } - } @@ -356,68 +571,162 @@ public class TemplateTestPromptingSystem { public static func prompt( for arguments: [ArgumentInfoV0], - collected: inout [String: ArgumentResponse] - ) -> [ArgumentResponse] { - return arguments + collected: inout [String: ArgumentResponse], + hasTTY: Bool = true + ) throws -> [ArgumentResponse] { + return try arguments .filter { $0.valueName != "help" && $0.shouldDisplay } .compactMap { arg in let key = arg.preferredName?.name ?? arg.valueName ?? UUID().uuidString if let existing = collected[key] { - print("Using previous value for '\(key)': \(existing.values.joined(separator: ", "))") + if hasTTY { + print("Using previous value for '\(key)': \(existing.values.joined(separator: ", "))") + } return existing } let defaultText = arg.defaultValue.map { " (default: \($0))" } ?? "" let allValuesText = (arg.allValues?.isEmpty == false) ? " [\(arg.allValues!.joined(separator: ", "))]" : "" - let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(defaultText):" + let completionText = generateCompletionHint(for: arg) + let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(completionText)\(defaultText):" var values: [String] = [] switch arg.kind { case .flag: - let confirmed = promptForConfirmation( - prompt: promptMessage, - defaultBehavior: arg.defaultValue?.lowercased() == "true" - ) - values = [confirmed ? "true" : "false"] + if !hasTTY && arg.isOptional == false && arg.defaultValue == nil { + fatalError("Required argument '\(arg.valueName ?? "")' not provided and no interactive terminal available") + } + + var confirmed: Bool? = nil + if hasTTY { + confirmed = try promptForConfirmation( + prompt: promptMessage, + defaultBehavior: arg.defaultValue?.lowercased() == "true", + isOptional: arg.isOptional + ) + } else if let defaultValue = arg.defaultValue { + confirmed = defaultValue.lowercased() == "true" + } + + if let confirmed { + values = [confirmed ? "true" : "false"] + } else if arg.isOptional { + // Flag was explicitly unset + let response = ArgumentResponse(argument: arg, values: [], isExplicitlyUnset: true) + collected[key] = response + return response + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: arg.valueName ?? "") + } case .option, .positional: - print(promptMessage) + if !hasTTY && arg.isOptional == false && arg.defaultValue == nil { + fatalError("Required argument '\(arg.valueName ?? "")' not provided and no interactive terminal available") + } + + if hasTTY { + let nilSuffix = arg.isOptional && arg.defaultValue == nil ? " (or enter \"nil\" to unset)" : "" + print(promptMessage + nilSuffix) + } if arg.isRepeating { - while let input = readLine(), !input.isEmpty { - if let allowed = arg.allValues, !allowed.contains(input) { - print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") - continue + if hasTTY { + while let input = readLine(), !input.isEmpty { + if input.lowercased() == "nil" && arg.isOptional { + // Clear the values array to explicitly unset + values = [] + let response = ArgumentResponse(argument: arg, values: values, isExplicitlyUnset: true) + collected[key] = response + return response + } + if let allowed = arg.allValues, !allowed.contains(input) { + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + continue + } + values.append(input) } - values.append(input) } if values.isEmpty, let def = arg.defaultValue { values = [def] } } else { - let input = readLine() + let input = hasTTY ? readLine() : nil if let input, !input.isEmpty { - if let allowed = arg.allValues, !allowed.contains(input) { - print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") - exit(1) + if input.lowercased() == "nil" && arg.isOptional { + values = [] + let response = ArgumentResponse(argument: arg, values: values, isExplicitlyUnset: true) + collected[key] = response + return response + } else { + if let allowed = arg.allValues, !allowed.contains(input) { + if hasTTY { + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + print("Or try completion suggestions: \(generateCompletionSuggestions(for: arg, input: input))") + fatalError("Invalid value provided") + } else { + throw TemplateError.invalidValue(argument: arg.valueName ?? "", invalidValues: [input], allowed: allowed) + } + } + values = [input] } - values = [input] } else if let def = arg.defaultValue { values = [def] } else if arg.isOptional == false { - fatalError("Required argument '\(arg.valueName ?? "")' not provided.") + if hasTTY { + fatalError("Required argument '\(arg.valueName ?? "")' not provided.") + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: arg.valueName ?? "") + } } } } - let response = ArgumentResponse(argument: arg, values: values) + let response = ArgumentResponse(argument: arg, values: values, isExplicitlyUnset: false) collected[key] = response return response } - + } + + /// Generates completion hint text based on CompletionKindV0 + private static func generateCompletionHint(for arg: ArgumentInfoV0) -> String { + guard let completionKind = arg.completionKind else { return "" } + + switch completionKind { + case .list(let values): + return " (suggestions: \(values.joined(separator: ", ")))" + case .file(let extensions): + if extensions.isEmpty { + return " (file completion available)" + } else { + return " (file completion: .\(extensions.joined(separator: ", .")))" + } + case .directory: + return " (directory completion available)" + case .shellCommand(let command): + return " (shell completions available: \(command))" + case .custom, .customAsync: + return " (custom completions available)" + case .customDeprecated: + return " (custom completions available)" + } + } + + /// Generates completion suggestions based on input and CompletionKindV0 + private static func generateCompletionSuggestions(for arg: ArgumentInfoV0, input: String) -> String { + guard let completionKind = arg.completionKind else { + return "No completions available" + } + + switch completionKind { + case .list(let values): + let suggestions = values.filter { $0.hasPrefix(input) } + return suggestions.isEmpty ? "No matching suggestions" : suggestions.joined(separator: ", ") + case .file, .directory, .shellCommand, .custom, .customAsync, .customDeprecated: + return "Use system completion for suggestions" + } } } @@ -426,19 +735,52 @@ public class TemplateTestPromptingSystem { /// - Parameters: /// - prompt: The prompt message to display. /// - defaultBehavior: The default value if the user provides no input. - /// - Returns: `true` if the user confirmed, otherwise `false`. + /// - isOptional: Whether the argument is optional and can be explicitly unset. + /// - Returns: `true` if the user confirmed, `false` if denied, `nil` if explicitly unset. + /// - Throws: TemplateError if required argument missing without TTY - private static func promptForConfirmation(prompt: String, defaultBehavior: Bool?) -> Bool { - let suffix = defaultBehavior == true ? " [Y/n]" : defaultBehavior == false ? " [y/N]" : " [y/n]" + private static func promptForConfirmation(prompt: String, defaultBehavior: Bool?, isOptional: Bool) throws -> Bool? { + var suffix = defaultBehavior == true ? " [Y/n]" : defaultBehavior == false ? " [y/N]" : " [y/n]" + + if isOptional && defaultBehavior == nil { + suffix = suffix + " or enter \"nil\" to unset." + } print(prompt + suffix, terminator: " ") guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { - return defaultBehavior ?? false + if let defaultBehavior { + return defaultBehavior + } else if isOptional { + return nil + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: "confirmation") + } } switch input { case "y", "yes": return true case "n", "no": return false - default: return defaultBehavior ?? false + case "nil": + if isOptional { + return nil + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: "confirmation") + } + case "": + if let defaultBehavior { + return defaultBehavior + } else if isOptional { + return nil + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: "confirmation") + } + default: + if let defaultBehavior { + return defaultBehavior + } else if isOptional { + return nil + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: "confirmation") + } } } @@ -450,9 +792,15 @@ public class TemplateTestPromptingSystem { /// The values provided by the user. public let values: [String] + + /// Whether the argument was explicitly unset (nil) by the user. + public let isExplicitlyUnset: Bool /// Returns the command line fragments representing this argument and its values. public var commandLineFragments: [String] { + // If explicitly unset, don't generate any command line fragments + guard !isExplicitlyUnset else { return [] } + guard let name = argument.valueName else { return self.values } @@ -461,11 +809,30 @@ public class TemplateTestPromptingSystem { case .flag: return self.values.first == "true" ? ["--\(name)"] : [] case .option: - return self.values.flatMap { ["--\(name)", $0] } + if argument.isRepeating { + return self.values.flatMap { ["--\(name)", $0] } + } else { + return self.values.flatMap { ["--\(name)", $0] } + } case .positional: return self.values } } + + /// Initialize with explicit unset state + public init(argument: ArgumentInfoV0, values: [String], isExplicitlyUnset: Bool = false) { + self.argument = argument + self.values = values + self.isExplicitlyUnset = isExplicitlyUnset + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(argument.valueName) + } + + public static func == (lhs: ArgumentResponse, rhs: ArgumentResponse) -> Bool { + return lhs.argument.valueName == rhs.argument.valueName + } } @@ -480,7 +847,6 @@ private enum TemplateError: Swift.Error { case manifestAlreadyExists /// The template has no arguments to prompt for. - case noArguments case invalidArgument(name: String) case unexpectedArgument(name: String) @@ -488,6 +854,8 @@ private enum TemplateError: Swift.Error { case missingValueForOption(name: String) case invalidValue(argument: String, invalidValues: [String], allowed: [String]) case unexpectedSubcommand(name: String) + case missingRequiredArgumentWithoutTTY(name: String) + case noTTYForSubcommandSelection } extension TemplateError: CustomStringConvertible { @@ -512,6 +880,10 @@ extension TemplateError: CustomStringConvertible { "Invalid value \(argument). Valid values are: \(allowed.joined(separator: ", ")). \(invalidValues.isEmpty ? "" : "Also, \(invalidValues.joined(separator: ", ")) are not valid.")" case .unexpectedSubcommand(name: let name): "Invalid subcommand \(name) provided in arguments, arguments only accepts flags, options, or positional arguments. Subcommands are treated via the --branch option" + case .missingRequiredArgumentWithoutTTY(name: let name): + "Required argument '\(name)' not provided and no interactive terminal available" + case .noTTYForSubcommandSelection: + "Cannot select subcommand interactively - no terminal available" } } } diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift index 5f835b81815..4e33890aaac 100644 --- a/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift +++ b/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift @@ -54,12 +54,8 @@ public struct InitTemplatePackage { /// Configuration information from the installed Swift Package Manager toolchain. let installedSwiftPMConfiguration: InstalledSwiftPMConfiguration /// The name of the package to create. - public var packageName: String - /// The path to the template files. - var templatePath: Basics.AbsolutePath - /// The type of package to create (e.g., library, executable). let packageType: InitPackage.PackageType @@ -121,7 +117,6 @@ public struct InitTemplatePackage { package init( name: String, initMode: SwiftRefactor.PackageDependency, - templatePath: Basics.AbsolutePath, fileSystem: FileSystem, packageType: InitPackage.PackageType, supportedTestingLibraries: Set, @@ -130,7 +125,6 @@ public struct InitTemplatePackage { ) { self.packageName = name self.packageDependency = initMode - self.templatePath = templatePath self.packageType = packageType self.supportedTestingLibraries = supportedTestingLibraries self.destinationPath = destinationPath @@ -209,8 +203,11 @@ public struct InitTemplatePackage { public final class TemplatePromptingSystem { + private let hasTTY: Bool - public init() {} + public init(hasTTY: Bool = true) { + self.hasTTY = hasTTY + } /// Prompts the user for input based on the given command definition and arguments. /// /// This method collects responses for a command's arguments by first validating any user-provided @@ -237,12 +234,13 @@ public final class TemplatePromptingSystem { public func promptUser(command: CommandInfoV0, arguments: [String], subcommandTrail: [String] = [], inheritedResponses: [ArgumentResponse] = []) throws -> [String] { let allArgs = try convertArguments(from: command) - let (providedResponses, leftoverArgs) = try self.parseAndMatchArgumentsWithLeftovers(arguments, definedArgs: allArgs) + let subCommands = getSubCommand(from: command) ?? [] + let (providedResponses, leftoverArgs) = try self.parseAndMatchArgumentsWithLeftovers(arguments, definedArgs: allArgs, subcommands: subCommands) let missingArgs = self.findMissingArguments(from: allArgs, excluding: providedResponses) var collectedResponses: [String: ArgumentResponse] = [:] - let promptedResponses = UserPrompter.prompt(for: missingArgs, collected: &collectedResponses) + let promptedResponses = try UserPrompter.prompt(for: missingArgs, collected: &collectedResponses, hasTTY: hasTTY) // Combine all inherited + current-level responses let allCurrentResponses = inheritedResponses + providedResponses + promptedResponses @@ -251,8 +249,7 @@ public final class TemplatePromptingSystem { let currentCommandResponses = allCurrentResponses.filter { currentArgNames.contains($0.argument.valueName) } let currentArgs = self.buildCommandLine(from: currentCommandResponses) - var fullCommand = subcommandTrail + currentArgs - + let fullCommand = subcommandTrail + currentArgs if let subCommands = getSubCommand(from: command) { // Try to auto-detect a subcommand from leftover args @@ -283,6 +280,9 @@ public final class TemplatePromptingSystem { return subCommandLine } else { // Fall back to interactive prompt + if !hasTTY { + throw TemplateError.noTTYForSubcommandSelection + } let chosenSubcommand = try self.promptUserForSubcommand(for: subCommands) var newTrail = subcommandTrail @@ -362,6 +362,7 @@ public final class TemplatePromptingSystem { /// /// This method converts user's predetermined arguments into the ArgumentResponse struct /// and validates the user's predetermined arguments against the template's available arguments. + /// Updated to handle all ParsingStrategyV0 cases from Swift Argument Parser. /// /// - Parameter input: The input arguments from the consumer. /// - parameter definedArgs: the arguments defined by the template @@ -371,55 +372,136 @@ public final class TemplatePromptingSystem { /// defined by the template. private func parseAndMatchArgumentsWithLeftovers( _ input: [String], - definedArgs: [ArgumentInfoV0] + definedArgs: [ArgumentInfoV0], + subcommands: [CommandInfoV0] = [] ) throws -> ([ArgumentResponse], [String]) { var responses: [ArgumentResponse] = [] var providedMap: [String: [String]] = [:] var leftover: [String] = [] - var index = 0 - - while index < input.count { - let token = input[index] - + var tokens = input + var terminatorSeen = false + var postTerminatorArgs: [String] = [] + + let subcommandNames = Set(subcommands.map { $0.commandName }) + let positionalArgs = definedArgs.filter { $0.kind == .positional } + + // Handle terminator (--) for post-terminator parsing + if let terminatorIndex = tokens.firstIndex(of: "--") { + postTerminatorArgs = Array(tokens[(terminatorIndex + 1)...]) + tokens = Array(tokens[.. [String] { + var values: [String] = [] + + switch arg.parsingStrategy { + case .default: + // Expect the next token to be a value and parse it + guard currentIndex < tokens.count && !tokens[currentIndex].starts(with: "-") else { + throw TemplateError.missingValueForOption(name: arg.valueName ?? "") + } + values.append(tokens[currentIndex]) + tokens.remove(at: currentIndex) + + case .scanningForValue: + // Parse the next token as a value if it exists + if currentIndex < tokens.count { + values.append(tokens[currentIndex]) + tokens.remove(at: currentIndex) + } + + case .unconditional: + // Parse the next token as a value, regardless of its type + guard currentIndex < tokens.count else { + throw TemplateError.missingValueForOption(name: arg.valueName ?? "") + } + values.append(tokens[currentIndex]) + tokens.remove(at: currentIndex) + + case .upToNextOption: + // Parse multiple values up to the next non-value + while currentIndex < tokens.count && !tokens[currentIndex].starts(with: "-") { + values.append(tokens[currentIndex]) + tokens.remove(at: currentIndex) + } + + case .allRemainingInput, .postTerminator, .allUnrecognized: + // These are handled separately in the main parsing logic + if currentIndex < tokens.count { + values.append(tokens[currentIndex]) + tokens.remove(at: currentIndex) + } + } + + return values + } /// Determines the rest of the arguments that need a user's response /// @@ -462,14 +589,10 @@ public final class TemplatePromptingSystem { /// Converts the command information into an array of argument metadata. /// /// - Parameter command: The command info object. - /// - Returns: An array of argument info objects. - /// - Throws: `TemplateError.noArguments` if the command has no arguments. + /// - Returns: An array of argument info objects. Returns empty array if command has no arguments. private func convertArguments(from command: CommandInfoV0) throws -> [ArgumentInfoV0] { - guard let rawArgs = command.arguments else { - throw TemplateError.noArguments - } - return rawArgs + return command.arguments ?? [] } /// A helper struct to prompt the user for input values for command arguments. @@ -482,11 +605,15 @@ public final class TemplatePromptingSystem { public static func prompt( for arguments: [ArgumentInfoV0], - collected: inout [String: ArgumentResponse] - ) -> [ArgumentResponse] { - return arguments + collected: inout [String: ArgumentResponse], + hasTTY: Bool = true + ) throws -> [ArgumentResponse] { + return try arguments .filter { $0.valueName != "help" && $0.shouldDisplay } .compactMap { arg in + + // check flag or option or positional + // flag: let key = arg.preferredName?.name ?? arg.valueName ?? UUID().uuidString if let existing = collected[key] { @@ -497,54 +624,142 @@ public final class TemplatePromptingSystem { let defaultText = arg.defaultValue.map { " (default: \($0))" } ?? "" let allValuesText = (arg.allValues?.isEmpty == false) ? " [\(arg.allValues!.joined(separator: ", "))]" : "" - let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(defaultText):" + let completionText = generateCompletionHint(for: arg) + let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(completionText)\(defaultText):" var values: [String] = [] switch arg.kind { case .flag: - let confirmed = promptForConfirmation( - prompt: promptMessage, - defaultBehavior: arg.defaultValue?.lowercased() == "true" - ) - values = [confirmed ? "true" : "false"] + if !hasTTY && arg.isOptional == false && arg.defaultValue == nil { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: arg.valueName ?? "") + + } + var confirmed: Bool? = nil + if hasTTY { + confirmed = TemplatePromptingSystem.promptForConfirmation( + prompt: promptMessage, + defaultBehavior: arg.defaultValue?.lowercased() == "true", + isOptional: arg.isOptional + ) + } + if let confirmed { + values = [confirmed ? "true" : "false"] + } else if arg.isOptional { + // Flag was explicitly unset + let response = ArgumentResponse(argument: arg, values: [], isExplicitlyUnset: true) + collected[key] = response + return response + } case .option, .positional: - print(promptMessage) + if !hasTTY && arg.isOptional == false && arg.defaultValue == nil { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: arg.valueName ?? "") + } + + if hasTTY { + let nilSuffix = arg.isOptional ? " (or enter \"nil\" to unset)" : "" + print(promptMessage + nilSuffix) + } if arg.isRepeating { - while let input = readLine(), !input.isEmpty { - if let allowed = arg.allValues, !allowed.contains(input) { - print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") - continue + if hasTTY { + while let input = readLine(), !input.isEmpty { + if input.lowercased() == "nil" && arg.isOptional { + // Clear the values array to explicitly unset + values = [] + let response = ArgumentResponse(argument: arg, values: values, isExplicitlyUnset: true) + collected[key] = response + return response + } + if let allowed = arg.allValues, !allowed.contains(input) { + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + continue + } + values.append(input) } - values.append(input) } if values.isEmpty, let def = arg.defaultValue { values = [def] } } else { - let input = readLine() + let input = hasTTY ? readLine() : nil if let input, !input.isEmpty { - if let allowed = arg.allValues, !allowed.contains(input) { - print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") - exit(1) - } - values = [input] + + if input.lowercased() == "nil" && arg.isOptional { + values = [] + let response = ArgumentResponse(argument: arg, values: values, isExplicitlyUnset: true) + collected[key] = response + return response + } else { + if let allowed = arg.allValues, !allowed.contains(input) { + if hasTTY { + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + print("Or try completion suggestions: \(generateCompletionSuggestions(for: arg, input: input))") + exit(1) + } else { + throw TemplateError.invalidValue(argument: arg.valueName ?? "", invalidValues: [input], allowed: allowed) + } + } + values = [input] + } } else if let def = arg.defaultValue { values = [def] } else if arg.isOptional == false { - fatalError("Required argument '\(arg.valueName ?? "")' not provided.") + if hasTTY { + fatalError("Required argument '\(arg.valueName ?? "")' not provided.") + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: arg.valueName ?? "") + } } } } - let response = ArgumentResponse(argument: arg, values: values) + let response = ArgumentResponse(argument: arg, values: values, isExplicitlyUnset: false) collected[key] = response return response } } + + /// Generates completion hint text based on CompletionKindV0 + private static func generateCompletionHint(for arg: ArgumentInfoV0) -> String { + guard let completionKind = arg.completionKind else { return "" } + + switch completionKind { + case .list(let values): + return " (suggestions: \(values.joined(separator: ", ")))" + case .file(let extensions): + if extensions.isEmpty { + return " (file completion available)" + } else { + return " (file completion: .\(extensions.joined(separator: ", .")))" + } + case .directory: + return " (directory completion available)" + case .shellCommand(let command): + return " (shell completions available: \(command))" + case .custom, .customAsync: + return " (custom completions available)" + case .customDeprecated: + return " (custom completions available)" + } + } + + /// Generates completion suggestions based on input and CompletionKindV0 + private static func generateCompletionSuggestions(for arg: ArgumentInfoV0, input: String) -> String { + guard let completionKind = arg.completionKind else { + return "No completions available" + } + + switch completionKind { + case .list(let values): + let suggestions = values.filter { $0.hasPrefix(input) } + return suggestions.isEmpty ? "No matching suggestions" : suggestions.joined(separator: ", ") + case .file, .directory, .shellCommand, .custom, .customAsync, .customDeprecated: + return "Use system completion for suggestions" + } + } } /// Builds an array of command line argument strings from the given argument responses. @@ -563,8 +778,12 @@ public final class TemplatePromptingSystem { /// - defaultBehavior: The default value if the user provides no input. /// - Returns: `true` if the user confirmed, otherwise `false`. - private static func promptForConfirmation(prompt: String, defaultBehavior: Bool?) -> Bool { - let suffix = defaultBehavior == true ? " [Y/n]" : defaultBehavior == false ? " [y/N]" : " [y/n]" + static func promptForConfirmation(prompt: String, defaultBehavior: Bool?, isOptional: Bool) -> Bool? { + var suffix = defaultBehavior == true ? " [Y/n]" : defaultBehavior == false ? " [y/N]" : " [y/n]" + + if isOptional { + suffix = suffix + "or enter \"nil\" to unset." + } print(prompt + suffix, terminator: " ") guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { return defaultBehavior ?? false @@ -573,6 +792,7 @@ public final class TemplatePromptingSystem { switch input { case "y", "yes": return true case "n", "no": return false + case "nil": return nil default: return defaultBehavior ?? false } } @@ -585,9 +805,15 @@ public final class TemplatePromptingSystem { /// The values provided by the user. public let values: [String] + + /// Whether the argument was explicitly unset (nil) by the user. + public let isExplicitlyUnset: Bool /// Returns the command line fragments representing this argument and its values. public var commandLineFragments: [String] { + // If explicitly unset, don't generate any command line fragments + guard !isExplicitlyUnset else { return [] } + guard let name = argument.valueName else { return self.values } @@ -601,6 +827,13 @@ public final class TemplatePromptingSystem { return self.values } } + + /// Initialize with explicit unset state + public init(argument: ArgumentInfoV0, values: [String], isExplicitlyUnset: Bool = false) { + self.argument = argument + self.values = values + self.isExplicitlyUnset = isExplicitlyUnset + } } } @@ -614,13 +847,14 @@ private enum TemplateError: Swift.Error { case manifestAlreadyExists /// The template has no arguments to prompt for. - case noArguments case invalidArgument(name: String) case unexpectedArgument(name: String) case unexpectedNamedArgument(name: String) case missingValueForOption(name: String) case invalidValue(argument: String, invalidValues: [String], allowed: [String]) + case missingRequiredArgumentWithoutTTY(name: String) + case noTTYForSubcommandSelection } extension TemplateError: CustomStringConvertible { @@ -643,6 +877,10 @@ extension TemplateError: CustomStringConvertible { "Missing value for option: \(name)" case .invalidValue(argument: let argument, invalidValues: let invalidValues, allowed: let allowed): "Invalid value \(argument). Valid values are: \(allowed.joined(separator: ", ")). \(invalidValues.isEmpty ? "" : "Also, \(invalidValues.joined(separator: ", ")) are not valid.")" + case .missingRequiredArgumentWithoutTTY(name: let name): + "Required argument '\(name)' not provided and no interactive terminal available" + case .noTTYForSubcommandSelection: + "Cannot select subcommand interactively - no terminal available" } } } diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index c58eb2c4847..90a4d61ac84 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -1770,8 +1770,51 @@ struct PackageCommandTests { #expect(localFileSystem.exists(destinationPath.appending("Sources").appending("foo"))) } } + + @Test( + .tags( + .Feature.Command.Package.Init, + .Feature.PackageType.LocalTemplate, + ), + .skipHostOS(.windows, "Git operations not fully supported in test environment"), + .requireUnrestrictedNetworkAccess("Test needs to create and access local git repositories"), + arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), + ) + func initGitTemplate( + data: BuildData + ) async throws { + try await testWithTemporaryDirectory { tempDir in + let templateRepoPath = tempDir.appending("template-repo") + let destinationPath = tempDir.appending("Foo") + try localFileSystem.createDirectory(destinationPath) + + try fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { fixturePath in + try localFileSystem.copy(from: fixturePath, to: templateRepoPath) + } + + initGitRepo(templateRepoPath, tag: "1.0.0") + + _ = try await execute( + ["--package-path", destinationPath.pathString, + "init", "--type", "ExecutableTemplate", + "--url", templateRepoPath.pathString, + "--exact", "1.0.0", "--", "--name", "foo", "--include-readme"], + packagePath: templateRepoPath, + configuration: data.config, + buildSystem: data.buildSystem, + ) + + let manifest = destinationPath.appending("Package.swift") + let readMe = destinationPath.appending("README.md") + expectFileExists(at: manifest) + expectFileExists(at: readMe) + #expect(localFileSystem.exists(destinationPath.appending("Sources").appending("foo"))) + } + } } + + @Suite( .tags( .Feature.Command.Package.AddDependency, diff --git a/Tests/CommandsTests/TemplateTests.swift b/Tests/CommandsTests/TemplateTests.swift index 20dce6264f1..9bcc1381350 100644 --- a/Tests/CommandsTests/TemplateTests.swift +++ b/Tests/CommandsTests/TemplateTests.swift @@ -15,7 +15,8 @@ import Basics @_spi(SwiftPMInternal) @testable import CoreCommands @testable import Commands - +@testable import Workspace +import ArgumentParserToolInfo import Foundation @_spi(DontAdoptOutsideOfSwiftPMExposedForBenchmarksAndTestsOnly) @@ -82,7 +83,6 @@ fileprivate func makeTestDependencyData() throws -> (tool: SwiftCommandState, pa .Feature.Command.Package.General, ), ) - struct TemplateTests{ // MARK: - Template Source Resolution Tests @Suite( @@ -938,48 +938,49 @@ struct TemplateTests{ struct PackageInitializerConfigurationTests { @Test func createPackageInitializer() throws { + try testWithTemporaryDirectory { tempDir in - let globalOptions = try GlobalOptions.parse([]) - let testLibraryOptions = try TestLibraryOptions.parse([]) - let buildOptions = try BuildCommandOptions.parse([]) - let directoryPath = AbsolutePath("/") - let tool = try SwiftCommandState.makeMockState(options: globalOptions) - + let globalOptions = try GlobalOptions.parse(["--package-path", tempDir.pathString]) + let testLibraryOptions = try TestLibraryOptions.parse([]) + let buildOptions = try BuildCommandOptions.parse([]) + let directoryPath = AbsolutePath("/") + let tool = try SwiftCommandState.makeMockState(options: globalOptions) - let templatePackageInitializer = try PackageInitConfiguration( - swiftCommandState: tool, - name: "foo", - initMode: "template", - testLibraryOptions: testLibraryOptions, - buildOptions: buildOptions, - globalOptions: globalOptions, - validatePackage: true, - args: ["--foobar foo"], - directory: directoryPath, - url: nil, - packageID: "foo.bar", - versionFlags: VersionFlags(exact: nil, revision: nil, branch: "master", from: nil, upToNextMinorFrom: nil ,to: nil) - ).makeInitializer() - - #expect(templatePackageInitializer is TemplatePackageInitializer) - - - let standardPackageInitalizer = try PackageInitConfiguration( - swiftCommandState: tool, - name: "foo", - initMode: "template", - testLibraryOptions: testLibraryOptions, - buildOptions: buildOptions, - globalOptions: globalOptions, - validatePackage: true, - args: ["--foobar foo"], - directory: nil, - url: nil, - packageID: nil, - versionFlags: VersionFlags(exact: nil, revision: nil, branch: "master", from: nil, upToNextMinorFrom: nil ,to: nil) - ).makeInitializer() - - #expect(standardPackageInitalizer is StandardPackageInitializer) + let templatePackageInitializer = try PackageInitConfiguration( + swiftCommandState: tool, + name: "foo", + initMode: "template", + testLibraryOptions: testLibraryOptions, + buildOptions: buildOptions, + globalOptions: globalOptions, + validatePackage: true, + args: ["--foobar foo"], + directory: directoryPath, + url: nil, + packageID: "foo.bar", + versionFlags: VersionFlags(exact: nil, revision: nil, branch: "master", from: nil, upToNextMinorFrom: nil ,to: nil) + ).makeInitializer() + + #expect(templatePackageInitializer is TemplatePackageInitializer) + + + let standardPackageInitalizer = try PackageInitConfiguration( + swiftCommandState: tool, + name: "foo", + initMode: "template", + testLibraryOptions: testLibraryOptions, + buildOptions: buildOptions, + globalOptions: globalOptions, + validatePackage: true, + args: ["--foobar foo"], + directory: nil, + url: nil, + packageID: nil, + versionFlags: VersionFlags(exact: nil, revision: nil, branch: "master", from: nil, upToNextMinorFrom: nil ,to: nil) + ).makeInitializer() + + #expect(standardPackageInitalizer is StandardPackageInitializer) + } } // TODO: Re-enable once SwiftCommandState mocking issues are resolved @@ -1004,4 +1005,1250 @@ struct TemplateTests{ } */ } + + // MARK: - Template Prompting System Tests + @Suite( + .tags( + Tag.TestSize.medium, + Tag.Feature.Command.Package.Init, + ), + ) + struct TemplatePromptingSystemTests { + + // MARK: - Helper Methods + + private func createTestCommand( + name: String = "test-template", + arguments: [ArgumentInfoV0] = [], + subcommands: [CommandInfoV0]? = nil + ) -> CommandInfoV0 { + return CommandInfoV0( + superCommands: [], + shouldDisplay: true, + commandName: name, + abstract: "Test template command", + discussion: "A command for testing template prompting", + defaultSubcommand: nil, + subcommands: subcommands ?? [], + arguments: arguments + ) + } + + private func createRequiredOption(name: String, defaultValue: String? = nil, allValues: [String]? = nil, parsingStrategy: ArgumentInfoV0.ParsingStrategyV0 = .default, completionKind: ArgumentInfoV0.CompletionKindV0? = nil) -> ArgumentInfoV0 { + return ArgumentInfoV0( + kind: .option, + shouldDisplay: true, + sectionTitle: nil, + isOptional: false, + isRepeating: false, + parsingStrategy: parsingStrategy, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: name)], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: name), + valueName: name, + defaultValue: defaultValue, + allValueStrings: allValues, + allValueDescriptions: nil, + completionKind: completionKind, + abstract: "\(name.capitalized) parameter", + discussion: nil + ) + } + + private func createOptionalOption(name: String, defaultValue: String? = nil, allValues: [String]? = nil, completionKind: ArgumentInfoV0.CompletionKindV0? = nil) -> ArgumentInfoV0 { + return ArgumentInfoV0( + kind: .option, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: false, + parsingStrategy: .default, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: name)], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: name), + valueName: name, + defaultValue: defaultValue, + allValueStrings: allValues, + allValueDescriptions: nil, + completionKind: completionKind, + abstract: "\(name.capitalized) parameter", + discussion: nil + ) + } + + private func createOptionalFlag(name: String, defaultValue: String? = nil, completionKind: ArgumentInfoV0.CompletionKindV0? = nil) -> ArgumentInfoV0 { + return ArgumentInfoV0( + kind: .flag, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: false, + parsingStrategy: .default, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: name)], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: name), + valueName: name, + defaultValue: defaultValue, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: completionKind, + abstract: "\(name.capitalized) flag", + discussion: nil + ) + } + + private func createPositionalArgument(name: String, isOptional: Bool = false, defaultValue: String? = nil, parsingStrategy: ArgumentInfoV0.ParsingStrategyV0 = .default) -> ArgumentInfoV0 { + return ArgumentInfoV0( + kind: .positional, + shouldDisplay: true, + sectionTitle: nil, + isOptional: isOptional, + isRepeating: false, + parsingStrategy: parsingStrategy, + names: nil, + preferredName: nil, + valueName: name, + defaultValue: defaultValue, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "\(name.capitalized) positional argument", + discussion: nil + ) + } + + // MARK: - Basic Functionality Tests + + @Test func createsPromptingSystemSuccessfully() throws { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let emptyCommand = createTestCommand(name: "empty") + + let result = try promptingSystem.promptUser( + command: emptyCommand, + arguments: [] + ) + #expect(result.isEmpty) + } + + @Test func handlesCommandWithProvidedArguments() throws { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = createTestCommand( + arguments: [createRequiredOption(name: "name")] + ) + + let result = try promptingSystem.promptUser( + command: commandInfo, + arguments: ["--name", "TestPackage"] + ) + + #expect(result.contains("--name")) + #expect(result.contains("TestPackage")) + } + + @Test func handlesOptionalArgumentsWithDefaults() throws { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = createTestCommand( + arguments: [ + createRequiredOption(name: "name"), + createOptionalFlag(name: "include-readme", defaultValue: "false") + ] + ) + + let result = try promptingSystem.promptUser( + command: commandInfo, + arguments: ["--name", "TestPackage"] + ) + + #expect(result.contains("--name")) + #expect(result.contains("TestPackage")) + // Flag with default "false" should not appear in command line + #expect(!result.contains("--include-readme")) + } + + @Test func validatesMissingRequiredArguments() throws { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = createTestCommand( + arguments: [createRequiredOption(name: "name")] + ) + + #expect(throws: Error.self) { + _ = try promptingSystem.promptUser( + command: commandInfo, + arguments: [] + ) + } + } + + // MARK: - Argument Response Tests + + @Test func argumentResponseHandlesExplicitlyUnsetFlags() throws { + let arg = createOptionalFlag(name: "verbose", defaultValue: "false") + + // Test explicitly unset flag + let explicitlyUnsetResponse = TemplatePromptingSystem.ArgumentResponse( + argument: arg, + values: [], + isExplicitlyUnset: true + ) + #expect(explicitlyUnsetResponse.isExplicitlyUnset == true) + #expect(explicitlyUnsetResponse.commandLineFragments.isEmpty) + + // Test normal flag response (true) + let trueResponse = TemplatePromptingSystem.ArgumentResponse( + argument: arg, + values: ["true"], + isExplicitlyUnset: false + ) + #expect(trueResponse.isExplicitlyUnset == false) + #expect(trueResponse.commandLineFragments == ["--verbose"]) + + // Test false flag response (should be empty) + let falseResponse = TemplatePromptingSystem.ArgumentResponse( + argument: arg, + values: ["false"], + isExplicitlyUnset: false + ) + #expect(falseResponse.commandLineFragments.isEmpty) + } + + @Test func argumentResponseHandlesExplicitlyUnsetOptions() throws { + let arg = createOptionalOption(name: "output") + + // Test explicitly unset option + let explicitlyUnsetResponse = TemplatePromptingSystem.ArgumentResponse( + argument: arg, + values: [], + isExplicitlyUnset: true + ) + #expect(explicitlyUnsetResponse.isExplicitlyUnset == true) + #expect(explicitlyUnsetResponse.commandLineFragments.isEmpty) + + // Test normal option response + let normalResponse = TemplatePromptingSystem.ArgumentResponse( + argument: arg, + values: ["./output"], + isExplicitlyUnset: false + ) + #expect(normalResponse.isExplicitlyUnset == false) + #expect(normalResponse.commandLineFragments == ["--output", "./output"]) + + // Test multiple values option + let multiValueArg = ArgumentInfoV0( + kind: .option, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: true, + parsingStrategy: .default, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "define")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "define"), + valueName: "define", + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: .none, + abstract: "Define parameter", + discussion: nil + ) + + let multiValueResponse = TemplatePromptingSystem.ArgumentResponse( + argument: multiValueArg, + values: ["FOO=bar", "BAZ=qux"], + isExplicitlyUnset: false + ) + #expect(multiValueResponse.commandLineFragments == ["--define", "FOO=bar", "--define", "BAZ=qux"]) + } + + @Test func argumentResponseHandlesPositionalArguments() throws { + let arg = createPositionalArgument(name: "target", isOptional: true) + + // Test explicitly unset positional + let explicitlyUnsetResponse = TemplatePromptingSystem.ArgumentResponse( + argument: arg, + values: [], + isExplicitlyUnset: true + ) + #expect(explicitlyUnsetResponse.isExplicitlyUnset == true) + #expect(explicitlyUnsetResponse.commandLineFragments.isEmpty) + + // Test normal positional response + let normalResponse = TemplatePromptingSystem.ArgumentResponse( + argument: arg, + values: ["MyTarget"], + isExplicitlyUnset: false + ) + #expect(normalResponse.isExplicitlyUnset == false) + #expect(normalResponse.commandLineFragments == ["MyTarget"]) + } + + // MARK: - Command Line Generation Tests + + @Test func commandLineGenerationWithMixedArgumentStates() throws { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + + let flagArg = createOptionalFlag(name: "verbose") + let requiredOptionArg = createRequiredOption(name: "name") + let optionalOptionArg = createOptionalOption(name: "output") + let positionalArg = createPositionalArgument(name: "target", isOptional: true) + + // Create responses with mixed states + let responses = [ + TemplatePromptingSystem.ArgumentResponse(argument: flagArg, values: [], isExplicitlyUnset: true), + TemplatePromptingSystem.ArgumentResponse(argument: requiredOptionArg, values: ["TestPackage"], isExplicitlyUnset: false), + TemplatePromptingSystem.ArgumentResponse(argument: optionalOptionArg, values: [], isExplicitlyUnset: true), + TemplatePromptingSystem.ArgumentResponse(argument: positionalArg, values: ["MyTarget"], isExplicitlyUnset: false) + ] + + let commandLine = promptingSystem.buildCommandLine(from: responses) + + // Should only contain the non-unset arguments + #expect(commandLine == ["--name", "TestPackage", "MyTarget"]) + } + + @Test func commandLineGenerationWithDefaultValues() throws { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + + let optionWithDefault = createOptionalOption(name: "version", defaultValue: "1.0.0") + let flagWithDefault = createOptionalFlag(name: "enabled", defaultValue: "true") + + let responses = [ + TemplatePromptingSystem.ArgumentResponse(argument: optionWithDefault, values: ["1.0.0"], isExplicitlyUnset: false), + TemplatePromptingSystem.ArgumentResponse(argument: flagWithDefault, values: ["true"], isExplicitlyUnset: false) + ] + + let commandLine = promptingSystem.buildCommandLine(from: responses) + + #expect(commandLine == ["--version", "1.0.0", "--enabled"]) + } + + // MARK: - Argument Parsing Tests + + @Test func parsesProvidedArgumentsCorrectly() throws { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = createTestCommand( + arguments: [ + createRequiredOption(name: "name"), + createOptionalFlag(name: "verbose"), + createOptionalOption(name: "output") + ] + ) + + let result = try promptingSystem.promptUser( + command: commandInfo, + arguments: ["--name", "TestPackage", "--verbose", "--output", "./dist"] + ) + + #expect(result.contains("--name")) + #expect(result.contains("TestPackage")) + #expect(result.contains("--verbose")) + #expect(result.contains("--output")) + #expect(result.contains("./dist")) + } + + @Test func handlesValidationWithAllowedValues() throws { + let restrictedArg = createRequiredOption( + name: "type", + allValues: ["executable", "library", "plugin"] + ) + + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = createTestCommand(arguments: [restrictedArg]) + + // Valid value should work + let validResult = try promptingSystem.promptUser( + command: commandInfo, + arguments: ["--type", "executable"] + ) + #expect(validResult.contains("executable")) + + // Invalid value should throw + #expect(throws: Error.self) { + _ = try promptingSystem.promptUser( + command: commandInfo, + arguments: ["--type", "invalid"] + ) + } + } + + // MARK: - Subcommand Tests + + @Test func handlesSubcommandDetection() throws { + let subcommand = createTestCommand( + name: "init", + arguments: [createRequiredOption(name: "name")] + ) + + let mainCommand = createTestCommand( + name: "package", + subcommands: [subcommand] + ) + + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + + let result = try promptingSystem.promptUser( + command: mainCommand, + arguments: ["init", "--name", "TestPackage"] + ) + + #expect(result.contains("init")) + #expect(result.contains("--name")) + #expect(result.contains("TestPackage")) + } + + // MARK: - Error Handling Tests + + @Test func handlesInvalidArgumentNames() throws { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = createTestCommand( + arguments: [createRequiredOption(name: "name")] + ) + + // Should handle unknown arguments gracefully by treating them as potential subcommands + let result = try promptingSystem.promptUser( + command: commandInfo, + arguments: ["--name", "TestPackage", "--unknown", "value"] + ) + + #expect(result.contains("--name")) + #expect(result.contains("TestPackage")) + } + + @Test func handlesMissingValueForOption() throws { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = createTestCommand( + arguments: [createRequiredOption(name: "name")] + ) + + #expect(throws: Error.self) { + _ = try promptingSystem.promptUser( + command: commandInfo, + arguments: ["--name"] + ) + } + } + + @Test func handlesNestedSubcommands() throws { + let innerSubcommand = createTestCommand( + name: "create", + arguments: [createRequiredOption(name: "name")] + ) + + let outerSubcommand = createTestCommand( + name: "package", + subcommands: [innerSubcommand] + ) + + let mainCommand = createTestCommand( + name: "swift", + subcommands: [outerSubcommand] + ) + + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + + let result = try promptingSystem.promptUser( + command: mainCommand, + arguments: ["package", "create", "--name", "MyPackage"] + ) + + #expect(result.contains("package")) + #expect(result.contains("create")) + #expect(result.contains("--name")) + #expect(result.contains("MyPackage")) + } + + // MARK: - Integration Tests + + @Test func handlesComplexCommandStructure() throws { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + + let complexCommand = createTestCommand( + arguments: [ + createRequiredOption(name: "name"), + createOptionalOption(name: "output", defaultValue: "./build"), + createOptionalFlag(name: "verbose", defaultValue: "false"), + createPositionalArgument(name: "target", isOptional: true, defaultValue: "main") + ] + ) + + let result = try promptingSystem.promptUser( + command: complexCommand, + arguments: ["--name", "TestPackage", "--verbose", "CustomTarget"] + ) + + #expect(result.contains("--name")) + #expect(result.contains("TestPackage")) + #expect(result.contains("--verbose")) + #expect(result.contains("CustomTarget")) + // Default values for optional arguments should be included when no explicit value provided + #expect(result.contains("--output")) + #expect(result.contains("./build")) + } + + @Test func handlesEmptyInputCorrectly() throws { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = createTestCommand( + arguments: [ + createOptionalOption(name: "output", defaultValue: "default"), + createOptionalFlag(name: "verbose", defaultValue: "false") + ] + ) + + let result = try promptingSystem.promptUser( + command: commandInfo, + arguments: [] + ) + + // Should contain default values where appropriate + #expect(result.contains("--output")) + #expect(result.contains("default")) + #expect(!result.contains("--verbose")) // false flag shouldn't appear + } + + @Test func handlesRepeatingArguments() throws { + let repeatingArg = ArgumentInfoV0( + kind: .option, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: true, + parsingStrategy: .default, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "define")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "define"), + valueName: "define", + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "Define parameter", + discussion: nil + ) + + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = createTestCommand(arguments: [repeatingArg]) + + let result = try promptingSystem.promptUser( + command: commandInfo, + arguments: ["--define", "FOO=bar", "--define", "BAZ=qux"] + ) + + #expect(result.contains("--define")) + #expect(result.contains("FOO=bar")) + #expect(result.contains("BAZ=qux")) + } + + @Test func handlesArgumentValidationWithCustomCompletions() throws { + let completionArg = ArgumentInfoV0( + kind: .option, + shouldDisplay: true, + sectionTitle: nil, + isOptional: false, + isRepeating: false, + parsingStrategy: .default, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "platform")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "platform"), + valueName: "platform", + defaultValue: nil, + allValueStrings: ["iOS", "macOS", "watchOS", "tvOS"], + allValueDescriptions: nil, + completionKind: .list(values: ["iOS", "macOS", "watchOS", "tvOS"]), + abstract: "Target platform", + discussion: nil + ) + + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = createTestCommand(arguments: [completionArg]) + + // Valid completion value should work + let validResult = try promptingSystem.promptUser( + command: commandInfo, + arguments: ["--platform", "iOS"] + ) + #expect(validResult.contains("iOS")) + + // Invalid completion value should throw + #expect(throws: Error.self) { + _ = try promptingSystem.promptUser( + command: commandInfo, + arguments: ["--platform", "Linux"] + ) + } + } + + @Test func handlesArgumentResponseBuilding() throws { + let flagArg = createOptionalFlag(name: "verbose") + let optionArg = createRequiredOption(name: "output") + let positionalArg = createPositionalArgument(name: "target") + + // Test various response scenarios + let flagResponse = TemplatePromptingSystem.ArgumentResponse( + argument: flagArg, + values: ["true"], + isExplicitlyUnset: false + ) + #expect(flagResponse.commandLineFragments == ["--verbose"]) + + let optionResponse = TemplatePromptingSystem.ArgumentResponse( + argument: optionArg, + values: ["./output"], + isExplicitlyUnset: false + ) + #expect(optionResponse.commandLineFragments == ["--output", "./output"]) + + let positionalResponse = TemplatePromptingSystem.ArgumentResponse( + argument: positionalArg, + values: ["MyTarget"], + isExplicitlyUnset: false + ) + #expect(positionalResponse.commandLineFragments == ["MyTarget"]) + } + + @Test func handlesMissingArgumentErrors() throws { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = createTestCommand( + arguments: [ + createRequiredOption(name: "required-arg"), + createOptionalOption(name: "optional-arg") + ] + ) + + // Should throw when required argument is missing + #expect(throws: Error.self) { + _ = try promptingSystem.promptUser( + command: commandInfo, + arguments: ["--optional-arg", "value"] + ) + } + } + + // MARK: - Parsing Strategy Tests + + @Test func handlesParsingStrategies() throws { + let upToNextOptionArg = createRequiredOption( + name: "files", + parsingStrategy: .upToNextOption + ) + + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = createTestCommand(arguments: [upToNextOptionArg]) + + let result = try promptingSystem.promptUser( + command: commandInfo, + arguments: ["--files", "file1.swift", "file2.swift", "file3.swift"] + ) + + #expect(result.contains("--files")) + #expect(result.contains("file1.swift")) + } + + @Test func handlesTerminatorParsing() throws { + let postTerminatorArg = ArgumentInfoV0( + kind: .option, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: false, + parsingStrategy: .postTerminator, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "post-args")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "post-args"), + valueName: "post-args", + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "Post-terminator arguments", + discussion: nil + ) + + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = createTestCommand( + arguments: [ + createRequiredOption(name: "name"), + postTerminatorArg + ] + ) + + let result = try promptingSystem.promptUser( + command: commandInfo, + arguments: ["--name", "TestPackage", "--", "arg1", "arg2"] + ) + + #expect(result.contains("--name")) + #expect(result.contains("TestPackage")) + // Post-terminator args should be handled separately + } + } + + // MARK: - Template Plugin Coordinator Tests + @Suite( + .tags( + Tag.TestSize.medium, + Tag.Feature.Command.Package.Init, + ), + ) + struct TemplatePluginCoordinatorTests { + + @Test func createsCoordinatorWithValidConfiguration() async throws { + try testWithTemporaryDirectory { tempDir in + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + let coordinator = TemplatePluginCoordinator( + buildSystem: .native, + swiftCommandState: tool, + scratchDirectory: tempDir, + template: "ExecutableTemplate", + args: ["--name", "TestPackage"], + branches: [] + ) + + // Test coordinator functionality by verifying it can handle basic operations + #expect(coordinator.buildSystem == .native) + #expect(coordinator.scratchDirectory == tempDir) + } + } + @Test func loadsPackageGraphInTemporaryWorkspace() async throws { //precondition linux error + try await fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in + try await testWithTemporaryDirectory { tempDir in + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + // Copy template to temporary directory for workspace loading + let workspaceDir = tempDir.appending("workspace") + try tool.fileSystem.copy(from: templatePath, to: workspaceDir) + + let coordinator = TemplatePluginCoordinator( + buildSystem: .native, + swiftCommandState: tool, + scratchDirectory: workspaceDir, + template: "ExecutableTemplate", + args: ["--name", "TestPackage"], + branches: [] + ) + + // Test coordinator's ability to load package graph + // The coordinator handles the workspace switching internally + let graph = try await coordinator.loadPackageGraph() + #expect(!graph.rootPackages.isEmpty, "Package graph should have root packages") + } + } + } + + @Test func handlesInvalidTemplateGracefully() async throws { + try await testWithTemporaryDirectory { tempDir in + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + let coordinator = TemplatePluginCoordinator( + buildSystem: .native, + swiftCommandState: tool, + scratchDirectory: tempDir, + template: "NonexistentTemplate", + args: ["--name", "TestPackage"], + branches: [] + ) + + // Test that coordinator handles invalid template name by throwing appropriate error + await #expect(throws: (any Error).self) { + _ = try await coordinator.loadPackageGraph() + } + } + } + } + + // MARK: - Template Plugin Runner Tests + @Suite( + .tags( + Tag.TestSize.medium, + Tag.Feature.Command.Package.Init, + ), + ) + struct TemplatePluginRunnerTests { + + @Test func handlesPluginExecutionForValidPackage() async throws { //precondition linux error + + try await fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in + try await testWithTemporaryDirectory { tempDir in + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + // Test that TemplatePluginRunner can handle static execution + try await tool.withTemporaryWorkspace(switchingTo: templatePath) { _, _ in + let graph = try await tool.loadPackageGraph() + let rootPackage = graph.rootPackages.first! + + // Verify we can identify plugins for execution + let pluginModules = rootPackage.modules.filter { $0.type == .plugin } + #expect(!pluginModules.isEmpty, "Template should have plugin modules") + } + } + } + } + + @Test func handlesPluginExecutionStaticAPI() async throws { //precondition linux error + + try await fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in + try await testWithTemporaryDirectory { tempDir in + let packagePath = tempDir.appending("TestPackage") + try makeDirectories(packagePath) + + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + // Test that TemplatePluginRunner static API works with valid input + try await tool.withTemporaryWorkspace(switchingTo: templatePath) { _, _ in + let graph = try await tool.loadPackageGraph() + let rootPackage = graph.rootPackages.first! + + // Test plugin execution readiness + #expect(!graph.rootPackages.isEmpty, "Should have root packages for plugin execution") + #expect(rootPackage.modules.contains { $0.type == .plugin }, "Should have plugin modules available") + } + } + } + } + } + + // MARK: - Template Build Support Tests + @Suite( + .tags( + Tag.TestSize.medium, + Tag.Feature.Command.Package.Init, + ), + ) + struct TemplateBuildSupportTests { + + @Test func buildForTestingWithValidTemplate() async throws { //precondition linux error + try await fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in + try await testWithTemporaryDirectory { tempDir in + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + let buildOptions = try BuildCommandOptions.parse([]) + + // Test TemplateBuildSupport static API for building templates + try await TemplateBuildSupport.buildForTesting( + swiftCommandState: tool, + buildOptions: buildOptions, + testingFolder: templatePath + ) + + // Verify build succeeds without errors + #expect(tool.fileSystem.exists(templatePath), "Template path should still exist after build") + } + } + } + + @Test func buildWithValidConfiguration() async throws { //build system provider error + try await fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in + try await testWithTemporaryDirectory { tempDir in + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + let buildOptions = try BuildCommandOptions.parse([]) + let globalOptions = try GlobalOptions.parse([]) + + // Test TemplateBuildSupport.build static method + try await TemplateBuildSupport.build( + swiftCommandState: tool, + buildOptions: buildOptions, + globalOptions: globalOptions, + cwd: templatePath, + transitiveFolder: nil + ) + + // Verify build configuration works with template + #expect(tool.fileSystem.exists(templatePath.appending("Package.swift")), "Package.swift should exist") + } + } + } + } + + // MARK: - InitTemplatePackage Tests + @Suite( + .tags( + Tag.TestSize.medium, + Tag.Feature.Command.Package.Init, + Tag.Feature.PackageType.LocalTemplate, + ), + ) + struct InitTemplatePackageTests { + + @Test func createsTemplatePackageWithValidConfiguration() async throws { + try fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in + try testWithTemporaryDirectory { tempDir in + let packagePath = tempDir.appending("TestPackage") + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + // Create package dependency for template + let dependency = SwiftRefactor.PackageDependency.fileSystem( + SwiftRefactor.PackageDependency.FileSystem( + path: templatePath.pathString + ) + ) + + let initPackage = InitTemplatePackage( + name: "TestPackage", + initMode: dependency, + fileSystem: tool.fileSystem, + packageType: .executable, + supportedTestingLibraries: [.xctest], + destinationPath: packagePath, + installedSwiftPMConfiguration: try tool.getHostToolchain().installedSwiftPMConfiguration + ) + + // Test package configuration + #expect(initPackage.packageName == "TestPackage") + #expect(initPackage.packageType == .executable) + #expect(initPackage.destinationPath == packagePath) + } + } + } + + @Test func writesPackageStructureWithTemplateDependency() async throws { + try fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in + try testWithTemporaryDirectory { tempDir in + let packagePath = tempDir.appending("TestPackage") + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + let dependency = SwiftRefactor.PackageDependency.fileSystem( + SwiftRefactor.PackageDependency.FileSystem( + path: templatePath.pathString + ) + ) + + let initPackage = InitTemplatePackage( + name: "TestPackage", + initMode: dependency, + fileSystem: tool.fileSystem, + packageType: .executable, + supportedTestingLibraries: [.xctest], + destinationPath: packagePath, + installedSwiftPMConfiguration: try tool.getHostToolchain().installedSwiftPMConfiguration + ) + + try initPackage.setupTemplateManifest() + + // Verify package structure was created + #expect(tool.fileSystem.exists(packagePath)) + #expect(tool.fileSystem.exists(packagePath.appending("Package.swift"))) + #expect(tool.fileSystem.exists(packagePath.appending("Sources"))) + } + } + } + + @Test func handlesInvalidTemplatePath() async throws { + try await testWithTemporaryDirectory { tempDir in + let invalidTemplatePath = tempDir.appending("NonexistentTemplate") + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + // Should handle invalid template path gracefully + await #expect(throws: (any Error).self) { + let _ = try await TemplatePackageInitializer.inferPackageType(from: invalidTemplatePath, templateName: "foo", swiftCommandState: tool) + } + } + } + } + + // MARK: - Integration Tests for Template Workflows + @Suite( + .tags( + Tag.TestSize.large, + Tag.Feature.Command.Package.Init, + Tag.Feature.PackageType.LocalTemplate, + ), + ) + struct TemplateWorkflowIntegrationTests { + + @Test( + .skipHostOS(.windows, "Template operations not fully supported in test environment"), + arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), + ) + func templateResolutionToPackageCreationWorkflow( + data: BuildData, + ) async throws { + try await fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in + try await testWithTemporaryDirectory { tempDir in + let packagePath = tempDir.appending("TestPackage") + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + // Test complete workflow: Template Resolution → Package Creation + let resolver = try TemplatePathResolver( + source: .local, + templateDirectory: templatePath, + templateURL: nil, + sourceControlRequirement: nil, + registryRequirement: nil, + packageIdentity: nil, + swiftCommandState: tool + ) + + let resolvedPath = try await resolver.resolve() + #expect(resolvedPath == templatePath) + + // Create package dependency builder + let dependencyBuilder = DefaultPackageDependencyBuilder( + templateSource: .local, + packageName: "TestPackage", + templateURL: nil, + templatePackageID: nil, + sourceControlRequirement: nil, + registryRequirement: nil, + resolvedTemplatePath: resolvedPath + ) + + let packageDependency = try dependencyBuilder.makePackageDependency() + + // Verify dependency was created correctly + if case .fileSystem(let fileSystemDep) = packageDependency { + #expect(fileSystemDep.path == resolvedPath.pathString) + } else { + Issue.record("Expected fileSystem dependency, got \(packageDependency)") + } + + // Create template package + let initPackage = InitTemplatePackage( + name: "TestPackage", + initMode: packageDependency, + fileSystem: tool.fileSystem, + packageType: .executable, + supportedTestingLibraries: [.xctest], + destinationPath: packagePath, + installedSwiftPMConfiguration: try tool.getHostToolchain().installedSwiftPMConfiguration + ) + + try initPackage.setupTemplateManifest() + + // Verify complete package structure + #expect(tool.fileSystem.exists(packagePath)) + expectFileExists(at: packagePath.appending("Package.swift")) + expectDirectoryExists(at: packagePath.appending("Sources")) + + /* Bad memory access error here + // Verify package builds successfully + try await executeSwiftBuild( + packagePath, + configuration: data.config, + buildSystem: data.buildSystem + ) + + let buildPath = packagePath.appending(".build") + expectDirectoryExists(at: buildPath) + */ + } + } + } + + @Test( + .skipHostOS(.windows, "Git operations not fully supported in test environment"), + .requireUnrestrictedNetworkAccess("Test needs to create and access local git repositories"), + ) + func gitTemplateResolutionAndBuildWorkflow() async throws { + try await testWithTemporaryDirectory { tempDir in + let templateRepoPath = tempDir.appending("template-repo") + + // Copy template structure to git repo + try fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { fixturePath in + try localFileSystem.copy(from: fixturePath, to: templateRepoPath) + } + + initGitRepo(templateRepoPath, tag: "1.0.0") + + let sourceControlURL = SourceControlURL(stringLiteral: templateRepoPath.pathString) + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + // Test Git template resolution + let sourceControlRequirement = SwiftRefactor.PackageDependency.SourceControl.Requirement.branch("main") + + let resolver = try TemplatePathResolver( + source: .git, + templateDirectory: nil, + templateURL: sourceControlURL.url?.absoluteString, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: nil, + packageIdentity: nil, + swiftCommandState: tool + ) + + let resolvedPath = try await resolver.resolve() + #expect(localFileSystem.exists(resolvedPath)) + + // Verify template was fetched correctly with expected files + #expect(localFileSystem.exists(resolvedPath.appending("Package.swift"))) + #expect(localFileSystem.exists(resolvedPath.appending("Templates"))) + } + } + + @Test func pluginCoordinationWithBuildSystemIntegration() async throws { //Build provider not initialized. + try await fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in + try await testWithTemporaryDirectory { tempDir in + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + // Test plugin coordination with build system + let coordinator = TemplatePluginCoordinator( + buildSystem: .native, + swiftCommandState: tool, + scratchDirectory: tempDir, + template: "ExecutableTemplate", + args: ["--name", "TestPackage"], + branches: [] + ) + + // Test coordinator functionality + #expect(coordinator.buildSystem == .native) + #expect(coordinator.scratchDirectory == tempDir) + + // Test build support static API + let buildOptions = try BuildCommandOptions.parse([]) + try await TemplateBuildSupport.buildForTesting( + swiftCommandState: tool, + buildOptions: buildOptions, + testingFolder: templatePath + ) + + // Verify they can work together (no errors thrown) + #expect(coordinator.buildSystem == .native) + } + } + } + + @Test func packageDependencyBuildingWithVersionResolution() async throws { + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + let lowerBoundVersion = Version(stringLiteral: "1.2.0") + let higherBoundVersion = Version(stringLiteral: "3.0.0") + + // Test version requirement resolution integration + let versionResolver = DependencyRequirementResolver( + packageIdentity: "test.package", + swiftCommandState: tool, + exact: nil, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: nil, + to: higherBoundVersion + ) + + let sourceControlRequirement = try versionResolver.resolveSourceControl() + let registryRequirement = try await versionResolver.resolveRegistry() + + // Test dependency building with resolved requirements + let dependencyBuilder = DefaultPackageDependencyBuilder( + templateSource: .git, + packageName: "TestPackage", + templateURL: "https://github.com/example/template.git", + templatePackageID: "test.package", + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: try AbsolutePath(validating: "/fake/path") + ) + + let gitDependency = try dependencyBuilder.makePackageDependency() + + // Verify dependency structure + if case .sourceControl(let sourceControlDep) = gitDependency { + #expect(sourceControlDep.location == "https://github.com/example/template.git") + if case .range(let lower, let upper) = sourceControlDep.requirement { + #expect(lower == "1.2.0") + #expect(upper == "3.0.0") + } else { + Issue.record("Expected range requirement, got \(sourceControlDep.requirement)") + } + } else { + Issue.record("Expected sourceControl dependency, got \(gitDependency)") + } + } + } + + // MARK: - End-to-End Template Initialization Tests + @Suite( + .tags( + Tag.TestSize.large, + Tag.Feature.Command.Package.Init, + Tag.Feature.PackageType.LocalTemplate, + ), + ) + struct EndToEndTemplateInitializationTests { + @Test func templateInitializationErrorHandling() async throws { + try await testWithTemporaryDirectory { tempDir in + let packagePath = tempDir.appending("TestPackage") + let nonexistentPath = tempDir.appending("nonexistent-template") + let options = try GlobalOptions.parse(["--package-path", packagePath.pathString]) + let tool = try SwiftCommandState.makeMockState(options: options) + + // Test complete error handling workflow + await #expect(throws: (any Error).self) { + let configuration = try PackageInitConfiguration( + swiftCommandState: tool, + name: "TestPackage", + initMode: "custom", + testLibraryOptions: TestLibraryOptions.parse([]), + buildOptions: BuildCommandOptions.parse([]), + globalOptions: options, + validatePackage: false, + args: ["--name", "TestPackage"], + directory: nonexistentPath, + url: nil, + packageID: nil, + versionFlags: VersionFlags( + exact: nil, revision: nil, branch: nil, + from: nil, upToNextMinorFrom: nil, to: nil + ) + ) + + let initializer = try configuration.makeInitializer() + + // Change to package directory + try tool.fileSystem.changeCurrentWorkingDirectory(to: packagePath) + try tool.fileSystem.createDirectory(packagePath, recursive: true) + + try await initializer.run() + } + + // Verify package was not created due to error + #expect(!tool.fileSystem.exists(packagePath.appending("Package.swift"))) + } + } + + @Test func standardPackageInitializerFallback() async throws { + try await testWithTemporaryDirectory { tmpPath in + let fs = localFileSystem + let path = tmpPath.appending("Foo") + try fs.createDirectory(path) + let options = try GlobalOptions.parse(["--package-path", path.pathString]) + let tool = try SwiftCommandState.makeMockState(options: options) + + // Test fallback to standard initializer when no template is specified + let configuration = try PackageInitConfiguration( + swiftCommandState: tool, + name: "TestPackage", + initMode: "executable", // Standard package type + testLibraryOptions: TestLibraryOptions.parse([]), + buildOptions: BuildCommandOptions.parse([]), + globalOptions: options, + validatePackage: false, + args: [], + directory: nil, + url: nil, + packageID: nil, + versionFlags: VersionFlags( + exact: nil, revision: nil, branch: nil, + from: nil, upToNextMinorFrom: nil, to: nil + ) + ) + + let initializer = try configuration.makeInitializer() + #expect(initializer is StandardPackageInitializer) + + // Change to package directory + try await initializer.run() + + // Verify standard package was created + #expect(tool.fileSystem.exists(path.appending("Package.swift"))) + #expect(try fs.getDirectoryContents(path.appending("Sources").appending("TestPackage")) == ["TestPackage.swift"]) + } + } + } } From de8917947a9bbdf3ef29bb473ad73015889b5cb0 Mon Sep 17 00:00:00 2001 From: John Bute Date: Mon, 22 Sep 2025 13:57:31 -0400 Subject: [PATCH 117/225] formatting --- Sources/Commands/PackageCommands/Init.swift | 130 +++---- .../PackageCommands/ShowTemplates.swift | 71 ++-- .../TestCommands/TestTemplateCommand.swift | 162 +++++---- .../PackageDependencyBuilder.swift | 16 +- ...ackageInitializationDirectoryManager.swift | 23 +- .../PackageInitializer.swift | 144 ++++---- .../RequirementResolver.swift | 61 ++-- .../_InternalInitSupport/TemplateBuild.swift | 4 +- .../TemplatePathResolver.swift | 95 ++--- .../TemplatePluginCoordinator.swift | 42 ++- .../TemplatePluginManager.swift | 58 ++-- .../TemplatePluginRunner.swift | 26 +- .../TemplateTestDirectoryManager.swift | 13 +- .../TemplateTesterManager.swift | 328 +++++++++++------- 14 files changed, 694 insertions(+), 479 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index f93d8c16a35..0df5fc260f6 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -18,16 +18,15 @@ import Basics import CoreCommands import PackageModel -import Workspace import SPMBuildCore import TSCUtility - +import Workspace extension SwiftPackageCommand { struct Init: AsyncSwiftCommand { - - public static let configuration = CommandConfiguration( - abstract: "Initialize a new package.") + static let configuration = CommandConfiguration( + abstract: "Initialize a new package." + ) @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions @@ -35,19 +34,20 @@ extension SwiftPackageCommand { @Option( name: .customLong("type"), help: ArgumentHelp("Specifies the package type or template.", discussion: """ - library - A package with a library. - executable - A package with an executable. - tool - A package with an executable that uses - Swift Argument Parser. Use this template if you - plan to have a rich set of command-line arguments. - build-tool-plugin - A package that vends a build tool plugin. - command-plugin - A package that vends a command plugin. - macro - A package that vends a macro. - empty - An empty package with a Package.swift manifest. - custom - When used with --path, --url, or --package-id, - this resolves to a template from the specified - package or location. - """)) + library - A package with a library. + executable - A package with an executable. + tool - A package with an executable that uses + Swift Argument Parser. Use this template if you + plan to have a rich set of command-line arguments. + build-tool-plugin - A package that vends a build tool plugin. + command-plugin - A package that vends a command plugin. + macro - A package that vends a macro. + empty - An empty package with a Package.swift manifest. + custom - When used with --path, --url, or --package-id, + this resolves to a template from the specified + package or location. + """) + ) var initMode: String? /// Which testing libraries to use (and any related options.) @@ -99,7 +99,10 @@ extension SwiftPackageCommand { var to: Version? /// Validation step to build package post generation and run if package is of type executable - @Flag(name: .customLong("validate-package"), help: "Run 'swift build' after package generation to validate the template.") + @Flag( + name: .customLong("validate-package"), + help: "Run 'swift build' after package generation to validate the template." + ) var validatePackage: Bool = false /// Predetermined arguments specified by the consumer. @@ -140,9 +143,7 @@ extension SwiftPackageCommand { try await initializer.run() } - init() { - } - + init() {} } } @@ -167,7 +168,6 @@ extension InitPackage.PackageType { } } - struct PackageInitConfiguration { let packageName: String let cwd: Basics.AbsolutePath @@ -220,7 +220,11 @@ struct PackageInitConfiguration { self.url = url self.packageID = packageID - let sourceResolver = DefaultTemplateSourceResolver(cwd: cwd, fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) + let sourceResolver = DefaultTemplateSourceResolver( + cwd: cwd, + fileSystem: swiftCommandState.fileSystem, + observabilityScope: swiftCommandState.observabilityScope + ) self.templateSource = sourceResolver.resolveSource( directory: directory, @@ -228,11 +232,15 @@ struct PackageInitConfiguration { packageID: packageID ) - - if templateSource != nil { - //we force wrap as we already do the the nil check. + if self.templateSource != nil { + // we force wrap as we already do the the nil check. do { - try sourceResolver.validate(templateSource: templateSource!, directory: self.directory, url: self.url, packageID: self.packageID) + try sourceResolver.validate( + templateSource: self.templateSource!, + directory: self.directory, + url: self.url, + packageID: self.packageID + ) } catch { swiftCommandState.observabilityScope.emit(error) } @@ -253,17 +261,17 @@ struct PackageInitConfiguration { } func makeInitializer() throws -> PackageInitializer { - if let templateSource = templateSource, - let versionResolver = versionResolver, - let buildOptions = buildOptions, - let globalOptions = globalOptions, - let validatePackage = validatePackage { - - return TemplatePackageInitializer( - packageName: packageName, - cwd: cwd, + if let templateSource, + let versionResolver, + let buildOptions, + let globalOptions, + let validatePackage + { + TemplatePackageInitializer( + packageName: self.packageName, + cwd: self.cwd, templateSource: templateSource, - templateName: initMode, + templateName: self.initMode, templateDirectory: self.directory, templateURL: self.url, templatePackageID: self.packageID, @@ -271,22 +279,21 @@ struct PackageInitConfiguration { buildOptions: buildOptions, globalOptions: globalOptions, validatePackage: validatePackage, - args: args, - swiftCommandState: swiftCommandState + args: self.args, + swiftCommandState: self.swiftCommandState ) } else { - return StandardPackageInitializer( - packageName: packageName, - initMode: initMode, - testLibraryOptions: testLibraryOptions, - cwd: cwd, - swiftCommandState: swiftCommandState + StandardPackageInitializer( + packageName: self.packageName, + initMode: self.initMode, + testLibraryOptions: self.testLibraryOptions, + cwd: self.cwd, + swiftCommandState: self.swiftCommandState ) } } } - public struct VersionFlags { let exact: Version? let revision: String? @@ -296,7 +303,6 @@ public struct VersionFlags { let to: Version? } - protocol TemplateSourceResolver { func resolveSource( directory: Basics.AbsolutePath?, @@ -313,7 +319,6 @@ protocol TemplateSourceResolver { } public struct DefaultTemplateSourceResolver: TemplateSourceResolver { - let cwd: AbsolutePath let fileSystem: FileSystem let observabilityScope: ObservabilityScope @@ -337,31 +342,33 @@ public struct DefaultTemplateSourceResolver: TemplateSourceResolver { ) throws { switch templateSource { case .git: - guard let url = url, isValidGitSource(url, fileSystem: fileSystem) else { + guard let url, isValidGitSource(url, fileSystem: fileSystem) else { throw SourceResolverError.invalidGitURL(url ?? "nil") } case .registry: - guard let packageID = packageID, isValidRegistryPackageIdentity(packageID) else { + guard let packageID, isValidRegistryPackageIdentity(packageID) else { throw SourceResolverError.invalidRegistryIdentity(packageID ?? "nil") } case .local: - guard let directory = directory else { + guard let directory else { throw SourceResolverError.missingLocalPath } - try isValidSwiftPackage(path: directory) + try self.isValidSwiftPackage(path: directory) } } private func isValidRegistryPackageIdentity(_ packageID: String) -> Bool { - return PackageIdentity.plain(packageID).isRegistry + PackageIdentity.plain(packageID).isRegistry } func isValidGitSource(_ input: String, fileSystem: FileSystem) -> Bool { - if input.hasPrefix("http://") || input.hasPrefix("https://") || input.hasPrefix("git@") || input.hasPrefix("ssh://") { - return true // likely a remote URL + if input.hasPrefix("http://") || input.hasPrefix("https://") || input.hasPrefix("git@") || input + .hasPrefix("ssh://") + { + return true // likely a remote URL } do { @@ -377,7 +384,7 @@ public struct DefaultTemplateSourceResolver: TemplateSourceResolver { } private func isValidSwiftPackage(path: AbsolutePath) throws { - if !fileSystem.exists(path) { + if !self.fileSystem.exists(path) { throw SourceResolverError.invalidDirectoryPath(path) } } @@ -391,16 +398,15 @@ public struct DefaultTemplateSourceResolver: TemplateSourceResolver { var description: String { switch self { case .invalidDirectoryPath(let path): - return "Invalid local path: \(path) does not exist or is not accessible." + "Invalid local path: \(path) does not exist or is not accessible." case .invalidGitURL(let url): - return "Invalid Git URL: \(url) is not a valid Git source." + "Invalid Git URL: \(url) is not a valid Git source." case .invalidRegistryIdentity(let id): - return "Invalid registry package identity: \(id) is not a valid registry package." + "Invalid registry package identity: \(id) is not a valid registry package." case .missingLocalPath: - return "Missing local path for template source." + "Missing local path for template source." } } - } } diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index f071cba17e3..5e34f7788ca 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -20,7 +20,6 @@ import TSCUtility import Workspace @_spi(PackageRefactor) import SwiftRefactor - /// A Swift command that lists the available executable templates from a package. /// /// The command can work with either a local package or a remote Git-based package template. @@ -75,21 +74,37 @@ struct ShowTemplates: AsyncSwiftCommand { var to: Version? func run(_ swiftCommandState: SwiftCommandState) async throws { - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") } // precheck() needed, extremely similar to the Init precheck, can refactor possibly - let source = try resolveSource(cwd: cwd, fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) + let source = try resolveSource( + cwd: cwd, + fileSystem: swiftCommandState.fileSystem, + observabilityScope: swiftCommandState.observabilityScope + ) let resolvedPath = try await resolveTemplatePath(using: swiftCommandState, source: source) let templates = try await loadTemplates(from: resolvedPath, swiftCommandState: swiftCommandState) try await displayTemplates(templates, at: resolvedPath, using: swiftCommandState) - try cleanupTemplate(source: source, path: resolvedPath, fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) + try cleanupTemplate( + source: source, + path: resolvedPath, + fileSystem: swiftCommandState.fileSystem, + observabilityScope: swiftCommandState.observabilityScope + ) } - private func resolveSource(cwd: AbsolutePath, fileSystem: FileSystem, observabilityScope: ObservabilityScope) throws -> InitTemplatePackage.TemplateSource { - guard let source = DefaultTemplateSourceResolver(cwd: cwd, fileSystem: fileSystem, observabilityScope: observabilityScope).resolveSource( + private func resolveSource( + cwd: AbsolutePath, + fileSystem: FileSystem, + observabilityScope: ObservabilityScope + ) throws -> InitTemplatePackage.TemplateSource { + guard let source = DefaultTemplateSourceResolver( + cwd: cwd, + fileSystem: fileSystem, + observabilityScope: observabilityScope + ).resolveSource( directory: cwd, url: self.templateURL, packageID: self.templatePackageID @@ -99,10 +114,10 @@ struct ShowTemplates: AsyncSwiftCommand { return source } - - - private func resolveTemplatePath(using swiftCommandState: SwiftCommandState, source: InitTemplatePackage.TemplateSource) async throws -> Basics.AbsolutePath { - + private func resolveTemplatePath( + using swiftCommandState: SwiftCommandState, + source: InitTemplatePackage.TemplateSource + ) async throws -> Basics.AbsolutePath { let requirementResolver = DependencyRequirementResolver( packageIdentity: templatePackageID, swiftCommandState: swiftCommandState, @@ -117,7 +132,6 @@ struct ShowTemplates: AsyncSwiftCommand { var sourceControlRequirement: SwiftRefactor.PackageDependency.SourceControl.Requirement? var registryRequirement: SwiftRefactor.PackageDependency.Registry.Requirement? - switch source { case .local: sourceControlRequirement = nil @@ -141,19 +155,24 @@ struct ShowTemplates: AsyncSwiftCommand { ).resolve() } - private func loadTemplates(from path: AbsolutePath, swiftCommandState: SwiftCommandState) async throws -> [Template] { + private func loadTemplates( + from path: AbsolutePath, + swiftCommandState: SwiftCommandState + ) async throws -> [Template] { let graph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: path) { _, _ in try await swiftCommandState.loadPackageGraph() } - let rootPackages = graph.rootPackages.map{ $0.identity } + let rootPackages = graph.rootPackages.map(\.identity) - return graph.allModules.filter({$0.underlying.template}).map { - Template(package: rootPackages.contains($0.packageIdentity) ? nil : $0.packageIdentity.description, name: $0.name) + return graph.allModules.filter(\.underlying.template).map { + Template( + package: rootPackages.contains($0.packageIdentity) ? nil : $0.packageIdentity.description, + name: $0.name + ) } } - private func getDescription(_ swiftCommandState: SwiftCommandState, template: String) async throws -> String { let workspace = try swiftCommandState.getActiveWorkspace() let root = try swiftCommandState.getWorkspaceRoot() @@ -170,7 +189,8 @@ struct ShowTemplates: AsyncSwiftCommand { if let target = targets.first(where: { $0.name == template }), let options = target.templateInitializationOptions, - case .packageInit(_, _, let description) = options { + case .packageInit(_, _, let description) = options + { return description } @@ -188,7 +208,7 @@ struct ShowTemplates: AsyncSwiftCommand { case .flatlist: for template in templates.sorted(by: { $0.name < $1.name }) { let description = try await swiftCommandState.withTemporaryWorkspace(switchingTo: path) { _, _ in - try await getDescription(swiftCommandState, template: template.name) + try await self.getDescription(swiftCommandState, template: template.name) } if let package = template.package { print("\(template.name) (\(package)) : \(description)") @@ -207,15 +227,16 @@ struct ShowTemplates: AsyncSwiftCommand { } } - private func cleanupTemplate(source: InitTemplatePackage.TemplateSource, path: AbsolutePath, fileSystem: FileSystem, observabilityScope: ObservabilityScope) throws { + private func cleanupTemplate( + source: InitTemplatePackage.TemplateSource, + path: AbsolutePath, + fileSystem: FileSystem, + observabilityScope: ObservabilityScope + ) throws { try TemplateInitializationDirectoryManager(fileSystem: fileSystem, observabilityScope: observabilityScope) .cleanupTemporary(templateSource: source, path: path, temporaryDirectory: nil) } - - - - /// Represents a discovered template. struct Template: Codable { /// Optional name of the external package, if the template comes from one. @@ -231,7 +252,7 @@ struct ShowTemplates: AsyncSwiftCommand { /// Output as a JSON array of template objects. case json - public init?(rawValue: String) { + init?(rawValue: String) { switch rawValue.lowercased() { case "flatlist": self = .flatlist @@ -242,7 +263,7 @@ struct ShowTemplates: AsyncSwiftCommand { } } - public var description: String { + var description: String { switch self { case .flatlist: "flatlist" case .json: "json" diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 34fc3f1ec53..188420e3404 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -30,8 +30,6 @@ import var TSCBasic.stdoutStream import class TSCBasic.SynchronizedQueue import class TSCBasic.Thread - - extension DispatchTimeInterval { var seconds: TimeInterval { switch self { @@ -45,7 +43,6 @@ extension DispatchTimeInterval { } } - extension SwiftTestCommand { struct Template: AsyncSwiftCommand { static let configuration = CommandConfiguration( @@ -66,7 +63,7 @@ extension SwiftTestCommand { help: "Specify the output path of the created templates.", completion: .directory ) - public var outputDirectory: AbsolutePath + var outputDirectory: AbsolutePath @OptionGroup(visibility: .hidden) var buildOptions: BuildCommandOptions @@ -78,12 +75,11 @@ extension SwiftTestCommand { var args: [String] = [] @Option( - name: .customLong("branches"), parsing: .upToNextOption, help: "Specify the branch of the template you want to test. Format: --branches branch1 branch2", ) - public var branches: [String] = [] + var branches: [String] = [] @Flag(help: "Dry-run to display argument tree") var dryRun: Bool = false @@ -94,18 +90,23 @@ extension SwiftTestCommand { @Option(help: "Set the output format.") var format: ShowTestTemplateOutput = .matrix - func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw ValidationError("Could not determine current working directory.") } - let directoryManager = TemplateTestingDirectoryManager(fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) - try directoryManager.createOutputDirectory(outputDirectoryPath: outputDirectory, swiftCommandState: swiftCommandState) + let directoryManager = TemplateTestingDirectoryManager( + fileSystem: swiftCommandState.fileSystem, + observabilityScope: swiftCommandState.observabilityScope + ) + try directoryManager.createOutputDirectory( + outputDirectoryPath: self.outputDirectory, + swiftCommandState: swiftCommandState + ) - let buildSystem = globalOptions.build.buildSystem != .native ? - globalOptions.build.buildSystem : - swiftCommandState.options.build.buildSystem + let buildSystem = self.globalOptions.build.buildSystem != .native ? + self.globalOptions.build.buildSystem : + swiftCommandState.options.build.buildSystem let pluginManager = try await TemplateTesterPluginManager( swiftCommandState: swiftCommandState, @@ -119,7 +120,7 @@ extension SwiftTestCommand { let commandPlugin = try pluginManager.loadTemplatePlugin() let commandLineFragments = try await pluginManager.run() - if dryRun { + if self.dryRun { for commandLine in commandLineFragments { print(commandLine.displayFormat()) } @@ -130,31 +131,45 @@ extension SwiftTestCommand { var buildMatrix: [String: BuildInfo] = [:] for commandLine in commandLineFragments { - let folderName = commandLine.fullPathKey - buildMatrix[folderName] = try await testDecisionTreeBranch(folderName: folderName, commandLine: commandLine.commandChain, swiftCommandState: swiftCommandState, packageType: packageType, commandPlugin: commandPlugin, cwd: cwd, buildSystem: buildSystem) - + buildMatrix[folderName] = try await self.testDecisionTreeBranch( + folderName: folderName, + commandLine: commandLine.commandChain, + swiftCommandState: swiftCommandState, + packageType: packageType, + commandPlugin: commandPlugin, + cwd: cwd, + buildSystem: buildSystem + ) } switch self.format { case .matrix: - printBuildMatrix(buildMatrix) + self.printBuildMatrix(buildMatrix) case .json: - printJSONMatrix(buildMatrix) + self.printJSONMatrix(buildMatrix) } } - private func testDecisionTreeBranch(folderName: String, commandLine: [CommandComponent], swiftCommandState: SwiftCommandState, packageType: InitPackage.PackageType, commandPlugin: ResolvedModule, cwd: AbsolutePath, buildSystem: BuildSystemProvider.Kind) async throws -> BuildInfo { - let destinationPath = outputDirectory.appending(component: folderName) + private func testDecisionTreeBranch( + folderName: String, + commandLine: [CommandComponent], + swiftCommandState: SwiftCommandState, + packageType: InitPackage.PackageType, + commandPlugin: ResolvedModule, + cwd: AbsolutePath, + buildSystem: BuildSystemProvider.Kind + ) async throws -> BuildInfo { + let destinationPath = self.outputDirectory.appending(component: folderName) swiftCommandState.observabilityScope.emit(debug: "Generating \(folderName)") try FileManager.default.createDirectory(at: destinationPath.asURL, withIntermediateDirectories: true) - return try await testTemplateInitialization( + return try await self.testTemplateInitialization( commandPlugin: commandPlugin, swiftCommandState: swiftCommandState, - buildOptions: buildOptions, + buildOptions: self.buildOptions, destinationAbsolutePath: destinationPath, testingFolderName: folderName, argumentPath: commandLine, @@ -171,7 +186,7 @@ extension SwiftTestCommand { "Gen Time(s)".padding(toLength: 12, withPad: " ", startingAt: 0), "Build Success".padding(toLength: 14, withPad: " ", startingAt: 0), "Build Time(s)".padding(toLength: 14, withPad: " ", startingAt: 0), - "Log File" + "Log File", ] print(header.joined(separator: " ")) @@ -179,10 +194,18 @@ extension SwiftTestCommand { let row = [ folder.padding(toLength: 30, withPad: " ", startingAt: 0), String(info.generationSuccess).padding(toLength: 12, withPad: " ", startingAt: 0), - String(format: "%.2f", info.generationDuration.seconds).padding(toLength: 12, withPad: " ", startingAt: 0), + String(format: "%.2f", info.generationDuration.seconds).padding( + toLength: 12, + withPad: " ", + startingAt: 0 + ), String(info.buildSuccess).padding(toLength: 14, withPad: " ", startingAt: 0), - String(format: "%.2f", info.buildDuration.seconds).padding(toLength: 14, withPad: " ", startingAt: 0), - info.logFilePath ?? "-" + String(format: "%.2f", info.buildDuration.seconds).padding( + toLength: 14, + withPad: " ", + startingAt: 0 + ), + info.logFilePath ?? "-", ] print(row.joined(separator: " ")) } @@ -199,10 +222,12 @@ extension SwiftTestCommand { } catch { print("Failed to encode JSON: \(error)") } - } - private func inferPackageType(swiftCommandState: SwiftCommandState, from templatePath: Basics.AbsolutePath) async throws -> InitPackage.PackageType { + private func inferPackageType( + swiftCommandState: SwiftCommandState, + from templatePath: Basics.AbsolutePath + ) async throws -> InitPackage.PackageType { let workspace = try swiftCommandState.getActiveWorkspace() let root = try swiftCommandState.getWorkspaceRoot() @@ -215,16 +240,17 @@ extension SwiftTestCommand { throw ValidationError("") } - var targetName = templateName + var targetName = self.templateName if targetName == nil { - targetName = try findTemplateName(from: manifest) + targetName = try self.findTemplateName(from: manifest) } for target in manifest.targets { if target.name == targetName, let options = target.templateInitializationOptions, - case .packageInit(let type, _, _) = options { + case .packageInit(let type, _, _) = options + { return try .init(from: type) } } @@ -232,11 +258,11 @@ extension SwiftTestCommand { throw ValidationError("") } - private func findTemplateName(from manifest: Manifest) throws -> String { let templateTargets = manifest.targets.compactMap { target -> String? in if let options = target.templateInitializationOptions, - case .packageInit = options { + case .packageInit = options + { return target.name } return nil @@ -252,7 +278,6 @@ extension SwiftTestCommand { } } - private func testTemplateInitialization( commandPlugin: ResolvedModule, swiftCommandState: SwiftCommandState, @@ -264,7 +289,6 @@ extension SwiftTestCommand { cwd: AbsolutePath, buildSystem: BuildSystemProvider.Kind ) async throws -> BuildInfo { - let startGen = DispatchTime.now() var genSuccess = false var buildSuccess = false @@ -290,19 +314,23 @@ extension SwiftTestCommand { try initTemplate.setupTemplateManifest() - let graph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in - try await swiftCommandState.loadPackageGraph() - } + let graph = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in + try await swiftCommandState.loadPackageGraph() + } - try await TemplateBuildSupport.buildForTesting(swiftCommandState: swiftCommandState, buildOptions: buildOptions, testingFolder: destinationAbsolutePath) + try await TemplateBuildSupport.buildForTesting( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + testingFolder: destinationAbsolutePath + ) // Build flat command with all subcommands and arguments - let flatCommand = buildFlatCommand(from: argumentPath) + let flatCommand = self.buildFlatCommand(from: argumentPath) print("Running plugin with args:", flatCommand) try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in - let output = try await TemplatePluginExecutor.execute( plugin: commandPlugin, rootPackage: graph.rootPackages.first!, @@ -328,7 +356,7 @@ extension SwiftTestCommand { genSuccess = false let errorLog = destinationAbsolutePath.appending("generation-output.log") - logPath = try? captureAndWriteError( + logPath = try? self.captureAndWriteError( to: errorLog, error: error, context: "Plugin Output (before failure)" @@ -357,7 +385,7 @@ extension SwiftTestCommand { buildSuccess = false let errorLog = destinationAbsolutePath.appending("build-output.log") - logPath = try? captureAndWriteError( + logPath = try? self.captureAndWriteError( to: errorLog, error: error, context: "Build Output (before failure)" @@ -381,7 +409,7 @@ extension SwiftTestCommand { if index > 0 { result.append(command.commandName) } - let commandArgs = command.arguments.flatMap { $0.commandLineFragments } + let commandArgs = command.arguments.flatMap(\.commandLineFragments) result.append(contentsOf: commandArgs) } @@ -391,22 +419,26 @@ extension SwiftTestCommand { private func captureAndWriteError(to path: AbsolutePath, error: Error, context: String) throws -> String { let existingOutput = (try? String(contentsOf: path.asURL)) ?? "" let logContent = - """ - Error: - -------------------------------- - \(error.localizedDescription) - - \(context): - -------------------------------- - \(existingOutput) - """ + """ + Error: + -------------------------------- + \(error.localizedDescription) + + \(context): + -------------------------------- + \(existingOutput) + """ try logContent.write(to: path.asURL, atomically: true, encoding: .utf8) return path.pathString } private func redirectStdoutAndStderr(to path: String) throws -> (originalStdout: Int32, originalStderr: Int32) { guard let file = fopen(path, "w") else { - throw NSError(domain: "RedirectError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot open file for writing"]) + throw NSError( + domain: "RedirectError", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Cannot open file for writing"] + ) } let originalStdout = dup(STDOUT_FILENO) @@ -427,14 +459,15 @@ extension SwiftTestCommand { close(originalStderr) } - enum ShowTestTemplateOutput: String, RawRepresentable, CustomStringConvertible, ExpressibleByArgument, CaseIterable { + enum ShowTestTemplateOutput: String, RawRepresentable, CustomStringConvertible, ExpressibleByArgument, + CaseIterable + { case matrix case json - public var description: String { rawValue } + var description: String { rawValue } } - struct BuildInfo: Encodable { var generationDuration: DispatchTimeInterval var buildDuration: DispatchTimeInterval @@ -448,19 +481,18 @@ extension SwiftTestCommand { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(generationDuration.seconds, forKey: .generationDuration) - try container.encode(buildDuration.seconds, forKey: .buildDuration) - try container.encode(generationSuccess, forKey: .generationSuccess) - try container.encode(buildSuccess, forKey: .buildSuccess) - try container.encodeIfPresent(logFilePath, forKey: .logFilePath) + try container.encode(self.generationDuration.seconds, forKey: .generationDuration) + try container.encode(self.buildDuration.seconds, forKey: .buildDuration) + try container.encode(self.generationSuccess, forKey: .generationSuccess) + try container.encode(self.buildSuccess, forKey: .buildSuccess) + try container.encodeIfPresent(self.logFilePath, forKey: .logFilePath) } } } } -private extension String { - func padded(_ toLength: Int) -> String { +extension String { + private func padded(_ toLength: Int) -> String { self.padding(toLength: toLength, withPad: " ", startingAt: 0) } } - diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift index 6f0c4a9063d..e410eb8bc59 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift @@ -17,7 +17,6 @@ import TSCUtility import Workspace @_spi(PackageRefactor) import SwiftRefactor - /// A protocol for building `MappablePackageDependency.Kind` instances from provided dependency information. /// /// Conforming types are responsible for converting high-level dependency configuration @@ -55,12 +54,10 @@ struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { /// The registry package identifier, if the template source is registry-based. let templatePackageID: String? - let sourceControlRequirement: PackageDependency.SourceControl.Requirement? let registryRequirement: PackageDependency.Registry.Requirement? let resolvedTemplatePath: Basics.AbsolutePath - /// Constructs a package dependency kind based on the selected template source. /// /// - Parameters: @@ -74,7 +71,8 @@ struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { func makePackageDependency() throws -> PackageDependency { switch self.templateSource { case .local: - return .fileSystem(.init(path: resolvedTemplatePath.asURL.path)) + return .fileSystem(.init(path: self.resolvedTemplatePath.asURL.path)) + case .git: guard let url = templateURL else { throw PackageDependencyBuilderError.missingGitURLOrPath @@ -95,7 +93,6 @@ struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { } } - /// Errors thrown by `TemplatePathResolver` during initialization. enum PackageDependencyBuilderError: LocalizedError, Equatable { case missingGitURLOrPath @@ -106,15 +103,14 @@ struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { var errorDescription: String? { switch self { case .missingGitURLOrPath: - return "Missing Git URL or path for template from git." + "Missing Git URL or path for template from git." case .missingGitRequirement: - return "Missing version requirement for template from git." + "Missing version requirement for template from git." case .missingRegistryIdentity: - return "Missing registry package identity for template from registry." + "Missing registry package identity for template from registry." case .missingRegistryRequirement: - return "Missing version requirement for template from registry ." + "Missing version requirement for template from registry ." } } } - } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift index f70af672e9c..8b4bd93ef15 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift @@ -1,8 +1,7 @@ import Basics -import Workspace -import Foundation import CoreCommands - +import Foundation +import Workspace import Basics import CoreCommands @@ -20,7 +19,9 @@ public struct TemplateInitializationDirectoryManager { self.observabilityScope = observabilityScope } - public func createTemporaryDirectories() throws -> (stagingPath: Basics.AbsolutePath, cleanupPath: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) { + public func createTemporaryDirectories() throws + -> (stagingPath: Basics.AbsolutePath, cleanupPath: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) + { let tempDir = try helper.createTemporaryDirectory() let dirs = try helper.createSubdirectories(in: tempDir, names: ["generated-package", "clean-up"]) @@ -33,9 +34,9 @@ public struct TemplateInitializationDirectoryManager { cleanupPath: Basics.AbsolutePath, swiftCommandState: SwiftCommandState ) async throws { - try helper.copyDirectoryContents(from: stagingPath, to: cleanupPath) - try await cleanBuildArtifacts(at: cleanupPath, swiftCommandState: swiftCommandState) - try helper.copyDirectoryContents(from: cleanupPath, to: cwd) + try self.helper.copyDirectoryContents(from: stagingPath, to: cleanupPath) + try await self.cleanBuildArtifacts(at: cleanupPath, swiftCommandState: swiftCommandState) + try self.helper.copyDirectoryContents(from: cleanupPath, to: cwd) } func cleanBuildArtifacts(at path: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) async throws { @@ -44,7 +45,11 @@ public struct TemplateInitializationDirectoryManager { } } - public func cleanupTemporary(templateSource: InitTemplatePackage.TemplateSource, path: Basics.AbsolutePath, temporaryDirectory: Basics.AbsolutePath?) throws { + public func cleanupTemporary( + templateSource: InitTemplatePackage.TemplateSource, + path: Basics.AbsolutePath, + temporaryDirectory: Basics.AbsolutePath? + ) throws { do { switch templateSource { case .git, .registry: @@ -56,7 +61,7 @@ public struct TemplateInitializationDirectoryManager { } if let tempDir = temporaryDirectory { - try helper.removeDirectoryIfExists(tempDir) + try self.helper.removeDirectoryIfExists(tempDir) } } catch { diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index 6e29846ac77..e9bbbaab055 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -6,15 +6,16 @@ import Basics @_spi(SwiftPMInternal) import CoreCommands -@_spi(PackageRefactor) import SwiftRefactor -import Workspace +import Foundation +import PackageGraph import SPMBuildCore +@_spi(PackageRefactor) import SwiftRefactor import TSCBasic import TSCUtility -import Foundation -import PackageGraph +import Workspace import class PackageModel.Manifest + protocol PackageInitializer { func run() async throws } @@ -39,18 +40,19 @@ struct TemplatePackageInitializer: PackageInitializer { var sourceControlRequirement: PackageDependency.SourceControl.Requirement? var registryRequirement: PackageDependency.Registry.Requirement? - swiftCommandState.observabilityScope.emit(debug: "Fetching versioning requirements and resolving path of template on local disk.") + self.swiftCommandState.observabilityScope + .emit(debug: "Fetching versioning requirements and resolving path of template on local disk.") - switch templateSource { + switch self.templateSource { case .local: sourceControlRequirement = nil registryRequirement = nil case .git: - sourceControlRequirement = try? versionResolver.resolveSourceControl() + sourceControlRequirement = try? self.versionResolver.resolveSourceControl() registryRequirement = nil case .registry: sourceControlRequirement = nil - registryRequirement = try? await versionResolver.resolveRegistry() + registryRequirement = try? await self.versionResolver.resolveRegistry() } // Resolve version requirements @@ -64,12 +66,20 @@ struct TemplatePackageInitializer: PackageInitializer { swiftCommandState: swiftCommandState ).resolve() - let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) + let directoryManager = TemplateInitializationDirectoryManager( + fileSystem: swiftCommandState.fileSystem, + observabilityScope: self.swiftCommandState.observabilityScope + ) let (stagingPath, cleanupPath, tempDir) = try directoryManager.createTemporaryDirectories() - swiftCommandState.observabilityScope.emit(debug: "Inferring initial type of consumer's package based on template's specifications.") + self.swiftCommandState.observabilityScope + .emit(debug: "Inferring initial type of consumer's package based on template's specifications.") - let packageType = try await TemplatePackageInitializer.inferPackageType(from: resolvedTemplatePath, templateName: templateName, swiftCommandState: swiftCommandState) + let packageType = try await TemplatePackageInitializer.inferPackageType( + from: resolvedTemplatePath, + templateName: self.templateName, + swiftCommandState: self.swiftCommandState + ) let builder = DefaultPackageDependencyBuilder( templateSource: templateSource, @@ -83,52 +93,67 @@ struct TemplatePackageInitializer: PackageInitializer { let templatePackage = try setUpPackage(builder: builder, packageType: packageType, stagingPath: stagingPath) - swiftCommandState.observabilityScope.emit(debug: "Finished setting up initial package: \(templatePackage.packageName).") + self.swiftCommandState.observabilityScope + .emit(debug: "Finished setting up initial package: \(templatePackage.packageName).") - swiftCommandState.observabilityScope.emit(debug: "Building package with dependency on template.") + self.swiftCommandState.observabilityScope.emit(debug: "Building package with dependency on template.") try await TemplateBuildSupport.build( - swiftCommandState: swiftCommandState, - buildOptions: buildOptions, - globalOptions: globalOptions, + swiftCommandState: self.swiftCommandState, + buildOptions: self.buildOptions, + globalOptions: self.globalOptions, cwd: stagingPath, transitiveFolder: stagingPath ) - swiftCommandState.observabilityScope.emit(debug: "Running plugin steps, including prompting and running the template package's plugin.") + self.swiftCommandState.observabilityScope + .emit(debug: "Running plugin steps, including prompting and running the template package's plugin.") - let buildSystem = globalOptions.build.buildSystem != .native ? - globalOptions.build.buildSystem : - swiftCommandState.options.build.buildSystem + let buildSystem = self.globalOptions.build.buildSystem != .native ? + self.globalOptions.build.buildSystem : + self.swiftCommandState.options.build.buildSystem try await TemplateInitializationPluginManager( - swiftCommandState: swiftCommandState, - template: templateName, + swiftCommandState: self.swiftCommandState, + template: self.templateName, scratchDirectory: stagingPath, - args: args, + args: self.args, buildSystem: buildSystem ).run() - try await directoryManager.finalize(cwd: cwd, stagingPath: stagingPath, cleanupPath: cleanupPath, swiftCommandState: swiftCommandState) + try await directoryManager.finalize( + cwd: self.cwd, + stagingPath: stagingPath, + cleanupPath: cleanupPath, + swiftCommandState: self.swiftCommandState + ) - if validatePackage { + if self.validatePackage { try await TemplateBuildSupport.build( - swiftCommandState: swiftCommandState, - buildOptions: buildOptions, - globalOptions: globalOptions, - cwd: cwd + swiftCommandState: self.swiftCommandState, + buildOptions: self.buildOptions, + globalOptions: self.globalOptions, + cwd: self.cwd ) } - try directoryManager.cleanupTemporary(templateSource: templateSource, path: resolvedTemplatePath, temporaryDirectory: tempDir) + try directoryManager.cleanupTemporary( + templateSource: self.templateSource, + path: resolvedTemplatePath, + temporaryDirectory: tempDir + ) } catch { - swiftCommandState.observabilityScope.emit(error) + self.swiftCommandState.observabilityScope.emit(error) } } - //Will have to add checking for git + registry too - static func inferPackageType(from templatePath: Basics.AbsolutePath, templateName: String?, swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType { + // Will have to add checking for git + registry too + static func inferPackageType( + from templatePath: Basics.AbsolutePath, + templateName: String?, + swiftCommandState: SwiftCommandState + ) async throws -> InitPackage.PackageType { try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { workspace, root in let rootManifests = try await workspace.loadRootManifests( packages: root.packages, @@ -142,13 +167,14 @@ struct TemplatePackageInitializer: PackageInitializer { var targetName = templateName if targetName == nil { - targetName = try findTemplateName(from: manifest) + targetName = try self.findTemplateName(from: manifest) } for target in manifest.targets { if target.name == targetName, - let options = target.templateInitializationOptions, - case .packageInit(let type, _, _) = options { + let options = target.templateInitializationOptions, + case .packageInit(let type, _, _) = options + { return try .init(from: type) } } @@ -159,7 +185,8 @@ struct TemplatePackageInitializer: PackageInitializer { static func findTemplateName(from manifest: Manifest) throws -> String { let templateTargets = manifest.targets.compactMap { target -> String? in if let options = target.templateInitializationOptions, - case .packageInit = options { + case .packageInit = options + { return target.name } return nil @@ -175,7 +202,6 @@ struct TemplatePackageInitializer: PackageInitializer { } } - private func setUpPackage( builder: DefaultPackageDependencyBuilder, packageType: InitPackage.PackageType, @@ -183,12 +209,12 @@ struct TemplatePackageInitializer: PackageInitializer { ) throws -> InitTemplatePackage { let templatePackage = try InitTemplatePackage( name: packageName, - initMode: try builder.makePackageDependency(), - fileSystem: swiftCommandState.fileSystem, + initMode: builder.makePackageDependency(), + fileSystem: self.swiftCommandState.fileSystem, packageType: packageType, supportedTestingLibraries: [], destinationPath: stagingPath, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration + installedSwiftPMConfiguration: self.swiftCommandState.getHostToolchain().installedSwiftPMConfiguration ) try templatePackage.setupTemplateManifest() @@ -204,22 +230,18 @@ struct TemplatePackageInitializer: PackageInitializer { var description: String { switch self { case .invalidManifestInTemplate(let path): - return "Invalid manifest found in template at \(path)." + "Invalid manifest found in template at \(path)." case .templateNotFound(let templateName): - return "Could not find template \(templateName)." + "Could not find template \(templateName)." case .noTemplatesInManifest: - return "No templates with packageInit options were found in the manifest." + "No templates with packageInit options were found in the manifest." case .multipleTemplatesFound(let templates): - return "Multiple templates found: \(templates.joined(separator: ", ")). Please specify one using --template." - + "Multiple templates found: \(templates.joined(separator: ", ")). Please specify one using --template." } } } - } - - struct StandardPackageInitializer: PackageInitializer { let packageName: String let initMode: String? @@ -228,7 +250,6 @@ struct StandardPackageInitializer: PackageInitializer { let swiftCommandState: SwiftCommandState func run() async throws { - guard let initModeString = self.initMode else { throw StandardPackageInitializerError.missingInitMode } @@ -237,12 +258,20 @@ struct StandardPackageInitializer: PackageInitializer { } // Configure testing libraries var supportedTestingLibraries = Set() - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (knownType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + if self.testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: self.swiftCommandState) || + (knownType == .macro && self.testLibraryOptions.isEnabled( + .xctest, + swiftCommandState: self.swiftCommandState + )) + { supportedTestingLibraries.insert(.xctest) } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (knownType != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + if self.testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: self.swiftCommandState) || + (knownType != .macro && self.testLibraryOptions.isEnabled( + .swiftTesting, + swiftCommandState: self.swiftCommandState + )) + { supportedTestingLibraries.insert(.swiftTesting) } @@ -252,7 +281,7 @@ struct StandardPackageInitializer: PackageInitializer { supportedTestingLibraries: supportedTestingLibraries, destinationPath: cwd, installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, - fileSystem: swiftCommandState.fileSystem + fileSystem: self.swiftCommandState.fileSystem ) initPackage.progressReporter = { message in print(message) } try initPackage.writePackageStructure() @@ -265,11 +294,10 @@ struct StandardPackageInitializer: PackageInitializer { var description: String { switch self { case .missingInitMode: - return "Specify a package type using the --type option." + "Specify a package type using the --type option." case .unsupportedPackageType(let type): - return "Package type '\(type)' is not supported." + "Package type '\(type)' is not supported." } } } } - diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index c1f0ecf3b71..c7560de0638 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -10,14 +10,14 @@ // //===----------------------------------------------------------------------===// -@_spi(PackageRefactor) import SwiftRefactor +import CoreCommands +import PackageFingerprint import PackageRegistry +import PackageSigning +@_spi(PackageRefactor) import SwiftRefactor import TSCBasic import TSCUtility -import CoreCommands import Workspace -import PackageFingerprint -import PackageSigning import class PackageModel.Manifest import struct PackageModel.PackageIdentity @@ -29,7 +29,6 @@ protocol DependencyRequirementResolving { func resolveRegistry() async throws -> SwiftRefactor.PackageDependency.Registry.Requirement? } - /// A utility for resolving a single, well-formed package dependency requirement /// from mutually exclusive versioning inputs, such as: /// - `exact`: A specific version (e.g., 1.2.3) @@ -70,7 +69,6 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// - Throws: `StringError` if multiple or no input fields are set, or if `to` is used without `from` or /// `upToNextMinorFrom`. func resolveSourceControl() throws -> SwiftRefactor.PackageDependency.SourceControl.Requirement { - var specifiedRequirements: [SwiftRefactor.PackageDependency.SourceControl.Requirement] = [] if let exact { @@ -107,7 +105,6 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { throw DependencyRequirementError.multipleRequirementsSpecified } - let requirement: PackageDependency.SourceControl.Requirement switch firstRequirement { case .range(let lowerBound, _), .rangeFrom(let lowerBound): @@ -121,9 +118,7 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { if self.to != nil { throw DependencyRequirementError.invalidToParameterWithoutFrom - } - } return requirement @@ -135,7 +130,7 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// - Throws: `StringError` if more than one registry versioning input is provided or if `to` is used without a base /// range. func resolveRegistry() async throws -> SwiftRefactor.PackageDependency.Registry.Requirement? { - if exact == nil, from == nil, upToNextMinorFrom == nil, to == nil { + if exact == nil, from == nil, upToNextMinorFrom == nil, self.to == nil { let config = try RegistryTemplateFetcher.getRegistriesConfig(self.swiftCommandState, global: true) let auth = try swiftCommandState.getRegistryAuthorizationProvider() @@ -156,7 +151,7 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { ) let resolvedVersion = try await resolveVersion(for: identity, using: registryClient) - return (.exact(resolvedVersion.description)) + return .exact(resolvedVersion.description) } var specifiedRequirements: [SwiftRefactor.PackageDependency.Registry.Requirement] = [] @@ -187,7 +182,6 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { throw DependencyRequirementError.multipleRequirementsSpecified } - let requirement: SwiftRefactor.PackageDependency.Registry.Requirement switch firstRequirement { case .range(let lowerBound, _), .rangeFrom(let lowerBound): @@ -206,7 +200,7 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { return requirement } - + /// Resolves the version to use for registry packages, fetching latest if none specified /// /// - Parameters: @@ -214,13 +208,22 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// - registryClient: The registry client to use for fetching metadata /// - Returns: The resolved version to use /// - Throws: Error if version resolution fails - func resolveVersion(for packageIdentity: PackageIdentity, using registryClient: RegistryClient) async throws -> Version { - let metadata = try await registryClient.getPackageMetadata(package: packageIdentity, observabilityScope: swiftCommandState.observabilityScope) - + func resolveVersion( + for packageIdentity: PackageIdentity, + using registryClient: RegistryClient + ) async throws -> Version { + let metadata = try await registryClient.getPackageMetadata( + package: packageIdentity, + observabilityScope: self.swiftCommandState.observabilityScope + ) + guard let maxVersion = metadata.versions.max() else { - throw DependencyRequirementError.failedToFetchLatestVersion(metadata: metadata, packageIdentity: packageIdentity) + throw DependencyRequirementError.failedToFetchLatestVersion( + metadata: metadata, + packageIdentity: packageIdentity + ) } - + return maxVersion } } @@ -233,7 +236,7 @@ enum DependencyType { case registry } -enum DependencyRequirementError: Error, CustomStringConvertible, Equatable{ +enum DependencyRequirementError: Error, CustomStringConvertible, Equatable { case multipleRequirementsSpecified case noRequirementSpecified case invalidToParameterWithoutFrom @@ -242,23 +245,21 @@ enum DependencyRequirementError: Error, CustomStringConvertible, Equatable{ var description: String { switch self { case .multipleRequirementsSpecified: - return "Specify exactly version requirement." + "Specify exactly version requirement." case .noRequirementSpecified: - return "No exact or lower bound version requirement specified." + "No exact or lower bound version requirement specified." case .invalidToParameterWithoutFrom: - return "--to requires --from or --up-to-next-minor-from" + "--to requires --from or --up-to-next-minor-from" case .failedToFetchLatestVersion(let metadata, let packageIdentity): - return """ - Failed to fetch latest version of \(packageIdentity) - Here is the metadata of the package you were trying to query: - \(metadata) - """ + """ + Failed to fetch latest version of \(packageIdentity) + Here is the metadata of the package you were trying to query: + \(metadata) + """ } } - public static func == (_ lhs: Self, _ rhs: Self) -> Bool { + static func == (_ lhs: Self, _ rhs: Self) -> Bool { lhs.description == rhs.description } - } - diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift index 8cdc17c4a6d..12d462b49d6 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -24,11 +24,11 @@ import TSCUtility /// command configuration and workspace context. enum TemplateBuildSupport { - /// Builds a Swift package using the given command state, options, and working directory. /// /// - Parameters: - /// - swiftCommandState: The current Swift command state, containing context such as the workspace and diagnostics. + /// - swiftCommandState: The current Swift command state, containing context such as the workspace and + /// diagnostics. /// - buildOptions: Options used to configure what and how to build, including the product and traits. /// - globalOptions: Global configuration such as the package directory and logging verbosity. /// - cwd: The current working directory to use if no package directory is explicitly provided. diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index ace59bcfac2..6a9e34f0a5d 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -10,20 +10,20 @@ // //===----------------------------------------------------------------------===// -//TODO: needs review +// TODO: needs review import ArgumentParser import Basics import CoreCommands import Foundation import PackageFingerprint -@_spi(PackageRefactor) import SwiftRefactor +import struct PackageModel.PackageIdentity import PackageRegistry import PackageSigning import SourceControl +@_spi(PackageRefactor) import SwiftRefactor import TSCBasic import TSCUtility import Workspace -import struct PackageModel.PackageIdentity /// A protocol representing a generic package template fetcher. /// @@ -83,7 +83,11 @@ struct TemplatePathResolver { guard let url = templateURL, let requirement = sourceControlRequirement else { throw TemplatePathResolverError.missingGitURLOrRequirement } - self.fetcher = GitTemplateFetcher(source: url, requirement: requirement, swiftCommandState: swiftCommandState) + self.fetcher = GitTemplateFetcher( + source: url, + requirement: requirement, + swiftCommandState: swiftCommandState + ) case .registry: guard let identity = packageIdentity, let requirement = registryRequirement else { @@ -118,13 +122,13 @@ struct TemplatePathResolver { var errorDescription: String? { switch self { case .missingLocalTemplatePath: - return "Template path must be specified for local templates." + "Template path must be specified for local templates." case .missingGitURLOrRequirement: - return "Missing Git URL or requirement for git template." + "Missing Git URL or requirement for git template." case .missingRegistryIdentityOrRequirement: - return "Missing registry package identity or requirement." + "Missing registry package identity or requirement." case .missingTemplateType: - return "Missing --template-type." + "Missing --template-type." } } } @@ -152,7 +156,6 @@ struct LocalTemplateFetcher: TemplateFetcher { /// The template is cloned into a temporary directory, checked out, and returned. struct GitTemplateFetcher: TemplateFetcher { - /// The Git URL of the remote repository. let source: String @@ -171,9 +174,8 @@ struct GitTemplateFetcher: TemplateFetcher { let bareCopyPath = tempDir.appending(component: "bare-copy") let workingCopyPath = tempDir.appending(component: "working-copy") - - try await cloneBareRepository(into: bareCopyPath) - try validateBareRepository(at: bareCopyPath) + try await self.cloneBareRepository(into: bareCopyPath) + try self.validateBareRepository(at: bareCopyPath) try FileManager.default.createDirectory( atPath: workingCopyPath.pathString, @@ -183,7 +185,7 @@ struct GitTemplateFetcher: TemplateFetcher { let repository = try createWorkingCopy(fromBare: bareCopyPath, at: workingCopyPath) try FileManager.default.removeItem(at: bareCopyPath.asURL) - try checkout(repository: repository) + try self.checkout(repository: repository) return workingCopyPath } @@ -199,18 +201,18 @@ struct GitTemplateFetcher: TemplateFetcher { do { try await provider.fetch(repository: repositorySpecifier, to: path) } catch { - if isSSHPermissionError(error) { - throw GitTemplateFetcherError.sshAuthenticationRequired(source: source) + if self.isSSHPermissionError(error) { + throw GitTemplateFetcherError.sshAuthenticationRequired(source: self.source) } - throw GitTemplateFetcherError.cloneFailed(source: source) + throw GitTemplateFetcherError.cloneFailed(source: self.source) } } private func isSSHPermissionError(_ error: Error) -> Bool { let errorString = String(describing: error).lowercased() return errorString.contains("permission denied") && - errorString.contains("publickey") && - source.hasPrefix("git@") + errorString.contains("publickey") && + self.source.hasPrefix("git@") } /// Validates that the directory contains a valid Git repository. @@ -224,7 +226,10 @@ struct GitTemplateFetcher: TemplateFetcher { /// Creates a working copy from a bare directory. /// /// - Throws: An error. - private func createWorkingCopy(fromBare barePath: Basics.AbsolutePath, at workingCopyPath: Basics.AbsolutePath) throws -> WorkingCheckout { + private func createWorkingCopy( + fromBare barePath: Basics.AbsolutePath, + at workingCopyPath: Basics.AbsolutePath + ) throws -> WorkingCheckout { let url = SourceControlURL(source) let repositorySpecifier = RepositorySpecifier(url: url) let provider = GitRepositoryProvider() @@ -240,7 +245,6 @@ struct GitTemplateFetcher: TemplateFetcher { } } - /// Checks out the desired state (branch, tag, revision) in the working copy based on the requirement. /// /// - Throws: An error if no matching version is found in a version range, or if checkout fails. @@ -258,27 +262,31 @@ struct GitTemplateFetcher: TemplateFetcher { case .range(let lowerBound, let upperBound): let tags = try repository.getTags() let versions = tags.compactMap { Version($0) } - + guard let lowerVersion = Version(lowerBound), - let upperVersion = Version(upperBound) else { + let upperVersion = Version(upperBound) + else { throw GitTemplateFetcherError.invalidVersionRange(lowerBound: lowerBound, upperBound: upperBound) } - - let versionRange = lowerVersion..= lowerVersion } guard let latestVersion = filteredVersions.max() else { throw GitTemplateFetcherError.noMatchingTagFromVersion(versionString) @@ -301,27 +309,27 @@ struct GitTemplateFetcher: TemplateFetcher { var errorDescription: String? { switch self { case .cloneFailed(let source): - return "Failed to clone repository from '\(source)'" + "Failed to clone repository from '\(source)'" case .invalidRepositoryDirectory(let path): - return "Invalid Git repository at path: \(path.pathString)" + "Invalid Git repository at path: \(path.pathString)" case .createWorkingCopyFailed(let path, let error): - return "Failed to create working copy at '\(path)': \(error.localizedDescription)" + "Failed to create working copy at '\(path)': \(error.localizedDescription)" case .checkoutFailed(let requirement, let error): - return "Failed to checkout using requirement '\(requirement)': \(error.localizedDescription)" + "Failed to checkout using requirement '\(requirement)': \(error.localizedDescription)" case .noMatchingTagInVersionRange(let lowerBound, let upperBound): - return "No Git tags found within version range \(lowerBound)..<\(upperBound)" + "No Git tags found within version range \(lowerBound)..<\(upperBound)" case .noMatchingTagFromVersion(let version): - return "No Git tags found from version \(version) or later" + "No Git tags found from version \(version) or later" case .invalidVersionRange(let lowerBound, let upperBound): - return "Invalid version range: \(lowerBound)..<\(upperBound)" + "Invalid version range: \(lowerBound)..<\(upperBound)" case .invalidVersion(let version): - return "Invalid version string: \(version)" + "Invalid version string: \(version)" case .sshAuthenticationRequired(let source): - return "SSH authentication required for '\(source)'.\nEnsure SSH agent is running and key is loaded:\n\nhttps://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent" + "SSH authentication required for '\(source)'.\nEnsure SSH agent is running and key is loaded:\n\nhttps://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent" } } - public static func == (lhs: GitTemplateFetcherError, rhs: GitTemplateFetcherError) -> Bool { + static func == (lhs: GitTemplateFetcherError, rhs: GitTemplateFetcherError) -> Bool { lhs.errorDescription == rhs.errorDescription } } @@ -386,7 +394,7 @@ struct RegistryTemplateFetcher: TemplateFetcher { /// Extract the version from the registry requirements private var version: Version { - switch requirement { + switch self.requirement { case .exact(let versionString): guard let version = Version(versionString) else { fatalError("Invalid version string: \(versionString)") @@ -405,13 +413,13 @@ struct RegistryTemplateFetcher: TemplateFetcher { } } - /// Resolves the registry configuration from shared SwiftPM configuration. /// /// - Returns: Registry configuration to use for fetching packages. /// - Throws: If configurations are missing or unreadable. - public static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace - .Configuration.Registries { + static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace + .Configuration.Registries + { let sharedFile = Workspace.DefaultLocations .registriesConfigurationFile(at: swiftCommandState.sharedConfigurationDirectory) do { @@ -433,12 +441,11 @@ struct RegistryTemplateFetcher: TemplateFetcher { var errorDescription: String? { switch self { case .failedToLoadConfiguration(let file, let underlyingError): - return """ + """ Failed to load registry configuration from '\(file.pathString)': \ \(underlyingError.localizedDescription) """ } } } - } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift index 6a72cc1d017..fef9661e4d2 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift @@ -7,14 +7,13 @@ import Basics @_spi(SwiftPMInternal) import CoreCommands +import Foundation +import PackageGraph import PackageModel -import Workspace import SPMBuildCore import TSCBasic import TSCUtility -import Foundation -import PackageGraph - +import Workspace struct TemplatePluginCoordinator { let buildSystem: BuildSystemProvider.Kind @@ -27,16 +26,18 @@ struct TemplatePluginCoordinator { private let EXPERIMENTAL_DUMP_HELP = ["--", "--experimental-dump-help"] func loadPackageGraph() async throws -> ModulesGraph { - try await swiftCommandState.withTemporaryWorkspace(switchingTo: scratchDirectory) { _, _ in - try await swiftCommandState.loadPackageGraph() + try await self.swiftCommandState.withTemporaryWorkspace(switchingTo: self.scratchDirectory) { _, _ in + try await self.swiftCommandState.loadPackageGraph() } } /// Loads the plugin that corresponds to the template's name. /// /// - Throws: - /// - `PluginError.noMatchingTemplate(name: String?)` if there are no plugins corresponding to the desired template. - /// - `PluginError.multipleMatchingTemplates(names: [String]` if the search returns more than one plugin given a desired template + /// - `PluginError.noMatchingTemplate(name: String?)` if there are no plugins corresponding to the desired + /// template. + /// - `PluginError.multipleMatchingTemplates(names: [String]` if the search returns more than one plugin given a + /// desired template /// /// - Returns: A data representation of the result of the execution of the template's plugin. func loadTemplatePlugin(from packageGraph: ModulesGraph) throws -> ResolvedModule { @@ -57,16 +58,21 @@ struct TemplatePluginCoordinator { /// Manages the logic of dumping the JSON representation of a template's decision tree. /// /// - Throws: - /// - `TemplatePluginError.failedToDecodeToolInfo(underlying: error)` If there is a change in representation between the JSON and the current version of the ToolInfoV0 struct + /// - `TemplatePluginError.failedToDecodeToolInfo(underlying: error)` If there is a change in representation + /// between the JSON and the current version of the ToolInfoV0 struct - func dumpToolInfo(using plugin: ResolvedModule, from packageGraph: ModulesGraph, rootPackage: ResolvedPackage) async throws -> ToolInfoV0 { + func dumpToolInfo( + using plugin: ResolvedModule, + from packageGraph: ModulesGraph, + rootPackage: ResolvedPackage + ) async throws -> ToolInfoV0 { let output = try await TemplatePluginRunner.run( plugin: plugin, package: rootPackage, packageGraph: packageGraph, - buildSystem: buildSystem, - arguments: EXPERIMENTAL_DUMP_HELP, - swiftCommandState: swiftCommandState, + buildSystem: self.buildSystem, + arguments: self.EXPERIMENTAL_DUMP_HELP, + swiftCommandState: self.swiftCommandState, requestPermission: true ) @@ -84,19 +90,19 @@ struct TemplatePluginCoordinator { var description: String { switch self { - case let .noMatchingTemplate(name): + case .noMatchingTemplate(let name): "No templates found matching '\(name ?? "")'" - case let .multipleMatchingTemplates(names): + case .multipleMatchingTemplates(let names): "Multiple templates matched: \(names.joined(separator: ", "))" - case let .failedToDecodeToolInfo(underlying): + case .failedToDecodeToolInfo(let underlying): "Failed to decode tool info: \(underlying.localizedDescription)" } } } } -private extension PluginCapability { - var commandInvocationVerb: String? { +extension PluginCapability { + fileprivate var commandInvocationVerb: String? { guard case .command(let intent, _) = self else { return nil } return intent.invocationVerb } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 9e14f788c18..15692b817f9 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -3,10 +3,10 @@ import ArgumentParserToolInfo import Basics import CoreCommands -import SPMBuildCore -import Workspace import Foundation import PackageGraph +import SPMBuildCore +import Workspace public protocol TemplatePluginManager { func loadTemplatePlugin() throws -> ResolvedModule @@ -23,7 +23,7 @@ enum TemplatePluginExecutor { swiftCommandState: SwiftCommandState, requestPermission: Bool = false ) async throws -> Data { - return try await TemplatePluginRunner.run( + try await TemplatePluginRunner.run( plugin: plugin, package: rootPackage, packageGraph: packageGraph, @@ -57,7 +57,13 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { } } - init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String], buildSystem: BuildSystemProvider.Kind) async throws { + init( + swiftCommandState: SwiftCommandState, + template: String?, + scratchDirectory: Basics.AbsolutePath, + args: [String], + buildSystem: BuildSystemProvider.Kind + ) async throws { let coordinator = TemplatePluginCoordinator( buildSystem: buildSystem, swiftCommandState: swiftCommandState, @@ -76,20 +82,27 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { self.buildSystem = buildSystem } - /// Manages the logic of running a template and executing on the information provided by the JSON representation of a template's arguments. + /// Manages the logic of running a template and executing on the information provided by the JSON representation of + /// a template's arguments. /// /// - Throws: - /// - `TemplatePluginError.executionFailed(underlying: error)` If there was an error during the execution of a template's plugin. - /// - `TemplatePluginError.failedToDecodeToolInfo(underlying: error)` If there is a change in representation between the JSON and the current version of the ToolInfoV0 struct + /// - `TemplatePluginError.executionFailed(underlying: error)` If there was an error during the execution of a + /// template's plugin. + /// - `TemplatePluginError.failedToDecodeToolInfo(underlying: error)` If there is a change in representation + /// between the JSON and the current version of the ToolInfoV0 struct /// - `TemplatePluginError.execu` func run() async throws { let plugin = try loadTemplatePlugin() - let toolInfo = try await coordinator.dumpToolInfo(using: plugin, from: packageGraph, rootPackage: rootPackage) + let toolInfo = try await coordinator.dumpToolInfo( + using: plugin, + from: self.packageGraph, + rootPackage: self.rootPackage + ) let cliResponses: [String] = try promptUserForTemplateArguments(using: toolInfo) - _ = try await runTemplatePlugin(plugin, with: cliResponses) + _ = try await self.runTemplatePlugin(plugin, with: cliResponses) } /// Utilizes the prompting system defined by the struct to prompt user. @@ -104,10 +117,12 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// - Returns: A 2D array of arguments provided by the user for template generation /// - Throws: Any errors during user prompting private func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [String] { - return try TemplatePromptingSystem(hasTTY: swiftCommandState.outputStream.isTTY).promptUser(command: toolInfo.command, arguments: args) + try TemplatePromptingSystem(hasTTY: self.swiftCommandState.outputStream.isTTY).promptUser( + command: toolInfo.command, + arguments: self.args + ) } - /// Runs the plugin of a template given a set of arguments. /// /// - Parameters: @@ -120,13 +135,13 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// - Returns: A data representation of the result of the execution of the template's plugin. private func runTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { - return try await TemplatePluginExecutor.execute( + try await TemplatePluginExecutor.execute( plugin: plugin, - rootPackage: rootPackage, - packageGraph: packageGraph, - buildSystemKind: buildSystem, + rootPackage: self.rootPackage, + packageGraph: self.packageGraph, + buildSystemKind: self.buildSystem, arguments: arguments, - swiftCommandState: swiftCommandState, + swiftCommandState: self.swiftCommandState, requestPermission: false ) } @@ -134,13 +149,15 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// Loads the plugin that corresponds to the template's name. /// /// - Throws: - /// - `TempaltePluginError.noMatchingTemplate(name: String?)` if there are no plugins corresponding to the desired template. - /// - `TemplatePluginError.multipleMatchingTemplates(names: [String]` if the search returns more than one plugin given a desired template + /// - `TempaltePluginError.noMatchingTemplate(name: String?)` if there are no plugins corresponding to the desired + /// template. + /// - `TemplatePluginError.multipleMatchingTemplates(names: [String]` if the search returns more than one plugin + /// given a desired template /// /// - Returns: A data representation of the result of the execution of the template's plugin. func loadTemplatePlugin() throws -> ResolvedModule { - return try coordinator.loadTemplatePlugin(from: packageGraph) + try self.coordinator.loadTemplatePlugin(from: self.packageGraph) } enum TemplateInitializationError: Error, CustomStringConvertible { @@ -149,9 +166,8 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { var description: String { switch self { case .missingPackageGraph: - return "No root package was found in package graph." + "No root package was found in package graph." } } } - } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift index 7844cefb17a..bef5d84a3a3 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift @@ -78,7 +78,7 @@ enum TemplatePluginRunner { var allowedNetworkConnections = allowNetworkConnections if requestPermission { - try requestPluginPermissions( + try self.requestPluginPermissions( from: pluginTarget, pluginName: plugin.name, packagePath: package.path, @@ -104,10 +104,16 @@ enum TemplatePluginRunner { let accessibleTools = try await plugin.preparePluginTools( fileSystem: swiftCommandState.fileSystem, environment: buildParams.buildEnvironment, - for: try pluginScriptRunner.hostTriple + for: pluginScriptRunner.hostTriple ) { name, path in - // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies are not supported within a package, so if the tool happens to be from the same package, we instead find the executable that corresponds to the product. There is always one, because of autogeneration of implicit executables with the same name as the target if there isn't an explicit one. - let buildResult = try await buildSystem.build(subset: .product(name, for: .host), buildOutputs: [.buildPlan]) + // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies + // are not supported within a package, so if the tool happens to be from the same package, we instead find + // the executable that corresponds to the product. There is always one, because of autogeneration of + // implicit executables with the same name as the target if there isn't an explicit one. + let buildResult = try await buildSystem.build( + subset: .product(name, for: .host), + buildOutputs: [.buildPlan] + ) if let buildPlan = buildResult.buildPlan { if let builtTool = buildPlan.buildProducts.first(where: { @@ -122,8 +128,12 @@ enum TemplatePluginRunner { } } - - let pluginDelegate = PluginDelegate(swiftCommandState: swiftCommandState, buildSystem: buildSystemKind, plugin: pluginTarget, echoOutput: false) + let pluginDelegate = PluginDelegate( + swiftCommandState: swiftCommandState, + buildSystem: buildSystemKind, + plugin: pluginTarget, + echoOutput: false + ) let workingDir = try swiftCommandState.options.locations.packageDirectory ?? swiftCommandState.fileSystem.currentWorkingDirectory @@ -148,10 +158,10 @@ enum TemplatePluginRunner { callbackQueue: DispatchQueue(label: "plugin-invocation"), delegate: pluginDelegate ) - + guard success else { let stringError = pluginDelegate.diagnostics - .map { $0.message } + .map(\.message) .joined(separator: "\n") throw DefaultPluginScriptRunnerError.invocationFailed( diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift index 2cb9bd2642f..11668157ec4 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift @@ -1,8 +1,8 @@ import Basics import CoreCommands import Foundation -import Workspace import PackageModel +import Workspace public struct TemplateTestingDirectoryManager { let fileSystem: FileSystem @@ -17,20 +17,23 @@ public struct TemplateTestingDirectoryManager { public func createTemporaryDirectories(directories: Set) throws -> [Basics.AbsolutePath] { let tempDir = try helper.createTemporaryDirectory() - return try helper.createSubdirectories(in: tempDir, names: Array(directories)) + return try self.helper.createSubdirectories(in: tempDir, names: Array(directories)) } - public func createOutputDirectory(outputDirectoryPath: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) throws { + public func createOutputDirectory( + outputDirectoryPath: Basics.AbsolutePath, + swiftCommandState: SwiftCommandState + ) throws { let manifestPath = outputDirectoryPath.appending(component: Manifest.filename) let fs = swiftCommandState.fileSystem - if !helper.directoryExists(outputDirectoryPath) { + if !self.helper.directoryExists(outputDirectoryPath) { try FileManager.default.createDirectory( at: outputDirectoryPath.asURL, withIntermediateDirectories: true ) } else if fs.exists(manifestPath) { - observabilityScope.emit( + self.observabilityScope.emit( error: DirectoryManagerError.foundManifestFile(path: outputDirectoryPath) ) throw DirectoryManagerError.foundManifestFile(path: outputDirectoryPath) diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index 3ad0f34377b..97d79675bcc 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -1,12 +1,11 @@ import ArgumentParserToolInfo import Basics -import SPMBuildCore import CoreCommands -import SPMBuildCore -import Workspace import Foundation import PackageGraph +import SPMBuildCore +import Workspace /// A utility for obtaining and running a template's plugin . /// @@ -29,7 +28,14 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { return root } - init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String], branches: [String], buildSystem: BuildSystemProvider.Kind) async throws { + init( + swiftCommandState: SwiftCommandState, + template: String?, + scratchDirectory: Basics.AbsolutePath, + args: [String], + branches: [String], + buildSystem: BuildSystemProvider.Kind + ) async throws { let coordinator = TemplatePluginCoordinator( buildSystem: buildSystem, swiftCommandState: swiftCommandState, @@ -50,22 +56,29 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { } func run() async throws -> [CommandPath] { - let plugin = try coordinator.loadTemplatePlugin(from: packageGraph) - let toolInfo = try await coordinator.dumpToolInfo(using: plugin, from: packageGraph, rootPackage: rootPackage) + let plugin = try coordinator.loadTemplatePlugin(from: self.packageGraph) + let toolInfo = try await coordinator.dumpToolInfo( + using: plugin, + from: self.packageGraph, + rootPackage: self.rootPackage + ) - return try promptUserForTemplateArguments(using: toolInfo) + return try self.promptUserForTemplateArguments(using: toolInfo) } private func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [CommandPath] { - try TemplateTestPromptingSystem(hasTTY: swiftCommandState.outputStream.isTTY).generateCommandPaths(rootCommand: toolInfo.command, args: args, branches: branches) + try TemplateTestPromptingSystem(hasTTY: self.swiftCommandState.outputStream.isTTY).generateCommandPaths( + rootCommand: toolInfo.command, + args: self.args, + branches: self.branches + ) } public func loadTemplatePlugin() throws -> ResolvedModule { - return try coordinator.loadTemplatePlugin(from: packageGraph) + try self.coordinator.loadTemplatePlugin(from: self.packageGraph) } } - public struct CommandPath { public let fullPathKey: String public let commandChain: [CommandComponent] @@ -78,41 +91,42 @@ public struct CommandComponent { extension CommandPath { func displayFormat() -> String { - let commandNames = commandChain.map { $0.commandName } + let commandNames = self.commandChain.map(\.commandName) let fullPath = commandNames.joined(separator: " ") var result = "Command Path: \(fullPath) \nExecution Format: \n\n" // Build flat command format: [Command command-args sub-command sub-command-args ...] - let flatCommand = buildFlatCommandDisplay() + let flatCommand = self.buildFlatCommandDisplay() result += "\(flatCommand)\n\n" - + return result } - + private func buildFlatCommandDisplay() -> String { var result: [String] = [] - - for (index, command) in commandChain.enumerated() { + + for (index, command) in self.commandChain.enumerated() { // Add command name (skip the first command name as it's the root) if index > 0 { result.append(command.commandName) } - + // Add all arguments for this command level - let commandArgs = command.arguments.flatMap { $0.commandLineFragments } + let commandArgs = command.arguments.flatMap(\.commandLineFragments) result.append(contentsOf: commandArgs) } - + return result.joined(separator: " ") } private func formatArguments(_ argumentResponses: - [Commands.TemplateTestPromptingSystem.ArgumentResponse]) -> String { + [Commands.TemplateTestPromptingSystem.ArgumentResponse] + ) -> String { let formattedArgs = argumentResponses.compactMap { response -> String? in guard let preferredName = - response.argument.preferredName?.name else { return nil } + response.argument.preferredName?.name else { return nil } let values = response.values.joined(separator: " ") return values.isEmpty ? nil : " --\(preferredName) \(values)" @@ -122,16 +136,13 @@ extension CommandPath { } } - - - public class TemplateTestPromptingSystem { - private let hasTTY: Bool public init(hasTTY: Bool = true) { self.hasTTY = hasTTY } + /// Prompts the user for input based on the given command definition and arguments. /// /// This method collects responses for a command's arguments by first validating any user-provided @@ -155,36 +166,38 @@ public class TemplateTestPromptingSystem { /// /// - Throws: An error if argument parsing or user prompting fails. - - // resolve arguments at this level // append arguments to the current path // if subcommands exist, then for each subcommand, pass the function again, where we deepCopy a path // if not, then jointhe command names of all the paths, and append CommandPath() - private func parseAndMatchArguments(_ input: [String], definedArgs: [ArgumentInfoV0], subcommands: [CommandInfoV0] = []) throws -> (Set, [String]) { + private func parseAndMatchArguments( + _ input: [String], + definedArgs: [ArgumentInfoV0], + subcommands: [CommandInfoV0] = [] + ) throws -> (Set, [String]) { var responses = Set() var providedMap: [String: [String]] = [:] var leftover: [String] = [] var tokens = input var terminatorSeen = false var postTerminatorArgs: [String] = [] - - let subcommandNames = Set(subcommands.map { $0.commandName }) + + let subcommandNames = Set(subcommands.map(\.commandName)) let positionalArgs = definedArgs.filter { $0.kind == .positional } - + // Handle terminator (--) for post-terminator parsing if let terminatorIndex = tokens.firstIndex(of: "--") { postTerminatorArgs = Array(tokens[(terminatorIndex + 1)...]) tokens = Array(tokens[.. [String] { + private func parseOptionValues( + arg: ArgumentInfoV0, + tokens: inout [String], + currentIndex: inout Int + ) throws -> [String] { var values: [String] = [] - + switch arg.parsingStrategy { case .default: // Expect the next token to be a value and parse it @@ -351,7 +372,7 @@ public class TemplateTestPromptingSystem { } values.append(tokens[currentIndex]) tokens.remove(at: currentIndex) - + case .scanningForValue: // Parse the next token as a value if it exists and isn't an option if currentIndex < tokens.count && !tokens[currentIndex].starts(with: "--") { @@ -360,7 +381,7 @@ public class TemplateTestPromptingSystem { } else if let defaultValue = arg.defaultValue { values.append(defaultValue) } - + case .unconditional: // Parse the next token as a value, regardless of its type guard currentIndex < tokens.count else { @@ -371,7 +392,7 @@ public class TemplateTestPromptingSystem { } values.append(tokens[currentIndex]) tokens.remove(at: currentIndex) - + case .upToNextOption: // Parse multiple values up to the next option while currentIndex < tokens.count && !tokens[currentIndex].starts(with: "--") { @@ -382,12 +403,12 @@ public class TemplateTestPromptingSystem { if values.isEmpty && arg.defaultValue != nil { values.append(arg.defaultValue!) } - + case .allRemainingInput: // Collect all remaining tokens values = Array(tokens[currentIndex...]) tokens.removeSubrange(currentIndex...) - + case .postTerminator, .allUnrecognized: // These are handled separately in the main parsing logic if currentIndex < tokens.count { @@ -395,7 +416,7 @@ public class TemplateTestPromptingSystem { tokens.remove(at: currentIndex) } } - + // Validate values against allowed values if specified if let allowed = arg.allValues { let invalid = values.filter { !allowed.contains($0) } @@ -407,16 +428,29 @@ public class TemplateTestPromptingSystem { ) } } - + return values } - public func generateCommandPaths(rootCommand: CommandInfoV0, args: [String], branches: [String]) throws -> [CommandPath] { + public func generateCommandPaths( + rootCommand: CommandInfoV0, + args: [String], + branches: [String] + ) throws -> [CommandPath] { var paths: [CommandPath] = [] var visitedArgs = Set() var inheritedResponses: [ArgumentResponse] = [] - try dfsWithInheritance(command: rootCommand, path: [], visitedArgs: &visitedArgs, inheritedResponses: &inheritedResponses, paths: &paths, predefinedArgs: args, branches: branches, branchDepth: 0) + try dfsWithInheritance( + command: rootCommand, + path: [], + visitedArgs: &visitedArgs, + inheritedResponses: &inheritedResponses, + paths: &paths, + predefinedArgs: args, + branches: branches, + branchDepth: 0 + ) for path in paths { print(path.displayFormat()) @@ -424,22 +458,34 @@ public class TemplateTestPromptingSystem { return paths } - func dfsWithInheritance(command: CommandInfoV0, path: [CommandComponent], visitedArgs: inout Set, inheritedResponses: inout [ArgumentResponse], paths: inout [CommandPath], predefinedArgs: [String], branches: [String], branchDepth: Int = 0) throws { - + func dfsWithInheritance( + command: CommandInfoV0, + path: [CommandComponent], + visitedArgs: inout Set, + inheritedResponses: inout [ArgumentResponse], + paths: inout [CommandPath], + predefinedArgs: [String], + branches: [String], + branchDepth: Int = 0 + ) throws { let allArgs = try convertArguments(from: command) - let subCommands = getSubCommand(from: command) ?? [] + let subCommands = self.getSubCommand(from: command) ?? [] - let (answeredArgs, leftoverArgs) = try parseAndMatchArguments(predefinedArgs, definedArgs: allArgs, subcommands: subCommands) + let (answeredArgs, leftoverArgs) = try parseAndMatchArguments( + predefinedArgs, + definedArgs: allArgs, + subcommands: subCommands + ) // Combine inherited responses with current parsed responses - let currentArgNames = Set(allArgs.map { $0.valueName }) + let currentArgNames = Set(allArgs.map(\.valueName)) let relevantInheritedResponses = inheritedResponses.filter { !currentArgNames.contains($0.argument.valueName) } - + var allCurrentResponses = Array(answeredArgs) + relevantInheritedResponses visitedArgs.formUnion(answeredArgs) // Find missing arguments that need prompting - let providedArgNames = Set(allCurrentResponses.map { $0.argument.valueName }) + let providedArgNames = Set(allCurrentResponses.map(\.argument.valueName)) let missingArgs = allArgs.filter { arg in !providedArgNames.contains(arg.valueName) && arg.valueName != "help" && arg.shouldDisplay } @@ -454,7 +500,7 @@ public class TemplateTestPromptingSystem { // Filter to only include arguments defined at this command level let currentLevelResponses = allCurrentResponses.filter { currentArgNames.contains($0.argument.valueName) } - + let currentComponent = CommandComponent( commandName: command.commandName, arguments: currentLevelResponses ) @@ -470,45 +516,61 @@ public class TemplateTestPromptingSystem { // Try to auto-detect a subcommand from leftover args if let (index, matchedSubcommand) = leftoverArgs .enumerated() - .compactMap({ (i, token) -> (Int, CommandInfoV0)? in + .compactMap({ i, token -> (Int, CommandInfoV0)? in if let match = subcommands.first(where: { $0.commandName == token }) { print("Detected subcommand '\(match.commandName)' from user input.") return (i, match) } return nil }) - .first { - + .first + { var newLeftoverArgs = leftoverArgs newLeftoverArgs.remove(at: index) - - let shouldTraverse: Bool - if branches.isEmpty { - shouldTraverse = true + + let shouldTraverse: Bool = if branches.isEmpty { + true } else if branchDepth < (branches.count - 1) { - shouldTraverse = matchedSubcommand.commandName == branches[branchDepth + 1] + matchedSubcommand.commandName == branches[branchDepth + 1] } else { - shouldTraverse = matchedSubcommand.commandName == branches[branchDepth] + matchedSubcommand.commandName == branches[branchDepth] } - + if shouldTraverse { - try dfsWithInheritance(command: matchedSubcommand, path: newPath, visitedArgs: &visitedArgs, inheritedResponses: &newInheritedResponses, paths: &paths, predefinedArgs: newLeftoverArgs, branches: branches, branchDepth: branchDepth + 1) + try self.dfsWithInheritance( + command: matchedSubcommand, + path: newPath, + visitedArgs: &visitedArgs, + inheritedResponses: &newInheritedResponses, + paths: &paths, + predefinedArgs: newLeftoverArgs, + branches: branches, + branchDepth: branchDepth + 1 + ) } } else { // No subcommand detected, process all available subcommands based on branch filter for sub in subcommands { - let shouldTraverse: Bool - if branches.isEmpty { - shouldTraverse = true + let shouldTraverse: Bool = if branches.isEmpty { + true } else if branchDepth < (branches.count - 1) { - shouldTraverse = sub.commandName == branches[branchDepth + 1] + sub.commandName == branches[branchDepth + 1] } else { - shouldTraverse = sub.commandName == branches[branchDepth] + sub.commandName == branches[branchDepth] } - + if shouldTraverse { var branchInheritedResponses = newInheritedResponses - try dfsWithInheritance(command: sub, path: newPath, visitedArgs: &visitedArgs, inheritedResponses: &branchInheritedResponses, paths: &paths, predefinedArgs: leftoverArgs, branches: branches, branchDepth: branchDepth + 1) + try dfsWithInheritance( + command: sub, + path: newPath, + visitedArgs: &visitedArgs, + inheritedResponses: &branchInheritedResponses, + paths: &paths, + predefinedArgs: leftoverArgs, + branches: branches, + branchDepth: branchDepth + 1 + ) } } } @@ -522,13 +584,10 @@ public class TemplateTestPromptingSystem { } func joinCommandNames(_ path: [CommandComponent]) -> String { - path.map { $0.commandName }.joined(separator: "-") + path.map(\.commandName).joined(separator: "-") } } - - - /// Retrieves the list of subcommands for a given command, excluding common utility commands. /// /// This method checks whether the given command contains any subcommands. If so, it filters @@ -574,7 +633,7 @@ public class TemplateTestPromptingSystem { collected: inout [String: ArgumentResponse], hasTTY: Bool = true ) throws -> [ArgumentResponse] { - return try arguments + try arguments .filter { $0.valueName != "help" && $0.shouldDisplay } .compactMap { arg in let key = arg.preferredName?.name ?? arg.valueName ?? UUID().uuidString @@ -588,8 +647,8 @@ public class TemplateTestPromptingSystem { let defaultText = arg.defaultValue.map { " (default: \($0))" } ?? "" let allValuesText = (arg.allValues?.isEmpty == false) ? - " [\(arg.allValues!.joined(separator: ", "))]" : "" - let completionText = generateCompletionHint(for: arg) + " [\(arg.allValues!.joined(separator: ", "))]" : "" + let completionText = self.generateCompletionHint(for: arg) let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(completionText)\(defaultText):" var values: [String] = [] @@ -597,9 +656,11 @@ public class TemplateTestPromptingSystem { switch arg.kind { case .flag: if !hasTTY && arg.isOptional == false && arg.defaultValue == nil { - fatalError("Required argument '\(arg.valueName ?? "")' not provided and no interactive terminal available") + fatalError( + "Required argument '\(arg.valueName ?? "")' not provided and no interactive terminal available" + ) } - + var confirmed: Bool? = nil if hasTTY { confirmed = try promptForConfirmation( @@ -610,7 +671,7 @@ public class TemplateTestPromptingSystem { } else if let defaultValue = arg.defaultValue { confirmed = defaultValue.lowercased() == "true" } - + if let confirmed { values = [confirmed ? "true" : "false"] } else if arg.isOptional { @@ -624,11 +685,14 @@ public class TemplateTestPromptingSystem { case .option, .positional: if !hasTTY && arg.isOptional == false && arg.defaultValue == nil { - fatalError("Required argument '\(arg.valueName ?? "")' not provided and no interactive terminal available") + fatalError( + "Required argument '\(arg.valueName ?? "")' not provided and no interactive terminal available" + ) } - + if hasTTY { - let nilSuffix = arg.isOptional && arg.defaultValue == nil ? " (or enter \"nil\" to unset)" : "" + let nilSuffix = arg.isOptional && arg + .defaultValue == nil ? " (or enter \"nil\" to unset)" : "" print(promptMessage + nilSuffix) } @@ -638,12 +702,18 @@ public class TemplateTestPromptingSystem { if input.lowercased() == "nil" && arg.isOptional { // Clear the values array to explicitly unset values = [] - let response = ArgumentResponse(argument: arg, values: values, isExplicitlyUnset: true) + let response = ArgumentResponse( + argument: arg, + values: values, + isExplicitlyUnset: true + ) collected[key] = response return response } if let allowed = arg.allValues, !allowed.contains(input) { - print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + print( + "Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))" + ) continue } values.append(input) @@ -657,17 +727,29 @@ public class TemplateTestPromptingSystem { if let input, !input.isEmpty { if input.lowercased() == "nil" && arg.isOptional { values = [] - let response = ArgumentResponse(argument: arg, values: values, isExplicitlyUnset: true) + let response = ArgumentResponse( + argument: arg, + values: values, + isExplicitlyUnset: true + ) collected[key] = response return response } else { if let allowed = arg.allValues, !allowed.contains(input) { if hasTTY { - print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") - print("Or try completion suggestions: \(generateCompletionSuggestions(for: arg, input: input))") + print( + "Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))" + ) + print( + "Or try completion suggestions: \(self.generateCompletionSuggestions(for: arg, input: input))" + ) fatalError("Invalid value provided") } else { - throw TemplateError.invalidValue(argument: arg.valueName ?? "", invalidValues: [input], allowed: allowed) + throw TemplateError.invalidValue( + argument: arg.valueName ?? "", + invalidValues: [input], + allowed: allowed + ) } } values = [input] @@ -689,11 +771,11 @@ public class TemplateTestPromptingSystem { return response } } - + /// Generates completion hint text based on CompletionKindV0 private static func generateCompletionHint(for arg: ArgumentInfoV0) -> String { guard let completionKind = arg.completionKind else { return "" } - + switch completionKind { case .list(let values): return " (suggestions: \(values.joined(separator: ", ")))" @@ -713,13 +795,13 @@ public class TemplateTestPromptingSystem { return " (custom completions available)" } } - + /// Generates completion suggestions based on input and CompletionKindV0 private static func generateCompletionSuggestions(for arg: ArgumentInfoV0, input: String) -> String { guard let completionKind = arg.completionKind else { return "No completions available" } - + switch completionKind { case .list(let values): let suggestions = values.filter { $0.hasPrefix(input) } @@ -739,7 +821,11 @@ public class TemplateTestPromptingSystem { /// - Returns: `true` if the user confirmed, `false` if denied, `nil` if explicitly unset. /// - Throws: TemplateError if required argument missing without TTY - private static func promptForConfirmation(prompt: String, defaultBehavior: Bool?, isOptional: Bool) throws -> Bool? { + private static func promptForConfirmation( + prompt: String, + defaultBehavior: Bool?, + isOptional: Bool + ) throws -> Bool? { var suffix = defaultBehavior == true ? " [Y/n]" : defaultBehavior == false ? " [y/N]" : " [y/n]" if isOptional && defaultBehavior == nil { @@ -759,7 +845,7 @@ public class TemplateTestPromptingSystem { switch input { case "y", "yes": return true case "n", "no": return false - case "nil": + case "nil": if isOptional { return nil } else { @@ -773,7 +859,7 @@ public class TemplateTestPromptingSystem { } else { throw TemplateError.missingRequiredArgumentWithoutTTY(name: "confirmation") } - default: + default: if let defaultBehavior { return defaultBehavior } else if isOptional { @@ -792,15 +878,15 @@ public class TemplateTestPromptingSystem { /// The values provided by the user. public let values: [String] - + /// Whether the argument was explicitly unset (nil) by the user. public let isExplicitlyUnset: Bool /// Returns the command line fragments representing this argument and its values. public var commandLineFragments: [String] { // If explicitly unset, don't generate any command line fragments - guard !isExplicitlyUnset else { return [] } - + guard !self.isExplicitlyUnset else { return [] } + guard let name = argument.valueName else { return self.values } @@ -809,7 +895,7 @@ public class TemplateTestPromptingSystem { case .flag: return self.values.first == "true" ? ["--\(name)"] : [] case .option: - if argument.isRepeating { + if self.argument.isRepeating { return self.values.flatMap { ["--\(name)", $0] } } else { return self.values.flatMap { ["--\(name)", $0] } @@ -818,7 +904,7 @@ public class TemplateTestPromptingSystem { return self.values } } - + /// Initialize with explicit unset state public init(argument: ArgumentInfoV0, values: [String], isExplicitlyUnset: Bool = false) { self.argument = argument @@ -827,15 +913,13 @@ public class TemplateTestPromptingSystem { } public func hash(into hasher: inout Hasher) { - hasher.combine(argument.valueName) + hasher.combine(self.argument.valueName) } public static func == (lhs: ArgumentResponse, rhs: ArgumentResponse) -> Bool { - return lhs.argument.valueName == rhs.argument.valueName + lhs.argument.valueName == rhs.argument.valueName } } - - } /// An error enum representing various template-related errors. From fa25b7235dc5aed024c63f5f40190a5d905659ad Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 23 Sep 2025 13:20:22 -0400 Subject: [PATCH 118/225] changes to reflect github comments --- .../generated-package/.gitignore | 8 - .../InferPackageType/Package.swift | 2 +- .../ShowExecutables/app/Package.swift | 2 +- .../deck-of-playing-cards/Package.swift | 2 +- .../ShowTemplates/app/Package.swift | 2 +- .../PackageCommands/PluginCommand.swift | 8 +- .../PackageCommands/ShowTemplates.swift | 51 +- .../TestCommands/TestTemplateCommand.swift | 214 ++-- .../PackageInitializer.swift | 29 +- .../RequirementResolver.swift | 4 - .../_InternalInitSupport/TemplateBuild.swift | 7 +- .../TemplatePathResolver.swift | 100 +- .../TemplatePluginCoordinator.swift | 2 +- .../TemplatePluginManager.swift | 25 +- .../TemplatePluginRunner.swift | 7 +- .../TemplateTesterManager.swift | 754 ++++++++++++-- Sources/CoreCommands/SwiftCommandState.swift | 9 +- Sources/PackageDescription/Product.swift | 2 +- Sources/PackageDescription/Target.swift | 12 +- Sources/PackageLoading/PackageBuilder.swift | 4 +- .../InitPackage.swift | 3 +- Tests/CommandsTests/PackageCommandTests.swift | 3 - Tests/CommandsTests/TemplateTests.swift | 925 +++++++++++------- 23 files changed, 1538 insertions(+), 637 deletions(-) delete mode 100644 Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore diff --git a/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore deleted file mode 100644 index 0023a534063..00000000000 --- a/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/Fixtures/Miscellaneous/InferPackageType/Package.swift b/Fixtures/Miscellaneous/InferPackageType/Package.swift index 714cf42da70..47f8992e439 100644 --- a/Fixtures/Miscellaneous/InferPackageType/Package.swift +++ b/Fixtures/Miscellaneous/InferPackageType/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:999.0.0 +// swift-tools-version: 6.3.0 import PackageDescription let initialLibrary: [Target] = .template( diff --git a/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift b/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift index 29a83e676a1..7945bacab8a 100644 --- a/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:999.0 +// swift-tools-version:6.3.0 import PackageDescription let package = Package( diff --git a/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift b/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift index 3dea7337eb5..0c23f679535 100644 --- a/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift +++ b/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:999.0.0 +// swift-tools-version:5.10 import PackageDescription let package = Package( diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift index 5be9ac6a666..c3059b14209 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:999.0.0 +// swift-tools-version:6.3.0 import PackageDescription let package = Package( diff --git a/Sources/Commands/PackageCommands/PluginCommand.swift b/Sources/Commands/PackageCommands/PluginCommand.swift index 042e99b77e9..0d23a09b978 100644 --- a/Sources/Commands/PackageCommands/PluginCommand.swift +++ b/Sources/Commands/PackageCommands/PluginCommand.swift @@ -424,19 +424,15 @@ struct PluginCommand: AsyncSwiftCommand { } } - static func findPlugins(matching verb: String?, in graph: ModulesGraph, limitedTo packageIdentity: String?) -> [ResolvedModule] { + static func findPlugins(matching verb: String, in graph: ModulesGraph, limitedTo packageIdentity: String?) -> [ResolvedModule] { // Find and return the command plugins that match the command. - let plugins = Self.availableCommandPlugins(in: graph, limitedTo: packageIdentity).filter { + Self.availableCommandPlugins(in: graph, limitedTo: packageIdentity).filter { let plugin = $0.underlying as! PluginModule // Filter out any non-command plugins and any whose verb is different. guard case .command(let intent, _) = plugin.capability else { return false } - guard let verb else { return true } - return verb == intent.invocationVerb } - - return plugins } } diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 5e34f7788ca..e31ce9b821f 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -78,40 +78,37 @@ struct ShowTemplates: AsyncSwiftCommand { throw InternalError("Could not find the current working directory") } - // precheck() needed, extremely similar to the Init precheck, can refactor possibly - let source = try resolveSource( + let sourceResolver = DefaultTemplateSourceResolver( cwd: cwd, fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope ) - let resolvedPath = try await resolveTemplatePath(using: swiftCommandState, source: source) - let templates = try await loadTemplates(from: resolvedPath, swiftCommandState: swiftCommandState) - try await displayTemplates(templates, at: resolvedPath, using: swiftCommandState) - try cleanupTemplate( - source: source, - path: resolvedPath, - fileSystem: swiftCommandState.fileSystem, - observabilityScope: swiftCommandState.observabilityScope + + let templateSource = sourceResolver.resolveSource( + directory: cwd, url: self.templateURL, packageID: self.templatePackageID ) - } - private func resolveSource( - cwd: AbsolutePath, - fileSystem: FileSystem, - observabilityScope: ObservabilityScope - ) throws -> InitTemplatePackage.TemplateSource { - guard let source = DefaultTemplateSourceResolver( - cwd: cwd, - fileSystem: fileSystem, - observabilityScope: observabilityScope - ).resolveSource( - directory: cwd, - url: self.templateURL, - packageID: self.templatePackageID - ) else { - throw ValidationError("No template source specified. Provide --url or run in a valid package directory.") + if let source = templateSource { + do { + try sourceResolver.validate( + templateSource: source, + directory: cwd, + url: self.templateURL, + packageID: self.templatePackageID + ) + let resolvedPath = try await resolveTemplatePath(using: swiftCommandState, source: source) + let templates = try await loadTemplates(from: resolvedPath, swiftCommandState: swiftCommandState) + try await displayTemplates(templates, at: resolvedPath, using: swiftCommandState) + try cleanupTemplate( + source: source, + path: resolvedPath, + fileSystem: swiftCommandState.fileSystem, + observabilityScope: swiftCommandState.observabilityScope + ) + } catch { + swiftCommandState.observabilityScope.emit(error) + } } - return source } private func resolveTemplatePath( diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 188420e3404..ac01b65f5e8 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -92,63 +92,75 @@ extension SwiftTestCommand { func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw ValidationError("Could not determine current working directory.") + throw InternalError("Could not find the current working directory") } - let directoryManager = TemplateTestingDirectoryManager( - fileSystem: swiftCommandState.fileSystem, - observabilityScope: swiftCommandState.observabilityScope - ) - try directoryManager.createOutputDirectory( - outputDirectoryPath: self.outputDirectory, - swiftCommandState: swiftCommandState - ) + do { + let directoryManager = TemplateTestingDirectoryManager( + fileSystem: swiftCommandState.fileSystem, + observabilityScope: swiftCommandState.observabilityScope + ) + try directoryManager.createOutputDirectory( + outputDirectoryPath: self.outputDirectory, + swiftCommandState: swiftCommandState + ) - let buildSystem = self.globalOptions.build.buildSystem != .native ? + let buildSystem = self.globalOptions.build.buildSystem != .native ? self.globalOptions.build.buildSystem : swiftCommandState.options.build.buildSystem - let pluginManager = try await TemplateTesterPluginManager( - swiftCommandState: swiftCommandState, - template: templateName, - scratchDirectory: cwd, - args: args, - branches: branches, - buildSystem: buildSystem, - ) + let resolvedTemplateName: String + if self.templateName == nil { + resolvedTemplateName = try await self.findTemplateName(from: cwd, swiftCommandState: swiftCommandState) + } else { + resolvedTemplateName = self.templateName! + } - let commandPlugin = try pluginManager.loadTemplatePlugin() - let commandLineFragments = try await pluginManager.run() + let pluginManager = try await TemplateTesterPluginManager( + swiftCommandState: swiftCommandState, + template: resolvedTemplateName, + scratchDirectory: cwd, + args: args, + branches: branches, + buildSystem: buildSystem, + ) - if self.dryRun { - for commandLine in commandLineFragments { - print(commandLine.displayFormat()) + let commandPlugin: ResolvedModule = try pluginManager.loadTemplatePlugin() + + let commandLineFragments: [CommandPath] = try await pluginManager.run() + + if self.dryRun { + for commandLine in commandLineFragments { + print(commandLine.displayFormat()) + } + return } - return - } - let packageType = try await inferPackageType(swiftCommandState: swiftCommandState, from: cwd) + let packageType = try await inferPackageType(swiftCommandState: swiftCommandState, from: cwd) - var buildMatrix: [String: BuildInfo] = [:] + var buildMatrix: [String: BuildInfo] = [:] - for commandLine in commandLineFragments { - let folderName = commandLine.fullPathKey + for commandLine in commandLineFragments { + let folderName = commandLine.fullPathKey - buildMatrix[folderName] = try await self.testDecisionTreeBranch( - folderName: folderName, - commandLine: commandLine.commandChain, - swiftCommandState: swiftCommandState, - packageType: packageType, - commandPlugin: commandPlugin, - cwd: cwd, - buildSystem: buildSystem - ) - } + buildMatrix[folderName] = try await self.testDecisionTreeBranch( + folderName: folderName, + commandLine: commandLine.commandChain, + swiftCommandState: swiftCommandState, + packageType: packageType, + commandPlugin: commandPlugin, + cwd: cwd, + buildSystem: buildSystem + ) + } - switch self.format { - case .matrix: - self.printBuildMatrix(buildMatrix) - case .json: - self.printJSONMatrix(buildMatrix) + switch self.format { + case .matrix: + self.printBuildMatrix(buildMatrix) + case .json: + self.printJSONMatrix(buildMatrix) + } + } catch { + swiftCommandState.observabilityScope.emit(error) } } @@ -164,7 +176,11 @@ extension SwiftTestCommand { let destinationPath = self.outputDirectory.appending(component: folderName) swiftCommandState.observabilityScope.emit(debug: "Generating \(folderName)") - try FileManager.default.createDirectory(at: destinationPath.asURL, withIntermediateDirectories: true) + do { + try FileManager.default.createDirectory(at: destinationPath.asURL, withIntermediateDirectories: true) + } catch { + throw TestTemplateCommandError.directoryCreationFailed(destinationPath.pathString) + } return try await self.testTemplateInitialization( commandPlugin: commandPlugin, @@ -218,9 +234,11 @@ extension SwiftTestCommand { let data = try encoder.encode(matrix) if let output = String(data: data, encoding: .utf8) { print(output) + } else { + print("Failed to convert JSON data to string") } } catch { - print("Failed to encode JSON: \(error)") + print("Failed to encode JSON: \(error.localizedDescription)") } } @@ -237,7 +255,7 @@ extension SwiftTestCommand { ) guard let manifest = rootManifests.values.first else { - throw ValidationError("") + throw TestTemplateCommandError.invalidManifestInTemplate } var targetName = self.templateName @@ -255,7 +273,7 @@ extension SwiftTestCommand { } } - throw ValidationError("") + throw TestTemplateCommandError.templateNotFound(targetName ?? "") } private func findTemplateName(from manifest: Manifest) throws -> String { @@ -270,11 +288,26 @@ extension SwiftTestCommand { switch templateTargets.count { case 0: - throw ValidationError("") + throw TestTemplateCommandError.noTemplatesInManifest case 1: return templateTargets[0] default: - throw ValidationError("") + throw TestTemplateCommandError.multipleTemplatesFound(templateTargets) + } + } + + func findTemplateName(from templatePath: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) async throws -> String { + try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { workspace, root in + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope + ) + + guard let manifest = rootManifests.values.first else { + throw TestTemplateCommandError.invalidManifestInTemplate + } + + return try findTemplateName(from: manifest) } } @@ -340,21 +373,22 @@ extension SwiftTestCommand { swiftCommandState: swiftCommandState, requestPermission: false ) - pluginOutput = String(data: output, encoding: .utf8) ?? "[Invalid UTF-8 output]" + guard let pluginOutput = String(data: output, encoding: .utf8) else { + throw TestTemplateCommandError.invalidUTF8Encoding(output) + } print(pluginOutput) } genDuration = startGen.distance(to: .now()) genSuccess = true - - if genSuccess { - try FileManager.default.removeItem(atPath: log) - } - + try FileManager.default.removeItem(atPath: log) } catch { genDuration = startGen.distance(to: .now()) genSuccess = false + let generationError = TestTemplateCommandError.generationFailed(error.localizedDescription) + swiftCommandState.observabilityScope.emit(generationError) + let errorLog = destinationAbsolutePath.appending("generation-output.log") logPath = try? self.captureAndWriteError( to: errorLog, @@ -384,6 +418,9 @@ extension SwiftTestCommand { buildDuration = buildStart.distance(to: .now()) buildSuccess = false + let buildError = TestTemplateCommandError.buildFailed(error.localizedDescription) + swiftCommandState.observabilityScope.emit(buildError) + let errorLog = destinationAbsolutePath.appending("build-output.log") logPath = try? self.captureAndWriteError( to: errorLog, @@ -433,30 +470,43 @@ extension SwiftTestCommand { } private func redirectStdoutAndStderr(to path: String) throws -> (originalStdout: Int32, originalStderr: Int32) { +#if os(Windows) + guard let file = _fsopen(path, "w", _SH_DENYWR) else { + throw TestTemplateCommandError.outputRedirectionFailed(path) + } + let originalStdout = _dup(_fileno(stdout)) + let originalStderr = _dup(_fileno(stderr)) + _dup2(_fileno(file), _fileno(stdout)) + _dup2(_fileno(file), _fileno(stderr)) + fclose(file) + return (originalStdout, originalStderr) +#else guard let file = fopen(path, "w") else { - throw NSError( - domain: "RedirectError", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Cannot open file for writing"] - ) + throw TestTemplateCommandError.outputRedirectionFailed(path) } - let originalStdout = dup(STDOUT_FILENO) let originalStderr = dup(STDERR_FILENO) dup2(fileno(file), STDOUT_FILENO) dup2(fileno(file), STDERR_FILENO) fclose(file) return (originalStdout, originalStderr) +#endif } private func restoreStdoutAndStderr(originalStdout: Int32, originalStderr: Int32) { fflush(stdout) fflush(stderr) - +#if os(Windows) + _dup2(originalStdout, _fileno(stdout)) + _dup2(originalStderr, _fileno(stderr)) + _close(originalStdout) + _close(originalStderr) +#else dup2(originalStdout, STDOUT_FILENO) dup2(originalStderr, STDERR_FILENO) close(originalStdout) close(originalStderr) +#endif } enum ShowTestTemplateOutput: String, RawRepresentable, CustomStringConvertible, ExpressibleByArgument, @@ -488,6 +538,44 @@ extension SwiftTestCommand { try container.encodeIfPresent(self.logFilePath, forKey: .logFilePath) } } + + enum TestTemplateCommandError: Error, CustomStringConvertible { + case invalidManifestInTemplate + case templateNotFound(String) + case noTemplatesInManifest + case multipleTemplatesFound([String]) + case directoryCreationFailed(String) + case buildSystemNotSupported(String) + case generationFailed(String) + case buildFailed(String) + case outputRedirectionFailed(String) + case invalidUTF8Encoding(Data) + + var description: String { + switch self { + case .invalidManifestInTemplate: + "Invalid or missing Package.swift manifest found in template. The template must contain a valid Swift package manifest." + case .templateNotFound(let templateName): + "Could not find template '\(templateName)' with packageInit options. Verify the template name and ensure it has proper template configuration." + case .noTemplatesInManifest: + "No templates with packageInit options were found in the manifest. The package must contain at least one target with template initialization options." + case .multipleTemplatesFound(let templates): + "Multiple templates found: \(templates.joined(separator: ", ")). Please specify one using --template-name option." + case .directoryCreationFailed(let path): + "Failed to create output directory at '\(path)'. Check permissions and available disk space." + case .buildSystemNotSupported(let system): + "Build system '\(system)' is not supported for template testing. Use a supported build system." + case .generationFailed(let details): + "Template generation failed: \(details). Check template configuration and input arguments." + case .buildFailed(let details): + "Build failed after template generation: \(details). Check generated code and dependencies." + case .outputRedirectionFailed(let path): + "Failed to redirect output to log file at '\(path)'. Check file permissions and disk space." + case .invalidUTF8Encoding(let data): + "Failed to encode \(data) into UTF-8." + } + } + } } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index e9bbbaab055..c473b54dcdf 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -75,9 +75,16 @@ struct TemplatePackageInitializer: PackageInitializer { self.swiftCommandState.observabilityScope .emit(debug: "Inferring initial type of consumer's package based on template's specifications.") + let resolvedTemplateName: String + if self.templateName == nil { + resolvedTemplateName = try await self.findTemplateName(from: resolvedTemplatePath) + } else { + resolvedTemplateName = self.templateName! + } + let packageType = try await TemplatePackageInitializer.inferPackageType( from: resolvedTemplatePath, - templateName: self.templateName, + templateName: resolvedTemplateName, swiftCommandState: self.swiftCommandState ) @@ -115,7 +122,7 @@ struct TemplatePackageInitializer: PackageInitializer { try await TemplateInitializationPluginManager( swiftCommandState: self.swiftCommandState, - template: self.templateName, + template: resolvedTemplateName, scratchDirectory: stagingPath, args: self.args, buildSystem: buildSystem @@ -145,6 +152,7 @@ struct TemplatePackageInitializer: PackageInitializer { } catch { self.swiftCommandState.observabilityScope.emit(error) + throw error } } @@ -167,7 +175,7 @@ struct TemplatePackageInitializer: PackageInitializer { var targetName = templateName if targetName == nil { - targetName = try self.findTemplateName(from: manifest) + targetName = try TemplatePackageInitializer.findTemplateName(from: manifest) } for target in manifest.targets { @@ -202,6 +210,21 @@ struct TemplatePackageInitializer: PackageInitializer { } } + func findTemplateName(from templatePath: Basics.AbsolutePath) async throws -> String { + try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { workspace, root in + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope + ) + + guard let manifest = rootManifests.values.first else { + throw TemplatePackageInitializerError.invalidManifestInTemplate(root.packages.description) + } + + return try TemplatePackageInitializer.findTemplateName(from: manifest) + } + } + private func setUpPackage( builder: DefaultPackageDependencyBuilder, packageType: InitPackage.PackageType, diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index c7560de0638..66b1bcb69f4 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -36,10 +36,6 @@ protocol DependencyRequirementResolving { /// - `revision`: A commit hash or VCS revision /// - `from` / `upToNextMinorFrom`: Lower bounds for version ranges /// - `to`: An optional upper bound that refines a version range -/// -/// This resolver ensures only one form of versioning input is specified and validates combinations like `to` with -/// `from`. - struct DependencyRequirementResolver: DependencyRequirementResolving { /// Package-id for registry let packageIdentity: String? diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift index 12d462b49d6..afc947f45c6 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -22,7 +22,6 @@ import TSCUtility /// `TemplateBuildSupport` encapsulates the logic needed to initialize the /// SwiftPM build system and perform a build operation based on a specific /// command configuration and workspace context. - enum TemplateBuildSupport { /// Builds a Swift package using the given command state, options, and working directory. /// @@ -58,7 +57,7 @@ enum TemplateBuildSupport { try await swiftCommandState.withTemporaryWorkspace(switchingTo: packageRoot) { _, _ in do { - try await buildSystem.build(subset: subset, buildOutputs: [.buildPlan]) + try await buildSystem.build(subset: subset, buildOutputs: []) } catch { throw ExitCode.failure } @@ -91,8 +90,8 @@ enum TemplateBuildSupport { try await swiftCommandState.withTemporaryWorkspace(switchingTo: testingFolder) { _, _ in do { - try await buildSystem.build(subset: subset, buildOutputs: [.buildPlan]) - } catch let diagnostics as Diagnostics { + try await buildSystem.build(subset: subset, buildOutputs: []) + } catch { throw ExitCode.failure } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index 6a9e34f0a5d..abdd100e7de 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -10,7 +10,6 @@ // //===----------------------------------------------------------------------===// -// TODO: needs review import ArgumentParser import Basics import CoreCommands @@ -25,11 +24,20 @@ import TSCBasic import TSCUtility import Workspace -/// A protocol representing a generic package template fetcher. +/// A protocol representing a generic template fetcher for Swift package templates. /// -/// Conforming types encapsulate the logic to retrieve a template from a given source, -/// such as a local path, Git repository, or registry. The template is expected to be -/// returned as an absolute path to its location on the file system. +/// Conforming types are responsible for retrieving a package template from a specific source, +/// such as a local directory, a Git repository, or a remote registry. The retrieved template +/// must be available on the local file system in order to infer package type. +/// +/// - Note: The returned path is an **absolute file system path** pointing to the **root directory** +/// of the fetched template. This path must reference a fully resolved and locally accessible +/// directory that contains the template's contents, ready for use by any consumer. +/// +/// Example sources might include: +/// - Local file paths (e.g. `/Users/username/Templates/MyTemplate`) +/// - Git repositories, either on disk or by HTTPS or SSH. +/// - Registry-resolved template directories protocol TemplateFetcher { func fetch() async throws -> Basics.AbsolutePath } @@ -145,7 +153,7 @@ struct LocalTemplateFetcher: TemplateFetcher { } } -/// Fetches a Swift package template from a Git repository based on a specified requirement. +/// Fetches a Swift package template from a Git repository based on a specified requirement for initial package type inference. /// /// Supports: /// - Checkout by tag (exact version) @@ -163,18 +171,22 @@ struct GitTemplateFetcher: TemplateFetcher { let requirement: PackageDependency.SourceControl.Requirement let swiftCommandState: SwiftCommandState + /// Fetches the repository and returns the path to the checked-out working copy. /// /// - Returns: A path to the directory containing the fetched template. /// - Throws: Any error encountered during repository fetch, checkout, or validation. - - /// Fetches a bare clone of the Git repository to the specified path. func fetch() async throws -> Basics.AbsolutePath { try await withTemporaryDirectory(removeTreeOnDeinit: false) { tempDir in let bareCopyPath = tempDir.appending(component: "bare-copy") let workingCopyPath = tempDir.appending(component: "working-copy") try await self.cloneBareRepository(into: bareCopyPath) + + defer { + try? FileManager.default.removeItem(at: bareCopyPath.asURL) + } + try self.validateBareRepository(at: bareCopyPath) try FileManager.default.createDirectory( @@ -183,7 +195,6 @@ struct GitTemplateFetcher: TemplateFetcher { ) let repository = try createWorkingCopy(fromBare: bareCopyPath, at: workingCopyPath) - try FileManager.default.removeItem(at: bareCopyPath.asURL) try self.checkout(repository: repository) @@ -201,21 +212,26 @@ struct GitTemplateFetcher: TemplateFetcher { do { try await provider.fetch(repository: repositorySpecifier, to: path) } catch { - if self.isSSHPermissionError(error) { - throw GitTemplateFetcherError.sshAuthenticationRequired(source: self.source) + if self.isPermissionError(error) { + throw GitTemplateFetcherError.authenticationRequired(source: self.source, error: error) } throw GitTemplateFetcherError.cloneFailed(source: self.source) } } - private func isSSHPermissionError(_ error: Error) -> Bool { + /// Function to determine if its a specifc SSHPermssionError + /// + /// - Returns: A boolean determining if it is either a permission error, or not. + private func isPermissionError(_ error: Error) -> Bool { let errorString = String(describing: error).lowercased() - return errorString.contains("permission denied") && - errorString.contains("publickey") && - self.source.hasPrefix("git@") + return errorString.contains("permission denied") } /// Validates that the directory contains a valid Git repository. + /// + /// - Parameters: + /// - path: the path where the git repository is located + /// - Throws: .invalidRepositoryDirectory(path: path) if the path does not contain a valid git directory. private func validateBareRepository(at path: Basics.AbsolutePath) throws { let provider = GitRepositoryProvider() guard try provider.isValidDirectory(path) else { @@ -225,7 +241,7 @@ struct GitTemplateFetcher: TemplateFetcher { /// Creates a working copy from a bare directory. /// - /// - Throws: An error. + /// - Throws: .createWorkingCopyFailed(path: workingCopyPath, underlyingError: error) if the provider failed to create a working copy from a bare repository private func createWorkingCopy( fromBare barePath: Basics.AbsolutePath, at workingCopyPath: Basics.AbsolutePath @@ -304,7 +320,7 @@ struct GitTemplateFetcher: TemplateFetcher { case noMatchingTagFromVersion(String) case invalidVersionRange(lowerBound: String, upperBound: String) case invalidVersion(String) - case sshAuthenticationRequired(source: String) + case authenticationRequired(source: String, error: Error) var errorDescription: String? { switch self { @@ -324,8 +340,8 @@ struct GitTemplateFetcher: TemplateFetcher { "Invalid version range: \(lowerBound)..<\(upperBound)" case .invalidVersion(let version): "Invalid version string: \(version)" - case .sshAuthenticationRequired(let source): - "SSH authentication required for '\(source)'.\nEnsure SSH agent is running and key is loaded:\n\nhttps://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent" + case .authenticationRequired(let source, let error): + "Authentication required for '\(source)'. \(error)" } } @@ -344,20 +360,20 @@ struct GitTemplateFetcher: TemplateFetcher { /// - Exact version /// - Upper bound of a version range (e.g., latest version within a range) struct RegistryTemplateFetcher: TemplateFetcher { + /// The swiftCommandState of the current process. - /// Used to get configurations and authentication needed to get package from registry let swiftCommandState: SwiftCommandState /// The package identifier of the package in registry let packageIdentity: String + /// The registry requirement used to determine which version to fetch. let requirement: PackageDependency.Registry.Requirement - /// Performs the registry fetch by downloading and extracting a source archive. + /// Performs the registry fetch by downloading and extracting a source archive for initial package type inference /// /// - Returns: Absolute path to the extracted template directory. /// - Throws: If registry configuration is invalid or the download fails. - func fetch() async throws -> Basics.AbsolutePath { try await withTemporaryDirectory(removeTreeOnDeinit: false) { tempDir in let config = try Self.getRegistriesConfig(self.swiftCommandState, global: true) @@ -393,23 +409,27 @@ struct RegistryTemplateFetcher: TemplateFetcher { } /// Extract the version from the registry requirements + /// + /// - Throws: .invalidVersionString if the requirement string does not correspond to a valid semver format version. private var version: Version { - switch self.requirement { - case .exact(let versionString): - guard let version = Version(versionString) else { - fatalError("Invalid version string: \(versionString)") - } - return version - case .range(_, let upperBound): - guard let version = Version(upperBound) else { - fatalError("Invalid version string: \(upperBound)") + get throws { + switch self.requirement { + case .exact(let versionString): + guard let version = Version(versionString) else { + throw RegistryConfigError.invalidVersionString(version: versionString) + } + return version + case .range(_, let upperBound): + guard let version = Version(upperBound) else { + throw RegistryConfigError.invalidVersionString(version: upperBound) + } + return version + case .rangeFrom(let versionString): + guard let version = Version(versionString) else { + throw RegistryConfigError.invalidVersionString(version: versionString) + } + return version } - return version - case .rangeFrom(let versionString): - guard let version = Version(versionString) else { - fatalError("Invalid version string: \(versionString)") - } - return version } } @@ -435,11 +455,17 @@ struct RegistryTemplateFetcher: TemplateFetcher { /// Errors that can occur while loading Swift package registry configuration. enum RegistryConfigError: Error, LocalizedError { + /// Indicates the configuration file could not be loaded. case failedToLoadConfiguration(file: Basics.AbsolutePath, underlyingError: Error) + /// Indicates that the conversion from string to Version failed + case invalidVersionString(version: String) + var errorDescription: String? { switch self { + case .invalidVersionString(let version): + "Invalid version string: \(version)" case .failedToLoadConfiguration(let file, let underlyingError): """ Failed to load registry configuration from '\(file.pathString)': \ diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift index fef9661e4d2..b56f8e687ad 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift @@ -19,7 +19,7 @@ struct TemplatePluginCoordinator { let buildSystem: BuildSystemProvider.Kind let swiftCommandState: SwiftCommandState let scratchDirectory: Basics.AbsolutePath - let template: String? + let template: String let args: [String] let branches: [String] diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 15692b817f9..26b607313ce 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -37,11 +37,11 @@ enum TemplatePluginExecutor { /// A utility for obtaining and running a template's plugin . /// -/// `TemplateIntiializationPluginManager` encapsulates the logic needed to fetch, +/// `TemplateInitializationPluginManager` encapsulates the logic needed to fetch, /// and run templates' plugins given arguments, based on the template initialization workflow. struct TemplateInitializationPluginManager: TemplatePluginManager { private let swiftCommandState: SwiftCommandState - private let template: String? + private let template: String private let scratchDirectory: Basics.AbsolutePath private let args: [String] private let packageGraph: ModulesGraph @@ -59,7 +59,7 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { init( swiftCommandState: SwiftCommandState, - template: String?, + template: String, scratchDirectory: Basics.AbsolutePath, args: [String], buildSystem: BuildSystemProvider.Kind @@ -85,13 +85,7 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// Manages the logic of running a template and executing on the information provided by the JSON representation of /// a template's arguments. /// - /// - Throws: - /// - `TemplatePluginError.executionFailed(underlying: error)` If there was an error during the execution of a - /// template's plugin. - /// - `TemplatePluginError.failedToDecodeToolInfo(underlying: error)` If there is a change in representation - /// between the JSON and the current version of the ToolInfoV0 struct - /// - `TemplatePluginError.execu` - + /// - Throws: Any error thrown during the loading of the template plugin, the fetching of the JSON representation of the template's arguments, prompting, or execution of the template func run() async throws { let plugin = try loadTemplatePlugin() let toolInfo = try await coordinator.dumpToolInfo( @@ -133,7 +127,6 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// - Any Errors thrown during the execution of the template's plugin given a 2D of arguments. /// /// - Returns: A data representation of the result of the execution of the template's plugin. - private func runTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { try await TemplatePluginExecutor.execute( plugin: plugin, @@ -146,16 +139,10 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { ) } - /// Loads the plugin that corresponds to the template's name. - /// - /// - Throws: - /// - `TempaltePluginError.noMatchingTemplate(name: String?)` if there are no plugins corresponding to the desired - /// template. - /// - `TemplatePluginError.multipleMatchingTemplates(names: [String]` if the search returns more than one plugin - /// given a desired template + /// Loads the plugin that corresponds to the template's name /// /// - Returns: A data representation of the result of the execution of the template's plugin. - + /// - Throws: Any Errors thrown during the loading of the template's plugin. func loadTemplatePlugin() throws -> ResolvedModule { try self.coordinator.loadTemplatePlugin(from: self.packageGraph) } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift index bef5d84a3a3..11f49f76309 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift @@ -35,7 +35,6 @@ import XCBuildSupport /// /// The plugin must be part of a resolved package graph, and the invocation is handled /// asynchronously through SwiftPM’s plugin infrastructure. - enum TemplatePluginRunner { /// Runs the given plugin target with the specified arguments and environment context. /// @@ -69,7 +68,7 @@ enum TemplatePluginRunner { allowNetworkConnections: [SandboxNetworkPermission] = [], requestPermission: Bool ) async throws -> Data { - let pluginTarget = try castToPlugin(plugin) + let pluginTarget = try getPluginModule(plugin) let pluginsDir = try pluginDirectory(for: plugin.name, in: swiftCommandState) let outputDir = pluginsDir.appending("outputs") let pluginScriptRunner = try swiftCommandState.getPluginScriptRunner(customPluginsDir: pluginsDir) @@ -94,7 +93,7 @@ enum TemplatePluginRunner { let buildParams = try swiftCommandState.toolsBuildParameters let buildSystem = try await swiftCommandState.createBuildSystem( - explicitBuildSystem: buildSystemKind, // FIXME: This should be based on BuildSystemProvider. + explicitBuildSystem: buildSystemKind, cacheBuildManifest: false, productsBuildParameters: swiftCommandState.productsBuildParameters, toolsBuildParameters: buildParams, @@ -173,7 +172,7 @@ enum TemplatePluginRunner { } /// Safely casts a `ResolvedModule` to a `PluginModule`, or throws if invalid. - private static func castToPlugin(_ plugin: ResolvedModule) throws -> PluginModule { + private static func getPluginModule(_ plugin: ResolvedModule) throws -> PluginModule { guard let pluginTarget = plugin.underlying as? PluginModule else { throw InternalError("Expected PluginModule") } diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index 97d79675bcc..ea0c8fae523 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -7,30 +7,96 @@ import PackageGraph import SPMBuildCore import Workspace -/// A utility for obtaining and running a template's plugin . +/// A utility for obtaining and running a template's plugin during testing workflows. /// -/// `TemplateTesterPluginManager` encapsulates the logic needed to fetch, -/// and run templates' plugins given arguments, based on the template initialization workflow. +/// `TemplateTesterPluginManager` encapsulates the logic needed to fetch, load, and execute +/// template plugins with specified arguments. It manages the complete testing workflow including +/// package graph loading, plugin coordination, and command path generation based on user input +/// and branch specifications. +/// +/// ## Overview +/// +/// The template tester manager handles: +/// - Loading and parsing package graphs for template projects +/// - Coordinating template plugin execution through ``TemplatePluginCoordinator`` +/// - Generating command paths based on user arguments and branch filters +/// - Managing the interaction between template plugins and the testing infrastructure +/// +/// ## Usage +/// +/// ```swift +/// let manager = try await TemplateTesterPluginManager( +/// swiftCommandState: commandState, +/// template: "MyTemplate", +/// scratchDirectory: scratchPath, +/// args: ["--name", "TestProject"], +/// branches: ["create", "swift"], +/// buildSystem: .native +/// ) +/// +/// let commandPaths = try await manager.run() +/// let plugin = try manager.loadTemplatePlugin() +/// ``` +/// +/// - Note: This manager is designed specifically for testing workflows and should not be used +/// in production template initialization scenarios. public struct TemplateTesterPluginManager: TemplatePluginManager { + /// The Swift command state containing build configuration and observability scope. private let swiftCommandState: SwiftCommandState + + /// The name of the template to test. If nil, will be auto-detected from the package manifest. private let template: String? + + /// The scratch directory path where temporary testing files are created. private let scratchDirectory: Basics.AbsolutePath + + /// The command line arguments to pass to the template plugin during testing. private let args: [String] + + /// The loaded package graph containing all resolved packages and dependencies. private let packageGraph: ModulesGraph + + /// The branch names used to filter which command paths to generate during testing. private let branches: [String] + + /// The coordinator responsible for managing template plugin operations. private let coordinator: TemplatePluginCoordinator + + /// The build system provider kind to use for building template dependencies. private let buildSystem: BuildSystemProvider.Kind + /// The root package from the loaded package graph. + /// + /// - Returns: The first root package in the package graph. + /// - Precondition: The package graph must contain at least one root package. + /// - Warning: This property will cause a fatal error if no root package is found. private var rootPackage: ResolvedPackage { guard let root = packageGraph.rootPackages.first else { - fatalError("No root package found.") + fatalError("No root package found in the package graph. Ensure the template package is properly configured.") } return root } + /// Initializes a new template tester plugin manager. + /// + /// This initializer performs the complete setup required for template testing, including + /// loading the package graph and setting up the plugin coordinator. + /// + /// - Parameters: + /// - swiftCommandState: The Swift command state containing build configuration and observability. + /// - template: The name of the template to test. If not provided, will be auto-detected. + /// - scratchDirectory: The directory path for temporary testing files. + /// - args: The command line arguments to pass to the template plugin. + /// - branches: The branch names to filter command path generation. + /// - buildSystem: The build system provider to use for compilation. + /// + /// - Throws: + /// - `PackageGraphError` if the package graph cannot be loaded + /// - `FileSystemError` if the scratch directory is invalid + /// - `TemplatePluginError` if the plugin coordinator setup fails init( swiftCommandState: SwiftCommandState, - template: String?, + template: String, scratchDirectory: Basics.AbsolutePath, args: [String], branches: [String], @@ -55,6 +121,27 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { self.buildSystem = buildSystem } + /// Executes the template testing workflow and generates command paths. + /// + /// This method performs the complete testing workflow: + /// 1. Loads the template plugin from the package graph + /// 2. Dumps tool information to understand available commands and arguments + /// 3. Prompts the user for template arguments based on the tool info + /// 4. Generates command paths for testing different argument combinations + /// + /// - Returns: An array of ``CommandPath`` objects representing different command execution paths. + /// - Throws: + /// - `TemplatePluginError` if the plugin cannot be loaded + /// - `ToolInfoError` if tool information cannot be extracted + /// - `TemplateError` if argument prompting fails + /// + /// ## Example + /// ```swift + /// let paths = try await manager.run() + /// for path in paths { + /// print(path.displayFormat()) + /// } + /// ``` func run() async throws -> [CommandPath] { let plugin = try coordinator.loadTemplatePlugin(from: self.packageGraph) let toolInfo = try await coordinator.dumpToolInfo( @@ -66,6 +153,14 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { return try self.promptUserForTemplateArguments(using: toolInfo) } + /// Prompts the user for template arguments and generates command paths. + /// + /// Creates a ``TemplateTestPromptingSystem`` instance and uses it to generate + /// command paths based on the provided tool information and user arguments. + /// + /// - Parameter toolInfo: The tool information extracted from the template plugin. + /// - Returns: An array of ``CommandPath`` representing different argument combinations. + /// - Throws: `TemplateError` if argument parsing or command path generation fails. private func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [CommandPath] { try TemplateTestPromptingSystem(hasTTY: self.swiftCommandState.outputStream.isTTY).generateCommandPaths( rootCommand: toolInfo.command, @@ -74,22 +169,90 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { ) } + /// Loads the template plugin module from the package graph. + /// + /// This method delegates to the ``TemplatePluginCoordinator`` to load the actual + /// plugin module that can be executed during template testing. + /// + /// - Returns: A ``ResolvedModule`` representing the loaded template plugin. + /// - Throws: `TemplatePluginError` if the plugin cannot be found or loaded. + /// + /// - Note: This method should be called after the package graph has been successfully loaded. public func loadTemplatePlugin() throws -> ResolvedModule { try self.coordinator.loadTemplatePlugin(from: self.packageGraph) } } +/// Represents a complete command execution path for template testing. +/// +/// A `CommandPath` encapsulates a sequence of commands and their arguments that form +/// a complete execution path through a template's command structure. This is used during +/// template testing to represent different ways the template can be invoked. +/// +/// ## Properties +/// +/// - ``fullPathKey``: A string identifier for the command path, typically formed by joining command names +/// - ``commandChain``: An ordered sequence of ``CommandComponent`` representing the command hierarchy +/// +/// ## Usage +/// +/// ```swift +/// let path = CommandPath( +/// fullPathKey: "init-swift-executable", +/// commandChain: [rootCommand, initCommand, swiftCommand, executableCommand] +/// ) +/// print(path.displayFormat()) +/// ``` public struct CommandPath { + /// The unique identifier for this command path, typically formed by joining command names with hyphens. public let fullPathKey: String + + /// The ordered sequence of command components that make up this execution path. public let commandChain: [CommandComponent] } +/// Represents a single command component within a command execution path. +/// +/// A `CommandComponent` contains a command name and its associated arguments. +/// Multiple components are chained together to form a complete ``CommandPath``. +/// +/// ## Properties +/// +/// - ``commandName``: The name of the command (e.g., "init", "swift", "executable") +/// - ``arguments``: The arguments and their values for this specific command level +/// +/// ## Example +/// +/// ```swift +/// let component = CommandComponent( +/// commandName: "init", +/// arguments: [nameArgument, typeArgument] +/// ) +/// ``` public struct CommandComponent { + /// The name of this command component. let commandName: String + + /// The arguments associated with this command component. let arguments: [TemplateTestPromptingSystem.ArgumentResponse] } extension CommandPath { + /// Formats the command path for display purposes. + /// + /// Creates a human-readable representation of the command path, including: + /// - The complete command path hierarchy + /// - The flat execution format suitable for command-line usage + /// + /// - Returns: A formatted string representation of the command path. + /// + /// ## Example Output + /// ``` + /// Command Path: init swift executable + /// Execution Format: + /// + /// init --name MyProject swift executable --target-name MyTarget + /// ``` func displayFormat() -> String { let commandNames = self.commandChain.map(\.commandName) let fullPath = commandNames.joined(separator: " ") @@ -103,6 +266,19 @@ extension CommandPath { return result } + /// Builds a flat command representation suitable for command-line execution. + /// + /// Flattens the command chain into a single array of strings that can be executed + /// as a command-line invocation. Skips the root command name and includes all + /// subcommands and their arguments in the correct order. + /// + /// - Returns: A space-separated string representing the complete command line. + /// + /// ## Format + /// The returned format follows the pattern: + /// `[subcommand1] [args1] [subcommand2] [args2] ...` + /// + /// The root command name is omitted as it's typically the executable name. private func buildFlatCommandDisplay() -> String { var result: [String] = [] @@ -120,6 +296,22 @@ extension CommandPath { return result.joined(separator: " ") } + /// Formats argument responses for command-line display. + /// + /// Takes an array of argument responses and formats them as command-line arguments + /// with proper flag and option syntax. + /// + /// - Parameter argumentResponses: The argument responses to format. + /// - Returns: A formatted string with each argument on a separate line, suitable for multi-line display. + /// + /// ## Example Output + /// ``` + /// --name ProjectName \ + /// --type executable \ + /// --target-name MainTarget + /// ``` + /// + /// - Note: This method is currently unused but preserved for potential future display formatting needs. private func formatArguments(_ argumentResponses: [Commands.TemplateTestPromptingSystem.ArgumentResponse] ) -> String { @@ -136,41 +328,76 @@ extension CommandPath { } } +/// A system for prompting users and generating command paths during template testing. +/// +/// `TemplateTestPromptingSystem` handles the complex logic of parsing user input, +/// prompting for missing arguments, and generating all possible command execution paths +/// based on template tool information. +/// +/// ## Key Features +/// +/// - **Argument Parsing**: Supports flags, options, and positional arguments with various parsing strategies +/// - **Interactive Prompting**: Prompts users for missing required arguments when a TTY is available +/// - **Command Path Generation**: Uses depth-first search to generate all valid command combinations +/// - **Branch Filtering**: Supports filtering command paths based on specified branch names +/// - **Validation**: Validates argument values against allowed value sets and completion kinds +/// +/// ## Usage +/// +/// ```swift +/// let promptingSystem = TemplateTestPromptingSystem(hasTTY: true) +/// let commandPaths = try promptingSystem.generateCommandPaths( +/// rootCommand: toolInfo.command, +/// args: userArgs, +/// branches: ["init", "swift"] +/// ) +/// ``` +/// +/// ## Argument Parsing Strategies +/// +/// The system supports various parsing strategies defined in `ArgumentParserToolInfo`: +/// - `.default`: Standard argument parsing +/// - `.scanningForValue`: Scans for values while allowing defaults +/// - `.unconditional`: Always consumes the next token as a value +/// - `.upToNextOption`: Consumes tokens until the next option is encountered +/// - `.allRemainingInput`: Consumes all remaining input tokens +/// - `.postTerminator`: Handles arguments after a `--` terminator +/// - `.allUnrecognized`: Captures unrecognized arguments public class TemplateTestPromptingSystem { + /// Indicates whether a TTY (terminal) is available for interactive prompting. + /// + /// When `true`, the system can prompt users interactively for missing arguments. + /// When `false`, the system relies on default values and may throw errors for required arguments. private let hasTTY: Bool + /// Initializes a new template test prompting system. + /// + /// - Parameter hasTTY: Whether interactive terminal prompting is available. Defaults to `true`. public init(hasTTY: Bool = true) { self.hasTTY = hasTTY } - /// Prompts the user for input based on the given command definition and arguments. - /// - /// This method collects responses for a command's arguments by first validating any user-provided - /// arguments (`arguments`) against the command's defined parameters. Any required arguments that are - /// missing will be interactively prompted from the user. + /// Parses and matches provided arguments against defined argument specifications. /// - /// If the command has subcommands, the method will attempt to detect a subcommand from any leftover - /// arguments. If no subcommand is found, the user is interactively prompted to select one. This process - /// is recursive: each subcommand is treated as a new command and processed accordingly. - /// - /// When building each CLI command line, only arguments defined for the current command level are included— - /// inherited arguments from previous levels are excluded to avoid duplication. + /// This method performs comprehensive argument parsing, handling: + /// - Named arguments (flags and options starting with `--`) + /// - Positional arguments in their defined order + /// - Special parsing strategies like post-terminator and all-remaining-input + /// - Argument validation against allowed value sets /// /// - Parameters: - /// - command: The top-level or current `CommandInfoV0` to prompt for. - /// - arguments: The list of pre-supplied command-line arguments to match against defined arguments. - /// - subcommandTrail: An internal list of command names to build the final CLI path (used recursively). - /// - inheritedResponses: Argument responses collected from parent commands that should be passed down. + /// - input: The input arguments to parse + /// - definedArgs: The argument definitions from the template tool info + /// - subcommands: Available subcommands for context during parsing /// - /// - Returns: A list of command paths, each representing a full CLI command path with arguments. + /// - Returns: A tuple containing: + /// - `Set`: Successfully parsed and matched arguments + /// - `[String]`: Leftover arguments that couldn't be matched (potentially for subcommands) /// - /// - Throws: An error if argument parsing or user prompting fails. - - // resolve arguments at this level - // append arguments to the current path - // if subcommands exist, then for each subcommand, pass the function again, where we deepCopy a path - // if not, then jointhe command names of all the paths, and append CommandPath() - + /// - Throws: + /// - `TemplateError.unexpectedNamedArgument` for unknown named arguments + /// - `TemplateError.invalidValue` for arguments with invalid values + /// - `TemplateError.missingValueForOption` for options missing required values private func parseAndMatchArguments( _ input: [String], definedArgs: [ArgumentInfoV0], @@ -303,7 +530,6 @@ public class TemplateTestPromptingSystem { // Phase 3: Handle special parsing strategies for arg in definedArgs { - let argName = arg.valueName ?? "__unknown" switch arg.parsingStrategy { case .postTerminator: if terminatorSeen { @@ -352,6 +578,30 @@ public class TemplateTestPromptingSystem { return (responses, leftover) } + /// Parses option values based on the argument's parsing strategy. + /// + /// This helper method handles the complexity of parsing option values according to + /// different parsing strategies defined in the argument specification. + /// + /// - Parameters: + /// - arg: The argument definition containing parsing strategy and validation rules + /// - tokens: The remaining input tokens (modified in-place as tokens are consumed) + /// - currentIndex: The current position in the tokens array (modified in-place) + /// + /// - Returns: An array of parsed values for the option + /// + /// - Throws: + /// - `TemplateError.missingValueForOption` when required values are missing + /// - `TemplateError.invalidValue` when values don't match allowed value constraints + /// + /// ## Supported Parsing Strategies + /// + /// - **Default**: Expects the next token to be a value + /// - **Scanning for Value**: Scans for a value, allowing defaults if none found + /// - **Unconditional**: Always consumes the next token regardless of its format + /// - **Up to Next Option**: Consumes tokens until another option is encountered + /// - **All Remaining Input**: Consumes all remaining tokens + /// - **Post Terminator/All Unrecognized**: Handled separately in main parsing logic /// Helper method to parse option values based on parsing strategy private func parseOptionValues( arg: ArgumentInfoV0, @@ -432,6 +682,30 @@ public class TemplateTestPromptingSystem { return values } + /// Generates all possible command paths for template testing. + /// + /// This is the main entry point for command path generation. It uses a depth-first search + /// algorithm to explore all possible command combinations, respecting branch filters and + /// argument inheritance between command levels. + /// + /// - Parameters: + /// - rootCommand: The root command information from the template tool info + /// - args: Predefined arguments provided by the user + /// - branches: Branch names to filter which command paths are generated + /// + /// - Returns: An array of ``CommandPath`` representing all valid command execution paths + /// + /// - Throws: `TemplateError` if argument parsing, validation, or prompting fails + /// + /// ## Branch Filtering + /// + /// When branches are specified, only command paths that match the branch hierarchy will be generated. + /// For example, if branches are `["init", "swift"]`, only paths like `init swift executable` + /// or `init swift library` will be included. + /// + /// ## Output + /// + /// This method also prints the display format of each generated command path for debugging purposes. public func generateCommandPaths( rootCommand: CommandInfoV0, args: [String], @@ -458,6 +732,36 @@ public class TemplateTestPromptingSystem { return paths } + /// Performs depth-first search with argument inheritance to generate command paths. + /// + /// This recursive method explores the command tree, handling argument inheritance between + /// parent and child commands, and generating complete command paths for testing. + /// + /// - Parameters: + /// - command: The current command being processed + /// - path: The current command path being built + /// - visitedArgs: Arguments that have been processed (modified in-place) + /// - inheritedResponses: Arguments inherited from parent commands (modified in-place) + /// - paths: The collection of completed command paths (modified in-place) + /// - predefinedArgs: User-provided arguments to parse and apply + /// - branches: Branch filter to limit which subcommands are explored + /// - branchDepth: Current depth in the branch hierarchy for filtering + /// + /// - Throws: `TemplateError` if argument processing fails at any level + /// + /// ## Algorithm + /// + /// 1. **Parse Arguments**: Parse predefined arguments against current command's argument definitions + /// 2. **Inherit Arguments**: Combine parsed arguments with inherited arguments from parent commands + /// 3. **Prompt for Missing**: Prompt user for any missing required arguments + /// 4. **Create Component**: Build a command component with resolved arguments + /// 5. **Process Subcommands**: Recursively process subcommands or add leaf paths to results + /// + /// ## Argument Inheritance + /// + /// Arguments defined at parent command levels are inherited by child commands unless + /// overridden. This allows for flexible command structures where common arguments + /// can be specified at higher levels. func dfsWithInheritance( command: CommandInfoV0, path: [CommandComponent], @@ -588,15 +892,30 @@ public class TemplateTestPromptingSystem { } } - /// Retrieves the list of subcommands for a given command, excluding common utility commands. + /// Retrieves the list of subcommands for a given command, excluding utility commands. /// - /// This method checks whether the given command contains any subcommands. If so, it filters - /// out the `"help"` subcommand (often auto-generated or reserved), and returns the remaining - /// subcommands. + /// This method filters out common utility commands like "help" that are typically + /// auto-generated and not relevant for template testing scenarios. /// - /// - Parameter command: The `CommandInfoV0` instance representing the current command. + /// - Parameter command: The command to extract subcommands from /// - /// - Returns: An array of `CommandInfoV0` representing valid subcommands, or `nil` if no subcommands exist. + /// - Returns: An array of valid subcommands, or `nil` if no subcommands exist + /// + /// ## Filtering Rules + /// + /// - Excludes commands named "help" (case-insensitive) + /// - Returns `nil` if no subcommands remain after filtering + /// - Preserves the original order of subcommands + /// + /// ## Usage + /// + /// ```swift + /// if let subcommands = getSubCommand(from: command) { + /// for subcommand in subcommands { + /// // Process each subcommand + /// } + /// } + /// ``` func getSubCommand(from command: CommandInfoV0) -> [CommandInfoV0]? { guard let subcommands = command.subcommands else { return nil } @@ -607,12 +926,25 @@ public class TemplateTestPromptingSystem { return filteredSubcommands } - /// Converts the command information into an array of argument metadata. + /// Converts command information into an array of argument metadata. /// - /// - Parameter command: The command info object. - /// - Returns: An array of argument info objects. - /// - Throws: `TemplateError.noArguments` if the command has no arguments. - + /// Extracts and returns the argument definitions from a command, which are used + /// for parsing user input and generating prompts. + /// + /// - Parameter command: The command information object containing argument definitions + /// + /// - Returns: An array of ``ArgumentInfoV0`` objects representing the command's arguments + /// + /// - Throws: ``TemplateError.noArguments`` if the command has no argument definitions + /// + /// ## Usage + /// + /// ```swift + /// let arguments = try convertArguments(from: command) + /// for arg in arguments { + /// print("Argument: \(arg.valueName ?? "unknown")") + /// } + /// ``` func convertArguments(from command: CommandInfoV0) throws -> [ArgumentInfoV0] { guard let rawArgs = command.arguments else { throw TemplateError.noArguments @@ -620,14 +952,68 @@ public class TemplateTestPromptingSystem { return rawArgs } - /// A helper struct to prompt the user for input values for command arguments. - + /// A utility for prompting users for command argument values. + /// + /// `UserPrompter` provides static methods for interactively prompting users to provide + /// values for command arguments when they haven't been specified via command-line arguments. + /// It handles different argument types (flags, options, positional) and supports both + /// interactive (TTY) and non-interactive modes. + /// + /// ## Features + /// + /// - **Interactive Prompting**: Prompts users when a TTY is available + /// - **Default Value Handling**: Uses default values when provided and no user input given + /// - **Value Validation**: Validates input against allowed value constraints + /// - **Completion Hints**: Provides completion suggestions based on argument metadata + /// - **Explicit Unset Support**: Allows users to explicitly unset optional arguments with "nil" + /// - **Repeating Arguments**: Supports prompting for multiple values for repeating arguments + /// + /// ## Argument Types + /// + /// - **Flags**: Boolean arguments prompted with yes/no confirmation + /// - **Options**: String arguments with optional value validation + /// - **Positional**: Arguments that don't use flag syntax public enum UserPrompter { - /// Prompts the user for input for each argument, handling flags, options, and positional arguments. + /// Prompts users for values for missing command arguments. /// - /// - Parameter arguments: The list of argument metadata to prompt for. - /// - Returns: An array of `ArgumentResponse` representing the user's input. - + /// This method handles the interactive prompting workflow for arguments that weren't + /// provided via command-line input. It supports different argument types and provides + /// appropriate prompts based on the argument's metadata. + /// + /// - Parameters: + /// - arguments: The argument definitions to prompt for + /// - collected: A dictionary to track previously collected argument responses (modified in-place) + /// - hasTTY: Whether interactive terminal prompting is available + /// + /// - Returns: An array of ``ArgumentResponse`` objects containing user input + /// + /// - Throws: + /// - ``TemplateError.missingRequiredArgumentWithoutTTY`` for required arguments when no TTY is available + /// - ``TemplateError.invalidValue`` for values that don't match validation constraints + /// + /// ## Prompting Behavior + /// + /// ### With TTY (Interactive Mode) + /// - Displays descriptive prompts with available options and defaults + /// - Supports completion hints and value validation + /// - Allows "nil" input to explicitly unset optional arguments + /// - Handles repeating arguments by accepting multiple lines of input + /// + /// ### Without TTY (Non-Interactive Mode) + /// - Uses default values when available + /// - Throws errors for required arguments without defaults + /// - Validates any provided values against constraints + /// + /// ## Example Usage + /// + /// ```swift + /// var collected: [String: ArgumentResponse] = [:] + /// let responses = try UserPrompter.prompt( + /// for: missingArguments, + /// collected: &collected, + /// hasTTY: true + /// ) + /// ``` public static func prompt( for arguments: [ArgumentInfoV0], collected: inout [String: ArgumentResponse], @@ -772,7 +1158,30 @@ public class TemplateTestPromptingSystem { } } - /// Generates completion hint text based on CompletionKindV0 + /// Generates completion hint text based on the argument's completion kind. + /// + /// Creates user-friendly text describing available completion options for an argument. + /// This helps users understand what values are expected or available. + /// + /// - Parameter arg: The argument definition containing completion information + /// + /// - Returns: A formatted hint string, or empty string if no completion info is available + /// + /// ## Completion Types + /// + /// - **List**: Shows available predefined values + /// - **File**: Indicates file completion with optional extension filters + /// - **Directory**: Indicates directory path completion + /// - **Shell Command**: Shows the shell command used for completion + /// - **Custom**: Indicates custom completion is available + /// + /// ## Example Output + /// + /// ``` + /// " (suggestions: swift, objc, cpp)" + /// " (file completion: .swift, .h)" + /// " (directory completion available)" + /// ``` private static func generateCompletionHint(for arg: ArgumentInfoV0) -> String { guard let completionKind = arg.completionKind else { return "" } @@ -796,7 +1205,27 @@ public class TemplateTestPromptingSystem { } } - /// Generates completion suggestions based on input and CompletionKindV0 + /// Generates completion suggestions based on user input and argument metadata. + /// + /// Provides intelligent completion suggestions by filtering available options + /// based on the user's partial input. + /// + /// - Parameters: + /// - arg: The argument definition containing completion information + /// - input: The user's partial input to match against + /// + /// - Returns: A formatted string with matching suggestions, or a message indicating no matches + /// + /// ## Behavior + /// + /// - **List Completion**: Filters list values that start with the input + /// - **Other Types**: Defers to system completion mechanisms + /// - **No Matches**: Returns "No matching suggestions" + /// + /// ## Example + /// + /// For input "sw" with available values ["swift", "swiftui", "objc"]: + /// Returns: "swift, swiftui" private static func generateCompletionSuggestions(for arg: ArgumentInfoV0, input: String) -> String { guard let completionKind = arg.completionKind else { return "No completions available" @@ -812,15 +1241,37 @@ public class TemplateTestPromptingSystem { } } - /// Prompts the user for a yes/no confirmation. + /// Prompts the user for a yes/no confirmation with support for default values and explicit unset. + /// + /// This method handles boolean flag prompting with sophisticated default value handling + /// and support for explicitly unsetting optional flags. /// /// - Parameters: - /// - prompt: The prompt message to display. - /// - defaultBehavior: The default value if the user provides no input. - /// - isOptional: Whether the argument is optional and can be explicitly unset. - /// - Returns: `true` if the user confirmed, `false` if denied, `nil` if explicitly unset. - /// - Throws: TemplateError if required argument missing without TTY - + /// - prompt: The message to display to the user + /// - defaultBehavior: The default value to use if no input is provided + /// - isOptional: Whether the flag can be explicitly unset with "nil" + /// + /// - Returns: + /// - `true` if the user confirmed (y/yes) + /// - `false` if the user denied (n/no) + /// - `nil` if the flag was explicitly unset (only for optional flags) + /// + /// - Throws: ``TemplateError.missingRequiredArgumentWithoutTTY`` for required flags without defaults + /// + /// ## Input Handling + /// + /// - **"y", "yes"**: Returns `true` + /// - **"n", "no"**: Returns `false` + /// - **"nil"**: Returns `nil` (only for optional flags) + /// - **Empty input**: Uses default behavior or `nil` for optional flags + /// - **Invalid input**: Uses default behavior or `nil` for optional flags + /// + /// ## Prompt Format + /// + /// - With default true: "Prompt message [Y/n]" + /// - With default false: "Prompt message [y/N]" + /// - No default: "Prompt message [y/n]" + /// - Optional: Appends " or enter 'nil' to unset." private static func promptForConfirmation( prompt: String, defaultBehavior: Bool?, @@ -870,19 +1321,89 @@ public class TemplateTestPromptingSystem { } } - /// Represents a user's response to an argument prompt. - + /// Represents a user's response to an argument prompt during template testing. + /// + /// `ArgumentResponse` encapsulates the user's input for a specific argument, + /// including the argument metadata, provided values, and whether the argument + /// was explicitly unset. + /// + /// ## Properties + /// + /// - ``argument``: The original argument definition from the template tool info + /// - ``values``: The string values provided by the user + /// - ``isExplicitlyUnset``: Whether the user explicitly chose to unset this optional argument + /// + /// ## Command Line Generation + /// + /// The ``commandLineFragments`` property converts the response into command-line arguments: + /// - **Flags**: Generate `--flag-name` if true, nothing if false + /// - **Options**: Generate `--option-name value` pairs + /// - **Positional**: Generate just the values without flag syntax + /// - **Explicitly Unset**: Generate no fragments + /// + /// ## Example + /// + /// ```swift + /// let response = ArgumentResponse( + /// argument: nameArgument, + /// values: ["MyProject"], + /// isExplicitlyUnset: false + /// ) + /// // commandLineFragments: ["--name", "MyProject"] + /// ``` public struct ArgumentResponse: Hashable { - /// The argument metadata. + /// The argument metadata from the template tool information. let argument: ArgumentInfoV0 - /// The values provided by the user. + /// The string values provided by the user for this argument. + /// + /// - For flags: Contains "true" or "false" + /// - For options: Contains the option value(s) + /// - For positional arguments: Contains the positional value(s) + /// - For repeating arguments: May contain multiple values public let values: [String] - /// Whether the argument was explicitly unset (nil) by the user. + /// Indicates whether the user explicitly chose to unset this optional argument. + /// + /// When `true`, this argument will not generate any command-line fragments, + /// effectively removing it from the final command invocation. public let isExplicitlyUnset: Bool - /// Returns the command line fragments representing this argument and its values. + /// Converts the argument response into command-line fragments. + /// + /// Generates the appropriate command-line representation based on the argument type: + /// + /// - **Flags**: + /// - Returns `["--flag-name"]` if the value is "true" + /// - Returns `[]` if the value is "false" or explicitly unset + /// + /// - **Options**: + /// - Returns `["--option-name", "value"]` for single values + /// - Returns `["--option-name", "value1", "--option-name", "value2"]` for repeating options + /// + /// - **Positional Arguments**: + /// - Returns the values directly without any flag syntax + /// + /// - **Explicitly Unset**: + /// - Returns `[]` regardless of argument type + /// + /// - Returns: An array of strings representing command-line arguments + /// + /// ## Example Output + /// + /// ```swift + /// // Flag argument (true) + /// ["--verbose"] + /// + /// // Option argument + /// ["--name", "MyProject"] + /// + /// // Repeating option + /// ["--target", "App", "--target", "Tests"] + /// + /// // Positional argument + /// ["executable"] + /// ``` public var commandLineFragments: [String] { // If explicitly unset, don't generate any command line fragments guard !self.isExplicitlyUnset else { return [] } @@ -905,45 +1426,144 @@ public class TemplateTestPromptingSystem { } } - /// Initialize with explicit unset state + /// Initializes a new argument response. + /// + /// - Parameters: + /// - argument: The argument definition this response corresponds to + /// - values: The values provided by the user + /// - isExplicitlyUnset: Whether the argument was explicitly unset (defaults to `false`) public init(argument: ArgumentInfoV0, values: [String], isExplicitlyUnset: Bool = false) { self.argument = argument self.values = values self.isExplicitlyUnset = isExplicitlyUnset } + /// Computes the hash value for the argument response. + /// + /// Hash computation is based solely on the argument's value name to ensure + /// that responses for the same argument are considered equivalent. + /// + /// - Parameter hasher: The hasher to use for combining hash values public func hash(into hasher: inout Hasher) { hasher.combine(self.argument.valueName) } + /// Determines equality between two argument responses. + /// + /// Two responses are considered equal if they correspond to the same argument, + /// as determined by comparing their value names. + /// + /// - Parameters: + /// - lhs: The left-hand side argument response + /// - rhs: The right-hand side argument response + /// + /// - Returns: `true` if both responses are for the same argument, `false` otherwise public static func == (lhs: ArgumentResponse, rhs: ArgumentResponse) -> Bool { lhs.argument.valueName == rhs.argument.valueName } } } -/// An error enum representing various template-related errors. +/// Errors that can occur during template testing and argument processing. +/// +/// `TemplateError` provides comprehensive error handling for various failure scenarios +/// that can occur during template testing, argument parsing, and user interaction. +/// +/// ## Error Categories +/// +/// ### File System Errors +/// - ``invalidPath``: Invalid or non-existent file paths +/// - ``manifestAlreadyExists``: Conflicts with existing manifest files +/// +/// ### Argument Processing Errors +/// - ``noArguments``: Template has no argument definitions +/// - ``invalidArgument(name:)``: Invalid argument names or definitions +/// - ``unexpectedArgument(name:)``: Unexpected arguments in input +/// - ``unexpectedNamedArgument(name:)``: Unexpected named arguments +/// - ``missingValueForOption(name:)``: Required option values missing +/// - ``invalidValue(argument:invalidValues:allowed:)``: Values that don't match constraints +/// +/// ### Command Structure Errors +/// - ``unexpectedSubcommand(name:)``: Invalid subcommand usage +/// +/// ### Interactive Mode Errors +/// - ``missingRequiredArgumentWithoutTTY(name:)``: Required arguments in non-interactive mode +/// - ``noTTYForSubcommandSelection``: Subcommand selection requires interactive mode +/// +/// ## Usage +/// +/// ```swift +/// do { +/// let responses = try parseArguments(input) +/// } catch TemplateError.invalidValue(let arg, let invalid, let allowed) { +/// print("Invalid value for \(arg): \(invalid). Allowed: \(allowed)") +/// } +/// ``` private enum TemplateError: Swift.Error { - /// The provided path is invalid or does not exist. + /// The provided file path is invalid or does not exist. case invalidPath - /// A manifest file already exists in the target directory. + /// A Package.swift manifest file already exists in the target directory. case manifestAlreadyExists - /// The template has no arguments to prompt for. + /// The template has no argument definitions to process. case noArguments + + /// An argument name is invalid or malformed. + /// - Parameter name: The invalid argument name case invalidArgument(name: String) + + /// An unexpected argument was encountered during parsing. + /// - Parameter name: The unexpected argument name case unexpectedArgument(name: String) + + /// An unexpected named argument (starting with --) was encountered. + /// - Parameter name: The unexpected named argument case unexpectedNamedArgument(name: String) + + /// A required value for an option argument is missing. + /// - Parameter name: The option name missing its value case missingValueForOption(name: String) + + /// One or more values don't match the argument's allowed value constraints. + /// - Parameters: + /// - argument: The argument name with invalid values + /// - invalidValues: The invalid values that were provided + /// - allowed: The list of allowed values for this argument case invalidValue(argument: String, invalidValues: [String], allowed: [String]) + + /// An unexpected subcommand was provided in the arguments. + /// - Parameter name: The unexpected subcommand name case unexpectedSubcommand(name: String) + + /// A required argument is missing and no interactive terminal is available for prompting. + /// - Parameter name: The name of the missing required argument case missingRequiredArgumentWithoutTTY(name: String) + + /// Subcommand selection requires an interactive terminal but none is available. case noTTYForSubcommandSelection } extension TemplateError: CustomStringConvertible { - /// A readable description of the error + /// A human-readable description of the template error. + /// + /// Provides clear, actionable error messages that help users understand + /// what went wrong and how to fix the issue. + /// + /// ## Error Message Format + /// + /// Each error type provides a descriptive message: + /// - **File system errors**: Explain path or file conflicts + /// - **Argument errors**: Detail specific validation failures with context + /// - **Interactive errors**: Explain TTY requirements and alternatives + /// + /// ## Example Messages + /// + /// ``` + /// "Invalid value for --type. Valid values are: executable, library. Also, xyz is not valid." + /// "Required argument 'name' not provided and no interactive terminal available" + /// "Invalid subcommand 'build' provided in arguments, arguments only accepts flags, options, or positional arguments. Subcommands are treated via the --branch option" + /// ``` var description: String { switch self { case .manifestAlreadyExists: diff --git a/Sources/CoreCommands/SwiftCommandState.swift b/Sources/CoreCommands/SwiftCommandState.swift index 2b4ca2b66ae..94c98322680 100644 --- a/Sources/CoreCommands/SwiftCommandState.swift +++ b/Sources/CoreCommands/SwiftCommandState.swift @@ -1314,7 +1314,6 @@ extension Basics.Diagnostic { } extension SwiftCommandState { - public func withTemporaryWorkspace( switchingTo packagePath: AbsolutePath, createPackagePath: Bool = true, @@ -1353,7 +1352,6 @@ extension SwiftCommandState { } catch { self.scratchDirectory = (packageRoot ?? cwd).appending(component: ".build") } - } self._workspace = originalWorkspace @@ -1363,17 +1361,14 @@ extension SwiftCommandState { // Set up new context self.packageRoot = findPackageRoot(fileSystem: self.fileSystem) - if let cwd = self.fileSystem.currentWorkingDirectory { - self.scratchDirectory = try BuildSystemUtilities.getEnvBuildPath(workingDir: cwd) ?? (packageRoot ?? cwd).appending(".build") - + self.scratchDirectory = try BuildSystemUtilities + .getEnvBuildPath(workingDir: cwd) ?? (self.packageRoot ?? cwd).appending(".build") } - let tempWorkspace = try self.getActiveWorkspace() let tempRoot = try self.getWorkspaceRoot() return try await perform(tempWorkspace, tempRoot) } } - diff --git a/Sources/PackageDescription/Product.swift b/Sources/PackageDescription/Product.swift index 34239fe0c6b..79e48f2a79b 100644 --- a/Sources/PackageDescription/Product.swift +++ b/Sources/PackageDescription/Product.swift @@ -196,7 +196,7 @@ public class Product { } public extension [Product] { - @available(_PackageDescription, introduced: 999.0.0) + @available(_PackageDescription, introduced: 6.3.0) static func template( name: String, ) -> [Product] { diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index 54e22b32f75..e848de18ab5 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -1271,7 +1271,7 @@ public final class Target { } public extension [Target] { - @available(_PackageDescription, introduced: 999.0.0) + @available(_PackageDescription, introduced: 6.3.0) static func template( name: String, dependencies: [Target.Dependency] = [], @@ -1290,11 +1290,8 @@ public extension [Target] { templatePermissions: [TemplatePermissions]? = nil, description: String ) -> [Target] { - let templatePluginName = "\(name)Plugin" let templateExecutableName = "\(name)" - - let permissions: [PluginPermission] = { return templatePermissions?.compactMap { permission in switch permission { @@ -1318,7 +1315,6 @@ public extension [Target] { } ?? [] }() - let templateInitializationOptions = Target.TemplateInitializationOptions.packageInit( templateType: initialPackageType, templatePermissions: templatePermissions, @@ -1686,7 +1682,7 @@ extension Target.PluginUsage: ExpressibleByStringLiteral { /// The type of permission a plug-in requires. /// /// Supported types are ``allowNetworkConnections(scope:reason:)`` and ``writeToPackageDirectory(reason:)``. -@available(_PackageDescription, introduced: 999.0) +@available(_PackageDescription, introduced: 6.3.0) public enum TemplatePermissions { /// Create a permission to make network connections. /// @@ -1694,7 +1690,7 @@ public enum TemplatePermissions { /// to the user at the time of request for approval, explaining why the plug-in is requesting access. /// - Parameter scope: The scope of the permission. /// - Parameter reason: A reason why the permission is needed. This is shown to the user when permission is sought. - @available(_PackageDescription, introduced: 999.0) + @available(_PackageDescription, introduced: 6.3.0) case allowNetworkConnections(scope: TemplateNetworkPermissionScope, reason: String) } @@ -1702,7 +1698,7 @@ public enum TemplatePermissions { /// The scope of a network permission. /// /// The scope can be none, local connections only, or all connections. -@available(_PackageDescription, introduced: 999.0) +@available(_PackageDescription, introduced: 6.3.0) public enum TemplateNetworkPermissionScope { /// Do not allow network access. case none diff --git a/Sources/PackageLoading/PackageBuilder.swift b/Sources/PackageLoading/PackageBuilder.swift index f1bf51de052..2de6413748b 100644 --- a/Sources/PackageLoading/PackageBuilder.swift +++ b/Sources/PackageLoading/PackageBuilder.swift @@ -113,7 +113,7 @@ extension ModuleError: CustomStringConvertible { let packages = packages.map(\.description).sorted().joined(separator: "', '") return "multiple packages ('\(packages)') declare targets with a conflicting name: '\(target)’; target names need to be unique across the package graph" case .moduleNotFound(let target, let type, let shouldSuggestRelaxedSourceDir, let expectedLocation): - var clauses = ["Source files for target \(target) should be located under '\(expectedLocation)/\(target)'"] + var clauses = ["Source files for target \(target) of type \(type) should be located under '\(expectedLocation)/\(target)'"] if shouldSuggestRelaxedSourceDir { clauses.append("'\(expectedLocation)'") } @@ -734,7 +734,7 @@ public final class PackageBuilder { missingModuleName, type, shouldSuggestRelaxedSourceDir: self.manifest.shouldSuggestRelaxedSourceDir(type: type), - expectedLocation: "Sources" // FIXME: this should provide the expected location of the module here + expectedLocation: PackageBuilder.suggestedPredefinedSourceDirectory(type: type) ) } diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift index db88c6338db..da656923adc 100644 --- a/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift +++ b/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift @@ -20,8 +20,7 @@ import protocol TSCBasic.OutputByteStream /// Create an initial template package. public final class InitPackage { /// The tool version to be used for new packages. - public static let newPackageToolsVersion = ToolsVersion.v6_1 //TODO: JOHN CHANGE ME BACK TO: - // - public static let newPackageToolsVersion = ToolsVersion.current + public static let newPackageToolsVersion = ToolsVersion.current /// Options for the template package. public struct InitPackageOptions { diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index 90a4d61ac84..061902350bc 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -1737,7 +1737,6 @@ struct PackageCommandTests { } } - @Test( .tags( .Feature.Command.Package.Init, @@ -1813,8 +1812,6 @@ struct PackageCommandTests { } } - - @Suite( .tags( .Feature.Command.Package.AddDependency, diff --git a/Tests/CommandsTests/TemplateTests.swift b/Tests/CommandsTests/TemplateTests.swift index 9bcc1381350..12368f890e5 100644 --- a/Tests/CommandsTests/TemplateTests.swift +++ b/Tests/CommandsTests/TemplateTests.swift @@ -12,34 +12,34 @@ import Basics +import ArgumentParserToolInfo +@testable import Commands @_spi(SwiftPMInternal) @testable import CoreCommands -@testable import Commands -@testable import Workspace -import ArgumentParserToolInfo import Foundation +@testable import Workspace +import _InternalTestSupport @_spi(DontAdoptOutsideOfSwiftPMExposedForBenchmarksAndTestsOnly) import PackageGraph -import TSCUtility import PackageLoading import SourceControl import SPMBuildCore -import _InternalTestSupport -import Workspace import Testing +import TSCUtility +import Workspace @_spi(PackageRefactor) import SwiftRefactor -import struct TSCBasic.ByteString +import class Basics.AsyncProcess import class TSCBasic.BufferedOutputByteStream +import struct TSCBasic.ByteString import enum TSCBasic.JSON -import class Basics.AsyncProcess - // MARK: - Helper Methods -fileprivate func makeTestResolver() throws -> (resolver: DefaultTemplateSourceResolver, tool: SwiftCommandState) { + +private func makeTestResolver() throws -> (resolver: DefaultTemplateSourceResolver, tool: SwiftCommandState) { let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) guard let cwd = tool.fileSystem.currentWorkingDirectory else { @@ -53,20 +53,26 @@ fileprivate func makeTestResolver() throws -> (resolver: DefaultTemplateSourceRe return (resolver, tool) } - -fileprivate func makeTestTool() throws -> SwiftCommandState { +private func makeTestTool() throws -> SwiftCommandState { let options = try GlobalOptions.parse([]) return try SwiftCommandState.makeMockState(options: options) } -fileprivate func makeVersions() -> (lower: Version, higher: Version) { +private func makeVersions() -> (lower: Version, higher: Version) { let lowerBoundVersion = Version(stringLiteral: "1.2.0") let higherBoundVersion = Version(stringLiteral: "3.0.0") return (lowerBoundVersion, higherBoundVersion) } - -fileprivate func makeTestDependencyData() throws -> (tool: SwiftCommandState, packageName: String, templateURL: String, templatePackageID: String, path: AbsolutePath) { +private func makeTestDependencyData() throws + -> ( + tool: SwiftCommandState, + packageName: String, + templateURL: String, + templatePackageID: String, + path: AbsolutePath + ) +{ let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) let packageName = "foo" @@ -83,8 +89,9 @@ fileprivate func makeTestDependencyData() throws -> (tool: SwiftCommandState, pa .Feature.Command.Package.General, ), ) -struct TemplateTests{ +struct TemplateTests { // MARK: - Template Source Resolution Tests + @Suite( .tags( Tag.TestSize.small, @@ -92,18 +99,21 @@ struct TemplateTests{ ), ) struct TemplateSourceResolverTests { - @Test func resolveSourceWithNilInputs() throws { - + @Test + func resolveSourceWithNilInputs() throws { let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - - guard let cwd = tool.fileSystem.currentWorkingDirectory else {return} + guard let cwd = tool.fileSystem.currentWorkingDirectory else { return } let fileSystem = tool.fileSystem let observabilityScope = tool.observabilityScope - let resolver = DefaultTemplateSourceResolver(cwd: cwd, fileSystem: fileSystem, observabilityScope: observabilityScope) + let resolver = DefaultTemplateSourceResolver( + cwd: cwd, + fileSystem: fileSystem, + observabilityScope: observabilityScope + ) let nilSource = resolver.resolveSource( directory: nil, url: nil, packageID: nil @@ -121,38 +131,57 @@ struct TemplateTests{ #expect(packageIDSource == .registry) let gitSource = resolver.resolveSource( - directory: AbsolutePath("/fake/path/to/template"), url: "https://github.com/foo/bar", packageID: "foo.bar" + directory: AbsolutePath("/fake/path/to/template"), url: "https://github.com/foo/bar", + packageID: "foo.bar" ) #expect(gitSource == .git) } - @Test func validateGitURLWithValidInput() async throws { - + @Test + func validateGitURLWithValidInput() async throws { let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - let resolver = DefaultTemplateSourceResolver(cwd: tool.fileSystem.currentWorkingDirectory!, fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope) + let resolver = DefaultTemplateSourceResolver( + cwd: tool.fileSystem.currentWorkingDirectory!, + fileSystem: tool.fileSystem, + observabilityScope: tool.observabilityScope + ) - try resolver.validate(templateSource: .git, directory: nil, url: "https://github.com/apple/swift", packageID: nil) + try resolver.validate( + templateSource: .git, + directory: nil, + url: "https://github.com/apple/swift", + packageID: nil + ) // Check that nothing was emitted (i.e., no error for valid URL) #expect(tool.observabilityScope.errorsReportedInAnyScope == false) - } - @Test func validateGitURLWithInvalidInput() throws { + @Test + func validateGitURLWithInvalidInput() throws { let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - let resolver = DefaultTemplateSourceResolver(cwd: tool.fileSystem.currentWorkingDirectory!, fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope) + let resolver = DefaultTemplateSourceResolver( + cwd: tool.fileSystem.currentWorkingDirectory!, + fileSystem: tool.fileSystem, + observabilityScope: tool.observabilityScope + ) #expect(throws: DefaultTemplateSourceResolver.SourceResolverError.invalidGitURL("invalid-url").self) { try resolver.validate(templateSource: .git, directory: nil, url: "invalid-url", packageID: nil) } } - @Test func validateRegistryIDWithValidInput() throws { + @Test + func validateRegistryIDWithValidInput() throws { let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - let resolver = DefaultTemplateSourceResolver(cwd: tool.fileSystem.currentWorkingDirectory!, fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope) + let resolver = DefaultTemplateSourceResolver( + cwd: tool.fileSystem.currentWorkingDirectory!, + fileSystem: tool.fileSystem, + observabilityScope: tool.observabilityScope + ) try resolver.validate(templateSource: .registry, directory: nil, url: nil, packageID: "mona.LinkedList") @@ -160,45 +189,69 @@ struct TemplateTests{ #expect(tool.observabilityScope.errorsReportedInAnyScope == false) } - @Test func validateRegistryIDWithInvalidInput() throws { - + @Test + func validateRegistryIDWithInvalidInput() throws { let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - let resolver = DefaultTemplateSourceResolver(cwd: tool.fileSystem.currentWorkingDirectory!, fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope) + let resolver = DefaultTemplateSourceResolver( + cwd: tool.fileSystem.currentWorkingDirectory!, + fileSystem: tool.fileSystem, + observabilityScope: tool.observabilityScope + ) - #expect(throws: DefaultTemplateSourceResolver.SourceResolverError.invalidRegistryIdentity("invalid-id").self) { + #expect(throws: DefaultTemplateSourceResolver.SourceResolverError.invalidRegistryIdentity("invalid-id") + .self + ) { try resolver.validate(templateSource: .registry, directory: nil, url: nil, packageID: "invalid-id") } } - @Test func validateLocalSourceWithMissingPath() throws { + @Test + func validateLocalSourceWithMissingPath() throws { let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - let resolver = DefaultTemplateSourceResolver(cwd: tool.fileSystem.currentWorkingDirectory!, fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope) + let resolver = DefaultTemplateSourceResolver( + cwd: tool.fileSystem.currentWorkingDirectory!, + fileSystem: tool.fileSystem, + observabilityScope: tool.observabilityScope + ) #expect(throws: DefaultTemplateSourceResolver.SourceResolverError.missingLocalPath.self) { try resolver.validate(templateSource: .local, directory: nil, url: nil, packageID: nil) } } - @Test func validateLocalSourceWithInvalidPath() throws { + @Test + func validateLocalSourceWithInvalidPath() throws { let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - let resolver = DefaultTemplateSourceResolver(cwd: tool.fileSystem.currentWorkingDirectory!, fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope) + let resolver = DefaultTemplateSourceResolver( + cwd: tool.fileSystem.currentWorkingDirectory!, + fileSystem: tool.fileSystem, + observabilityScope: tool.observabilityScope + ) - #expect(throws: DefaultTemplateSourceResolver.SourceResolverError.invalidDirectoryPath("/fake/path/that/does/not/exist").self) { - try resolver.validate(templateSource: .local, directory: "/fake/path/that/does/not/exist", url: nil, packageID: nil) + #expect(throws: DefaultTemplateSourceResolver.SourceResolverError + .invalidDirectoryPath("/fake/path/that/does/not/exist").self + ) { + try resolver.validate( + templateSource: .local, + directory: "/fake/path/that/does/not/exist", + url: nil, + packageID: nil + ) } } - @Test func resolveRegistryDependencyWithNoVersion() async throws { + @Test + func resolveRegistryDependencyWithNoVersion() async throws { // TODO: Set up registry mock for this test // Should test that registry dependency resolution returns nil when no version constraints are provided } - } // MARK: - Dependency Requirement Resolution Tests + @Suite( .tags( Tag.TestSize.medium, @@ -206,9 +259,8 @@ struct TemplateTests{ ), ) struct DependencyRequirementResolverTests { - - @Test func resolveRegistryDependencyRequirements() async throws { - + @Test + func resolveRegistryDependencyRequirements() async throws { let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) @@ -247,7 +299,6 @@ struct TemplateTests{ Issue.record("Expected exact registry dependency, got \(String(describing: exactRegistryDependency))") } - // test from to let fromToRegistryDependency = try await DependencyRequirementResolver( packageIdentity: nil, @@ -284,7 +335,10 @@ struct TemplateTests{ #expect(lowerBound == expectedRange.lowerBound.description) #expect(upperBound == expectedRange.upperBound.description) } else { - Issue.record("Expected range registry dependency, got \(String(describing: upToNextMinorFromToRegistryDependency))") + Issue + .record( + "Expected range registry dependency, got \(String(describing: upToNextMinorFromToRegistryDependency))" + ) } // test just from @@ -302,7 +356,8 @@ struct TemplateTests{ if case .rangeFrom(let lowerBound) = fromRegistryDependency { #expect(lowerBound == lowerBoundVersion.description) } else { - Issue.record("Expected rangeFrom registry dependency, got \(String(describing: fromRegistryDependency))") + Issue + .record("Expected rangeFrom registry dependency, got \(String(describing: fromRegistryDependency))") } // test just up-to-next-minor-from @@ -322,10 +377,12 @@ struct TemplateTests{ #expect(lowerBound == expectedRange.lowerBound.description) #expect(upperBound == expectedRange.upperBound.description) } else { - Issue.record("Expected range registry dependency, got \(String(describing: upToNextMinorFromRegistryDependency))") + Issue + .record( + "Expected range registry dependency, got \(String(describing: upToNextMinorFromRegistryDependency))" + ) } - await #expect(throws: DependencyRequirementError.multipleRequirementsSpecified.self) { try await DependencyRequirementResolver( packageIdentity: nil, @@ -366,8 +423,8 @@ struct TemplateTests{ } } - @Test func resolveSourceControlDependencyRequirements() throws { - + @Test + func resolveSourceControlDependencyRequirements() throws { let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) @@ -389,7 +446,10 @@ struct TemplateTests{ if case .branch(let branchName) = branchSourceControlDependency { #expect(branchName == "master") } else { - Issue.record("Expected branch source control dependency, got \(String(describing: branchSourceControlDependency))") + Issue + .record( + "Expected branch source control dependency, got \(String(describing: branchSourceControlDependency))" + ) } let revisionSourceControlDependency = try DependencyRequirementResolver( @@ -406,7 +466,10 @@ struct TemplateTests{ if case .revision(let revisionHash) = revisionSourceControlDependency { #expect(revisionHash == "dae86e") } else { - Issue.record("Expected revision source control dependency, got \(String( describing: revisionSourceControlDependency))") + Issue + .record( + "Expected revision source control dependency, got \(String(describing: revisionSourceControlDependency))" + ) } // test exact specification @@ -424,7 +487,10 @@ struct TemplateTests{ if case .exact(let version) = exactSourceControlDependency { #expect(version == lowerBoundVersion.description) } else { - Issue.record("Expected exact source control dependency, got \(String(describing: exactSourceControlDependency))") + Issue + .record( + "Expected exact source control dependency, got \(String(describing: exactSourceControlDependency))" + ) } // test from to @@ -443,7 +509,10 @@ struct TemplateTests{ #expect(lowerBound == lowerBoundVersion.description) #expect(upperBound == higherBoundVersion.description) } else { - Issue.record("Expected range source control dependency, got \(String(describing: fromToSourceControlDependency))") + Issue + .record( + "Expected range source control dependency, got \(String(describing: fromToSourceControlDependency))" + ) } // test up-to-next-minor-from and to @@ -463,7 +532,10 @@ struct TemplateTests{ #expect(lowerBound == expectedRange.lowerBound.description) #expect(upperBound == expectedRange.upperBound.description) } else { - Issue.record("Expected range source control dependency, got \(String(describing: upToNextMinorFromToSourceControlDependency))") + Issue + .record( + "Expected range source control dependency, got \(String(describing: upToNextMinorFromToSourceControlDependency))" + ) } // test just from @@ -481,7 +553,10 @@ struct TemplateTests{ if case .rangeFrom(let lowerBound) = fromSourceControlDependency { #expect(lowerBound == lowerBoundVersion.description) } else { - Issue.record("Expected rangeFrom source control dependency, got \(String(describing: fromSourceControlDependency))") + Issue + .record( + "Expected rangeFrom source control dependency, got \(String(describing: fromSourceControlDependency))" + ) } // test just up-to-next-minor-from @@ -501,10 +576,12 @@ struct TemplateTests{ #expect(lowerBound == expectedRange.lowerBound.description) #expect(upperBound == expectedRange.upperBound.description) } else { - Issue.record("Expected range source control dependency, got \(String(describing: upToNextMinorFromSourceControlDependency))") + Issue + .record( + "Expected range source control dependency, got \(String(describing: upToNextMinorFromSourceControlDependency))" + ) } - #expect(throws: DependencyRequirementError.multipleRequirementsSpecified.self) { try DependencyRequirementResolver( packageIdentity: nil, @@ -562,10 +639,10 @@ struct TemplateTests{ Issue.record("Expected range source control dependency, got \(range)") } } - } // MARK: - Template Path Resolution Tests + @Suite( .tags( Tag.TestSize.medium, @@ -574,8 +651,8 @@ struct TemplateTests{ ), ) struct TemplatePathResolverTests { - - @Test func resolveLocalTemplatePath() async throws { + @Test + func resolveLocalTemplatePath() async throws { let mockTemplatePath = AbsolutePath("/fake/path/to/template") let options = try GlobalOptions.parse([]) @@ -599,9 +676,7 @@ struct TemplateTests{ .requireUnrestrictedNetworkAccess("Test needs to create and access local git repositories"), ) func resolveGitTemplatePath() async throws { - try await testWithTemporaryDirectory { path in - let sourceControlRequirement = SwiftRefactor.PackageDependency.SourceControl.Requirement.branch("main") let options = try GlobalOptions.parse([]) @@ -623,7 +698,10 @@ struct TemplateTests{ swiftCommandState: tool ) let path = try await resolver.resolve() - #expect(localFileSystem.exists(path.appending(component: "file.swift")), "Template was not fetched correctly") + #expect( + localFileSystem.exists(path.appending(component: "file.swift")), + "Template was not fetched correctly" + ) } } @@ -632,9 +710,7 @@ struct TemplateTests{ .requireUnrestrictedNetworkAccess("Test needs to attempt git clone operations"), ) func resolveGitTemplatePathWithInvalidURL() async throws { - try await testWithTemporaryDirectory { path in - let sourceControlRequirement = SwiftRefactor.PackageDependency.SourceControl.Requirement.branch("main") let options = try GlobalOptions.parse([]) @@ -654,20 +730,23 @@ struct TemplateTests{ swiftCommandState: tool ) - await #expect(throws: GitTemplateFetcher.GitTemplateFetcherError.cloneFailed(source: "invalid-git-url")) { + await #expect(throws: GitTemplateFetcher.GitTemplateFetcherError + .cloneFailed(source: "invalid-git-url") + ) { _ = try await resolver.resolve() } } } - @Test func resolveRegistryTemplatePath() async throws { + @Test + func resolveRegistryTemplatePath() async throws { // TODO: Implement registry template path resolution test // Should test fetching template from package registry } - } // MARK: - Template Directory Management Tests + @Suite( .tags( Tag.TestSize.medium, @@ -675,15 +754,16 @@ struct TemplateTests{ ), ) struct TemplateDirectoryManagerTests { - - @Test func createTemporaryDirectories() throws { - + @Test + func createTemporaryDirectories() throws { let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - let (stagingPath, cleanupPath, tempDir) = try TemplateInitializationDirectoryManager(fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope).createTemporaryDirectories() - + let (stagingPath, cleanupPath, tempDir) = try TemplateInitializationDirectoryManager( + fileSystem: tool.fileSystem, + observabilityScope: tool.observabilityScope + ).createTemporaryDirectories() #expect(stagingPath.parentDirectory == tempDir) #expect(cleanupPath.parentDirectory == tempDir) @@ -695,7 +775,6 @@ struct TemplateTests{ #expect(tool.fileSystem.exists(cleanupPath)) } - @Test( .tags( Tag.Feature.Command.Package.Init, @@ -727,7 +806,6 @@ struct TemplateTests{ #expect(localFileSystem.exists(stagingBinFile)) #expect(localFileSystem.isDirectory(stagingBuildPath)) - try await TemplateInitializationDirectoryManager( fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope @@ -740,12 +818,15 @@ struct TemplateTests{ // Postcondition checks #expect(localFileSystem.exists(cwd), "cwd should exist after finalize") - #expect(localFileSystem.exists(cwdBinFile) == false, "Binary should have been cleaned before copying to cwd") + #expect( + localFileSystem.exists(cwdBinFile) == false, + "Binary should have been cleaned before copying to cwd" + ) } } - @Test func cleanUpTemporaryDirectories() throws { - + @Test + func cleanUpTemporaryDirectories() throws { try fixture(name: "Miscellaneous/DirectoryManagerFinalize", createGitRepo: false) { fixturePath in let pathToRemove = fixturePath.appending("targetFolderForRemoval") let options = try GlobalOptions.parse([]) @@ -785,10 +866,10 @@ struct TemplateTests{ #expect(localFileSystem.exists(pathToRemove), "path should not be removed if local") } } - } // MARK: - Package Dependency Builder Tests + @Suite( .tags( Tag.TestSize.medium, @@ -796,8 +877,8 @@ struct TemplateTests{ ), ) struct PackageDependencyBuilderTests { - - @Test func buildDependenciesFromTemplateSource() async throws { + @Test + func buildDependenciesFromTemplateSource() async throws { let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) @@ -806,10 +887,12 @@ struct TemplateTests{ let templatePackageID = "foo.bar" let versionResolver = DependencyRequirementResolver( - packageIdentity: templatePackageID, swiftCommandState: tool, exact: Version(stringLiteral: "1.2.0"), revision: nil, branch: nil, from: nil, upToNextMinorFrom: nil, to: nil + packageIdentity: templatePackageID, swiftCommandState: tool, exact: Version(stringLiteral: "1.2.0"), + revision: nil, branch: nil, from: nil, upToNextMinorFrom: nil, to: nil ) - let sourceControlRequirement: SwiftRefactor.PackageDependency.SourceControl.Requirement = try versionResolver.resolveSourceControl() + let sourceControlRequirement: SwiftRefactor.PackageDependency.SourceControl + .Requirement = try versionResolver.resolveSourceControl() guard let registryRequirement = try await versionResolver.resolveRegistry() else { Issue.record("Registry ID of template could not be resolved.") return @@ -817,7 +900,7 @@ struct TemplateTests{ let resolvedTemplatePath: AbsolutePath = try AbsolutePath(validating: "/fake/path/to/template") - //local + // local let localDependency = try DefaultPackageDependencyBuilder( templateSource: .local, @@ -883,7 +966,9 @@ struct TemplateTests{ Issue.record("Expected sourceControl dependency, got \(gitDependency)") } - #expect(throws: DefaultPackageDependencyBuilder.PackageDependencyBuilderError.missingRegistryIdentity.self) { + #expect(throws: DefaultPackageDependencyBuilder.PackageDependencyBuilderError.missingRegistryIdentity + .self + ) { try DefaultPackageDependencyBuilder( templateSource: .registry, packageName: packageName, @@ -895,7 +980,9 @@ struct TemplateTests{ ).makePackageDependency() } - #expect(throws: DefaultPackageDependencyBuilder.PackageDependencyBuilderError.missingRegistryRequirement.self) { + #expect(throws: DefaultPackageDependencyBuilder.PackageDependencyBuilderError.missingRegistryRequirement + .self + ) { try DefaultPackageDependencyBuilder( templateSource: .registry, packageName: packageName, @@ -925,10 +1012,10 @@ struct TemplateTests{ Issue.record("Expected registry dependency, got \(registryDependency)") } } - } // MARK: - Package Initializer Configuration Tests + @Suite( .tags( Tag.TestSize.small, @@ -936,10 +1023,9 @@ struct TemplateTests{ ), ) struct PackageInitializerConfigurationTests { - - @Test func createPackageInitializer() throws { + @Test + func createPackageInitializer() throws { try testWithTemporaryDirectory { tempDir in - let globalOptions = try GlobalOptions.parse(["--package-path", tempDir.pathString]) let testLibraryOptions = try TestLibraryOptions.parse([]) let buildOptions = try BuildCommandOptions.parse([]) @@ -958,13 +1044,19 @@ struct TemplateTests{ directory: directoryPath, url: nil, packageID: "foo.bar", - versionFlags: VersionFlags(exact: nil, revision: nil, branch: "master", from: nil, upToNextMinorFrom: nil ,to: nil) + versionFlags: VersionFlags( + exact: nil, + revision: nil, + branch: "master", + from: nil, + upToNextMinorFrom: nil, + to: nil + ) ).makeInitializer() #expect(templatePackageInitializer is TemplatePackageInitializer) - - let standardPackageInitalizer = try PackageInitConfiguration( + let standardPackageInitalizer = try PackageInitConfiguration( swiftCommandState: tool, name: "foo", initMode: "template", @@ -976,7 +1068,14 @@ struct TemplateTests{ directory: nil, url: nil, packageID: nil, - versionFlags: VersionFlags(exact: nil, revision: nil, branch: "master", from: nil, upToNextMinorFrom: nil ,to: nil) + versionFlags: VersionFlags( + exact: nil, + revision: nil, + branch: "master", + from: nil, + upToNextMinorFrom: nil, + to: nil + ) ).makeInitializer() #expect(standardPackageInitalizer is StandardPackageInitializer) @@ -1007,6 +1106,7 @@ struct TemplateTests{ } // MARK: - Template Prompting System Tests + @Suite( .tags( Tag.TestSize.medium, @@ -1014,15 +1114,14 @@ struct TemplateTests{ ), ) struct TemplatePromptingSystemTests { - // MARK: - Helper Methods - + private func createTestCommand( name: String = "test-template", arguments: [ArgumentInfoV0] = [], subcommands: [CommandInfoV0]? = nil ) -> CommandInfoV0 { - return CommandInfoV0( + CommandInfoV0( superCommands: [], shouldDisplay: true, commandName: name, @@ -1033,9 +1132,15 @@ struct TemplateTests{ arguments: arguments ) } - - private func createRequiredOption(name: String, defaultValue: String? = nil, allValues: [String]? = nil, parsingStrategy: ArgumentInfoV0.ParsingStrategyV0 = .default, completionKind: ArgumentInfoV0.CompletionKindV0? = nil) -> ArgumentInfoV0 { - return ArgumentInfoV0( + + private func createRequiredOption( + name: String, + defaultValue: String? = nil, + allValues: [String]? = nil, + parsingStrategy: ArgumentInfoV0.ParsingStrategyV0 = .default, + completionKind: ArgumentInfoV0.CompletionKindV0? = nil + ) -> ArgumentInfoV0 { + ArgumentInfoV0( kind: .option, shouldDisplay: true, sectionTitle: nil, @@ -1053,9 +1158,14 @@ struct TemplateTests{ discussion: nil ) } - - private func createOptionalOption(name: String, defaultValue: String? = nil, allValues: [String]? = nil, completionKind: ArgumentInfoV0.CompletionKindV0? = nil) -> ArgumentInfoV0 { - return ArgumentInfoV0( + + private func createOptionalOption( + name: String, + defaultValue: String? = nil, + allValues: [String]? = nil, + completionKind: ArgumentInfoV0.CompletionKindV0? = nil + ) -> ArgumentInfoV0 { + ArgumentInfoV0( kind: .option, shouldDisplay: true, sectionTitle: nil, @@ -1073,9 +1183,13 @@ struct TemplateTests{ discussion: nil ) } - - private func createOptionalFlag(name: String, defaultValue: String? = nil, completionKind: ArgumentInfoV0.CompletionKindV0? = nil) -> ArgumentInfoV0 { - return ArgumentInfoV0( + + private func createOptionalFlag( + name: String, + defaultValue: String? = nil, + completionKind: ArgumentInfoV0.CompletionKindV0? = nil + ) -> ArgumentInfoV0 { + ArgumentInfoV0( kind: .flag, shouldDisplay: true, sectionTitle: nil, @@ -1093,9 +1207,14 @@ struct TemplateTests{ discussion: nil ) } - - private func createPositionalArgument(name: String, isOptional: Bool = false, defaultValue: String? = nil, parsingStrategy: ArgumentInfoV0.ParsingStrategyV0 = .default) -> ArgumentInfoV0 { - return ArgumentInfoV0( + + private func createPositionalArgument( + name: String, + isOptional: Bool = false, + defaultValue: String? = nil, + parsingStrategy: ArgumentInfoV0.ParsingStrategyV0 = .default + ) -> ArgumentInfoV0 { + ArgumentInfoV0( kind: .positional, shouldDisplay: true, sectionTitle: nil, @@ -1113,13 +1232,14 @@ struct TemplateTests{ discussion: nil ) } - + // MARK: - Basic Functionality Tests - @Test func createsPromptingSystemSuccessfully() throws { + @Test + func createsPromptingSystemSuccessfully() throws { let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let emptyCommand = createTestCommand(name: "empty") - + let emptyCommand = self.createTestCommand(name: "empty") + let result = try promptingSystem.promptUser( command: emptyCommand, arguments: [] @@ -1127,47 +1247,50 @@ struct TemplateTests{ #expect(result.isEmpty) } - @Test func handlesCommandWithProvidedArguments() throws { + @Test + func handlesCommandWithProvidedArguments() throws { let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let commandInfo = createTestCommand( - arguments: [createRequiredOption(name: "name")] + let commandInfo = self.createTestCommand( + arguments: [self.createRequiredOption(name: "name")] ) - + let result = try promptingSystem.promptUser( command: commandInfo, arguments: ["--name", "TestPackage"] ) - + #expect(result.contains("--name")) #expect(result.contains("TestPackage")) } - @Test func handlesOptionalArgumentsWithDefaults() throws { + @Test + func handlesOptionalArgumentsWithDefaults() throws { let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let commandInfo = createTestCommand( + let commandInfo = self.createTestCommand( arguments: [ - createRequiredOption(name: "name"), - createOptionalFlag(name: "include-readme", defaultValue: "false") + self.createRequiredOption(name: "name"), + self.createOptionalFlag(name: "include-readme", defaultValue: "false"), ] ) - + let result = try promptingSystem.promptUser( command: commandInfo, arguments: ["--name", "TestPackage"] ) - + #expect(result.contains("--name")) #expect(result.contains("TestPackage")) // Flag with default "false" should not appear in command line #expect(!result.contains("--include-readme")) } - @Test func validatesMissingRequiredArguments() throws { + @Test + func validatesMissingRequiredArguments() throws { let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let commandInfo = createTestCommand( - arguments: [createRequiredOption(name: "name")] + let commandInfo = self.createTestCommand( + arguments: [self.createRequiredOption(name: "name")] ) - + #expect(throws: Error.self) { _ = try promptingSystem.promptUser( command: commandInfo, @@ -1175,60 +1298,62 @@ struct TemplateTests{ ) } } - + // MARK: - Argument Response Tests - - @Test func argumentResponseHandlesExplicitlyUnsetFlags() throws { - let arg = createOptionalFlag(name: "verbose", defaultValue: "false") - + + @Test + func argumentResponseHandlesExplicitlyUnsetFlags() throws { + let arg = self.createOptionalFlag(name: "verbose", defaultValue: "false") + // Test explicitly unset flag let explicitlyUnsetResponse = TemplatePromptingSystem.ArgumentResponse( - argument: arg, - values: [], + argument: arg, + values: [], isExplicitlyUnset: true ) #expect(explicitlyUnsetResponse.isExplicitlyUnset == true) #expect(explicitlyUnsetResponse.commandLineFragments.isEmpty) - + // Test normal flag response (true) let trueResponse = TemplatePromptingSystem.ArgumentResponse( - argument: arg, - values: ["true"], + argument: arg, + values: ["true"], isExplicitlyUnset: false ) #expect(trueResponse.isExplicitlyUnset == false) #expect(trueResponse.commandLineFragments == ["--verbose"]) - + // Test false flag response (should be empty) let falseResponse = TemplatePromptingSystem.ArgumentResponse( - argument: arg, - values: ["false"], + argument: arg, + values: ["false"], isExplicitlyUnset: false ) #expect(falseResponse.commandLineFragments.isEmpty) } - @Test func argumentResponseHandlesExplicitlyUnsetOptions() throws { - let arg = createOptionalOption(name: "output") - + @Test + func argumentResponseHandlesExplicitlyUnsetOptions() throws { + let arg = self.createOptionalOption(name: "output") + // Test explicitly unset option let explicitlyUnsetResponse = TemplatePromptingSystem.ArgumentResponse( - argument: arg, - values: [], + argument: arg, + values: [], isExplicitlyUnset: true ) #expect(explicitlyUnsetResponse.isExplicitlyUnset == true) #expect(explicitlyUnsetResponse.commandLineFragments.isEmpty) - + // Test normal option response let normalResponse = TemplatePromptingSystem.ArgumentResponse( - argument: arg, - values: ["./output"], + argument: arg, + values: ["./output"], isExplicitlyUnset: false ) #expect(normalResponse.isExplicitlyUnset == false) #expect(normalResponse.commandLineFragments == ["--output", "./output"]) - + // Test multiple values option let multiValueArg = ArgumentInfoV0( kind: .option, @@ -1249,115 +1374,140 @@ struct TemplateTests{ ) let multiValueResponse = TemplatePromptingSystem.ArgumentResponse( - argument: multiValueArg, - values: ["FOO=bar", "BAZ=qux"], + argument: multiValueArg, + values: ["FOO=bar", "BAZ=qux"], isExplicitlyUnset: false ) #expect(multiValueResponse.commandLineFragments == ["--define", "FOO=bar", "--define", "BAZ=qux"]) } - @Test func argumentResponseHandlesPositionalArguments() throws { - let arg = createPositionalArgument(name: "target", isOptional: true) - + @Test + func argumentResponseHandlesPositionalArguments() throws { + let arg = self.createPositionalArgument(name: "target", isOptional: true) + // Test explicitly unset positional let explicitlyUnsetResponse = TemplatePromptingSystem.ArgumentResponse( - argument: arg, - values: [], + argument: arg, + values: [], isExplicitlyUnset: true ) #expect(explicitlyUnsetResponse.isExplicitlyUnset == true) #expect(explicitlyUnsetResponse.commandLineFragments.isEmpty) - - // Test normal positional response + + // Test normal positional response let normalResponse = TemplatePromptingSystem.ArgumentResponse( - argument: arg, - values: ["MyTarget"], + argument: arg, + values: ["MyTarget"], isExplicitlyUnset: false ) #expect(normalResponse.isExplicitlyUnset == false) #expect(normalResponse.commandLineFragments == ["MyTarget"]) } - + // MARK: - Command Line Generation Tests - @Test func commandLineGenerationWithMixedArgumentStates() throws { + @Test + func commandLineGenerationWithMixedArgumentStates() throws { let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let flagArg = createOptionalFlag(name: "verbose") - let requiredOptionArg = createRequiredOption(name: "name") - let optionalOptionArg = createOptionalOption(name: "output") - let positionalArg = createPositionalArgument(name: "target", isOptional: true) - + let flagArg = self.createOptionalFlag(name: "verbose") + let requiredOptionArg = self.createRequiredOption(name: "name") + let optionalOptionArg = self.createOptionalOption(name: "output") + let positionalArg = self.createPositionalArgument(name: "target", isOptional: true) + // Create responses with mixed states let responses = [ TemplatePromptingSystem.ArgumentResponse(argument: flagArg, values: [], isExplicitlyUnset: true), - TemplatePromptingSystem.ArgumentResponse(argument: requiredOptionArg, values: ["TestPackage"], isExplicitlyUnset: false), - TemplatePromptingSystem.ArgumentResponse(argument: optionalOptionArg, values: [], isExplicitlyUnset: true), - TemplatePromptingSystem.ArgumentResponse(argument: positionalArg, values: ["MyTarget"], isExplicitlyUnset: false) + TemplatePromptingSystem.ArgumentResponse( + argument: requiredOptionArg, + values: ["TestPackage"], + isExplicitlyUnset: false + ), + TemplatePromptingSystem.ArgumentResponse( + argument: optionalOptionArg, + values: [], + isExplicitlyUnset: true + ), + TemplatePromptingSystem.ArgumentResponse( + argument: positionalArg, + values: ["MyTarget"], + isExplicitlyUnset: false + ), ] - + let commandLine = promptingSystem.buildCommandLine(from: responses) - + // Should only contain the non-unset arguments #expect(commandLine == ["--name", "TestPackage", "MyTarget"]) } - - @Test func commandLineGenerationWithDefaultValues() throws { + + @Test + func commandLineGenerationWithDefaultValues() throws { let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let optionWithDefault = createOptionalOption(name: "version", defaultValue: "1.0.0") - let flagWithDefault = createOptionalFlag(name: "enabled", defaultValue: "true") - + let optionWithDefault = self.createOptionalOption(name: "version", defaultValue: "1.0.0") + let flagWithDefault = self.createOptionalFlag(name: "enabled", defaultValue: "true") + let responses = [ - TemplatePromptingSystem.ArgumentResponse(argument: optionWithDefault, values: ["1.0.0"], isExplicitlyUnset: false), - TemplatePromptingSystem.ArgumentResponse(argument: flagWithDefault, values: ["true"], isExplicitlyUnset: false) + TemplatePromptingSystem.ArgumentResponse( + argument: optionWithDefault, + values: ["1.0.0"], + isExplicitlyUnset: false + ), + TemplatePromptingSystem.ArgumentResponse( + argument: flagWithDefault, + values: ["true"], + isExplicitlyUnset: false + ), ] - + let commandLine = promptingSystem.buildCommandLine(from: responses) - + #expect(commandLine == ["--version", "1.0.0", "--enabled"]) } - + // MARK: - Argument Parsing Tests - - @Test func parsesProvidedArgumentsCorrectly() throws { + + @Test + func parsesProvidedArgumentsCorrectly() throws { let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let commandInfo = createTestCommand( + let commandInfo = self.createTestCommand( arguments: [ - createRequiredOption(name: "name"), - createOptionalFlag(name: "verbose"), - createOptionalOption(name: "output") + self.createRequiredOption(name: "name"), + self.createOptionalFlag(name: "verbose"), + self.createOptionalOption(name: "output"), ] ) - + let result = try promptingSystem.promptUser( command: commandInfo, arguments: ["--name", "TestPackage", "--verbose", "--output", "./dist"] ) - + #expect(result.contains("--name")) #expect(result.contains("TestPackage")) #expect(result.contains("--verbose")) #expect(result.contains("--output")) #expect(result.contains("./dist")) } - - @Test func handlesValidationWithAllowedValues() throws { - let restrictedArg = createRequiredOption( - name: "type", + + @Test + func handlesValidationWithAllowedValues() throws { + let restrictedArg = self.createRequiredOption( + name: "type", allValues: ["executable", "library", "plugin"] ) - + let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let commandInfo = createTestCommand(arguments: [restrictedArg]) - + let commandInfo = self.createTestCommand(arguments: [restrictedArg]) + // Valid value should work let validResult = try promptingSystem.promptUser( command: commandInfo, arguments: ["--type", "executable"] ) #expect(validResult.contains("executable")) - + // Invalid value should throw #expect(throws: Error.self) { _ = try promptingSystem.promptUser( @@ -1366,56 +1516,59 @@ struct TemplateTests{ ) } } - + // MARK: - Subcommand Tests - - @Test func handlesSubcommandDetection() throws { - let subcommand = createTestCommand( + + @Test + func handlesSubcommandDetection() throws { + let subcommand = self.createTestCommand( name: "init", - arguments: [createRequiredOption(name: "name")] + arguments: [self.createRequiredOption(name: "name")] ) - - let mainCommand = createTestCommand( + + let mainCommand = self.createTestCommand( name: "package", subcommands: [subcommand] ) - + let promptingSystem = TemplatePromptingSystem(hasTTY: false) let result = try promptingSystem.promptUser( command: mainCommand, arguments: ["init", "--name", "TestPackage"] ) - + #expect(result.contains("init")) #expect(result.contains("--name")) #expect(result.contains("TestPackage")) } - + // MARK: - Error Handling Tests - - @Test func handlesInvalidArgumentNames() throws { + + @Test + func handlesInvalidArgumentNames() throws { let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let commandInfo = createTestCommand( - arguments: [createRequiredOption(name: "name")] + let commandInfo = self.createTestCommand( + arguments: [self.createRequiredOption(name: "name")] ) - + // Should handle unknown arguments gracefully by treating them as potential subcommands let result = try promptingSystem.promptUser( command: commandInfo, arguments: ["--name", "TestPackage", "--unknown", "value"] ) - + #expect(result.contains("--name")) #expect(result.contains("TestPackage")) } - - @Test func handlesMissingValueForOption() throws { + + @Test + func handlesMissingValueForOption() throws { let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let commandInfo = createTestCommand( - arguments: [createRequiredOption(name: "name")] + let commandInfo = self.createTestCommand( + arguments: [self.createRequiredOption(name: "name")] ) - + #expect(throws: Error.self) { _ = try promptingSystem.promptUser( command: commandInfo, @@ -1424,18 +1577,19 @@ struct TemplateTests{ } } - @Test func handlesNestedSubcommands() throws { - let innerSubcommand = createTestCommand( + @Test + func handlesNestedSubcommands() throws { + let innerSubcommand = self.createTestCommand( name: "create", - arguments: [createRequiredOption(name: "name")] + arguments: [self.createRequiredOption(name: "name")] ) - let outerSubcommand = createTestCommand( + let outerSubcommand = self.createTestCommand( name: "package", subcommands: [innerSubcommand] ) - let mainCommand = createTestCommand( + let mainCommand = self.createTestCommand( name: "swift", subcommands: [outerSubcommand] ) @@ -1454,24 +1608,25 @@ struct TemplateTests{ } // MARK: - Integration Tests - - @Test func handlesComplexCommandStructure() throws { + + @Test + func handlesComplexCommandStructure() throws { let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let complexCommand = createTestCommand( + let complexCommand = self.createTestCommand( arguments: [ - createRequiredOption(name: "name"), - createOptionalOption(name: "output", defaultValue: "./build"), - createOptionalFlag(name: "verbose", defaultValue: "false"), - createPositionalArgument(name: "target", isOptional: true, defaultValue: "main") + self.createRequiredOption(name: "name"), + self.createOptionalOption(name: "output", defaultValue: "./build"), + self.createOptionalFlag(name: "verbose", defaultValue: "false"), + self.createPositionalArgument(name: "target", isOptional: true, defaultValue: "main"), ] ) - + let result = try promptingSystem.promptUser( command: complexCommand, arguments: ["--name", "TestPackage", "--verbose", "CustomTarget"] ) - + #expect(result.contains("--name")) #expect(result.contains("TestPackage")) #expect(result.contains("--verbose")) @@ -1480,28 +1635,30 @@ struct TemplateTests{ #expect(result.contains("--output")) #expect(result.contains("./build")) } - - @Test func handlesEmptyInputCorrectly() throws { + + @Test + func handlesEmptyInputCorrectly() throws { let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let commandInfo = createTestCommand( + let commandInfo = self.createTestCommand( arguments: [ - createOptionalOption(name: "output", defaultValue: "default"), - createOptionalFlag(name: "verbose", defaultValue: "false") + self.createOptionalOption(name: "output", defaultValue: "default"), + self.createOptionalFlag(name: "verbose", defaultValue: "false"), ] ) - + let result = try promptingSystem.promptUser( command: commandInfo, arguments: [] ) - + // Should contain default values where appropriate #expect(result.contains("--output")) #expect(result.contains("default")) #expect(!result.contains("--verbose")) // false flag shouldn't appear } - @Test func handlesRepeatingArguments() throws { + @Test + func handlesRepeatingArguments() throws { let repeatingArg = ArgumentInfoV0( kind: .option, shouldDisplay: true, @@ -1519,21 +1676,22 @@ struct TemplateTests{ abstract: "Define parameter", discussion: nil ) - + let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let commandInfo = createTestCommand(arguments: [repeatingArg]) - + let commandInfo = self.createTestCommand(arguments: [repeatingArg]) + let result = try promptingSystem.promptUser( command: commandInfo, arguments: ["--define", "FOO=bar", "--define", "BAZ=qux"] ) - + #expect(result.contains("--define")) #expect(result.contains("FOO=bar")) #expect(result.contains("BAZ=qux")) } - @Test func handlesArgumentValidationWithCustomCompletions() throws { + @Test + func handlesArgumentValidationWithCustomCompletions() throws { let completionArg = ArgumentInfoV0( kind: .option, shouldDisplay: true, @@ -1551,17 +1709,17 @@ struct TemplateTests{ abstract: "Target platform", discussion: nil ) - + let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let commandInfo = createTestCommand(arguments: [completionArg]) - + let commandInfo = self.createTestCommand(arguments: [completionArg]) + // Valid completion value should work let validResult = try promptingSystem.promptUser( command: commandInfo, arguments: ["--platform", "iOS"] ) #expect(validResult.contains("iOS")) - + // Invalid completion value should throw #expect(throws: Error.self) { _ = try promptingSystem.promptUser( @@ -1571,11 +1729,12 @@ struct TemplateTests{ } } - @Test func handlesArgumentResponseBuilding() throws { - let flagArg = createOptionalFlag(name: "verbose") - let optionArg = createRequiredOption(name: "output") - let positionalArg = createPositionalArgument(name: "target") - + @Test + func handlesArgumentResponseBuilding() throws { + let flagArg = self.createOptionalFlag(name: "verbose") + let optionArg = self.createRequiredOption(name: "output") + let positionalArg = self.createPositionalArgument(name: "target") + // Test various response scenarios let flagResponse = TemplatePromptingSystem.ArgumentResponse( argument: flagArg, @@ -1583,14 +1742,14 @@ struct TemplateTests{ isExplicitlyUnset: false ) #expect(flagResponse.commandLineFragments == ["--verbose"]) - + let optionResponse = TemplatePromptingSystem.ArgumentResponse( argument: optionArg, values: ["./output"], isExplicitlyUnset: false ) #expect(optionResponse.commandLineFragments == ["--output", "./output"]) - + let positionalResponse = TemplatePromptingSystem.ArgumentResponse( argument: positionalArg, values: ["MyTarget"], @@ -1598,16 +1757,17 @@ struct TemplateTests{ ) #expect(positionalResponse.commandLineFragments == ["MyTarget"]) } - - @Test func handlesMissingArgumentErrors() throws { + + @Test + func handlesMissingArgumentErrors() throws { let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let commandInfo = createTestCommand( + let commandInfo = self.createTestCommand( arguments: [ - createRequiredOption(name: "required-arg"), - createOptionalOption(name: "optional-arg") + self.createRequiredOption(name: "required-arg"), + self.createOptionalOption(name: "optional-arg"), ] ) - + // Should throw when required argument is missing #expect(throws: Error.self) { _ = try promptingSystem.promptUser( @@ -1619,14 +1779,15 @@ struct TemplateTests{ // MARK: - Parsing Strategy Tests - @Test func handlesParsingStrategies() throws { - let upToNextOptionArg = createRequiredOption( + @Test + func handlesParsingStrategies() throws { + let upToNextOptionArg = self.createRequiredOption( name: "files", parsingStrategy: .upToNextOption ) let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let commandInfo = createTestCommand(arguments: [upToNextOptionArg]) + let commandInfo = self.createTestCommand(arguments: [upToNextOptionArg]) let result = try promptingSystem.promptUser( command: commandInfo, @@ -1637,7 +1798,8 @@ struct TemplateTests{ #expect(result.contains("file1.swift")) } - @Test func handlesTerminatorParsing() throws { + @Test + func handlesTerminatorParsing() throws { let postTerminatorArg = ArgumentInfoV0( kind: .option, shouldDisplay: true, @@ -1655,20 +1817,20 @@ struct TemplateTests{ abstract: "Post-terminator arguments", discussion: nil ) - + let promptingSystem = TemplatePromptingSystem(hasTTY: false) - let commandInfo = createTestCommand( + let commandInfo = self.createTestCommand( arguments: [ - createRequiredOption(name: "name"), - postTerminatorArg + self.createRequiredOption(name: "name"), + postTerminatorArg, ] ) - + let result = try promptingSystem.promptUser( command: commandInfo, arguments: ["--name", "TestPackage", "--", "arg1", "arg2"] ) - + #expect(result.contains("--name")) #expect(result.contains("TestPackage")) // Post-terminator args should be handled separately @@ -1676,6 +1838,7 @@ struct TemplateTests{ } // MARK: - Template Plugin Coordinator Tests + @Suite( .tags( Tag.TestSize.medium, @@ -1683,12 +1846,12 @@ struct TemplateTests{ ), ) struct TemplatePluginCoordinatorTests { - - @Test func createsCoordinatorWithValidConfiguration() async throws { + @Test + func createsCoordinatorWithValidConfiguration() async throws { try testWithTemporaryDirectory { tempDir in let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - + let coordinator = TemplatePluginCoordinator( buildSystem: .native, swiftCommandState: tool, @@ -1697,13 +1860,15 @@ struct TemplateTests{ args: ["--name", "TestPackage"], branches: [] ) - + // Test coordinator functionality by verifying it can handle basic operations #expect(coordinator.buildSystem == .native) #expect(coordinator.scratchDirectory == tempDir) } } - @Test func loadsPackageGraphInTemporaryWorkspace() async throws { //precondition linux error + + @Test + func loadsPackageGraphInTemporaryWorkspace() async throws { // precondition linux error try await fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in try await testWithTemporaryDirectory { tempDir in let options = try GlobalOptions.parse([]) @@ -1712,7 +1877,7 @@ struct TemplateTests{ // Copy template to temporary directory for workspace loading let workspaceDir = tempDir.appending("workspace") try tool.fileSystem.copy(from: templatePath, to: workspaceDir) - + let coordinator = TemplatePluginCoordinator( buildSystem: .native, swiftCommandState: tool, @@ -1721,7 +1886,7 @@ struct TemplateTests{ args: ["--name", "TestPackage"], branches: [] ) - + // Test coordinator's ability to load package graph // The coordinator handles the workspace switching internally let graph = try await coordinator.loadPackageGraph() @@ -1730,11 +1895,12 @@ struct TemplateTests{ } } - @Test func handlesInvalidTemplateGracefully() async throws { + @Test + func handlesInvalidTemplateGracefully() async throws { try await testWithTemporaryDirectory { tempDir in let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - + let coordinator = TemplatePluginCoordinator( buildSystem: .native, swiftCommandState: tool, @@ -1743,7 +1909,7 @@ struct TemplateTests{ args: ["--name", "TestPackage"], branches: [] ) - + // Test that coordinator handles invalid template name by throwing appropriate error await #expect(throws: (any Error).self) { _ = try await coordinator.loadPackageGraph() @@ -1753,6 +1919,7 @@ struct TemplateTests{ } // MARK: - Template Plugin Runner Tests + @Suite( .tags( Tag.TestSize.medium, @@ -1760,19 +1927,19 @@ struct TemplateTests{ ), ) struct TemplatePluginRunnerTests { - - @Test func handlesPluginExecutionForValidPackage() async throws { //precondition linux error + @Test + func handlesPluginExecutionForValidPackage() async throws { // precondition linux error try await fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in - try await testWithTemporaryDirectory { tempDir in + try await testWithTemporaryDirectory { _ in let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - + // Test that TemplatePluginRunner can handle static execution try await tool.withTemporaryWorkspace(switchingTo: templatePath) { _, _ in let graph = try await tool.loadPackageGraph() let rootPackage = graph.rootPackages.first! - + // Verify we can identify plugins for execution let pluginModules = rootPackage.modules.filter { $0.type == .plugin } #expect(!pluginModules.isEmpty, "Template should have plugin modules") @@ -1781,24 +1948,28 @@ struct TemplateTests{ } } - @Test func handlesPluginExecutionStaticAPI() async throws { //precondition linux error + @Test + func handlesPluginExecutionStaticAPI() async throws { // precondition linux error try await fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in try await testWithTemporaryDirectory { tempDir in let packagePath = tempDir.appending("TestPackage") try makeDirectories(packagePath) - + let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - + // Test that TemplatePluginRunner static API works with valid input try await tool.withTemporaryWorkspace(switchingTo: templatePath) { _, _ in let graph = try await tool.loadPackageGraph() let rootPackage = graph.rootPackages.first! - + // Test plugin execution readiness #expect(!graph.rootPackages.isEmpty, "Should have root packages for plugin execution") - #expect(rootPackage.modules.contains { $0.type == .plugin }, "Should have plugin modules available") + #expect( + rootPackage.modules.contains { $0.type == .plugin }, + "Should have plugin modules available" + ) } } } @@ -1806,6 +1977,7 @@ struct TemplateTests{ } // MARK: - Template Build Support Tests + @Suite( .tags( Tag.TestSize.medium, @@ -1813,30 +1985,31 @@ struct TemplateTests{ ), ) struct TemplateBuildSupportTests { - - @Test func buildForTestingWithValidTemplate() async throws { //precondition linux error + @Test + func buildForTestingWithValidTemplate() async throws { // precondition linux error try await fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in - try await testWithTemporaryDirectory { tempDir in + try await testWithTemporaryDirectory { _ in let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) let buildOptions = try BuildCommandOptions.parse([]) - + // Test TemplateBuildSupport static API for building templates try await TemplateBuildSupport.buildForTesting( swiftCommandState: tool, buildOptions: buildOptions, testingFolder: templatePath ) - + // Verify build succeeds without errors #expect(tool.fileSystem.exists(templatePath), "Template path should still exist after build") } } } - @Test func buildWithValidConfiguration() async throws { //build system provider error + @Test + func buildWithValidConfiguration() async throws { // build system provider error try await fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in - try await testWithTemporaryDirectory { tempDir in + try await testWithTemporaryDirectory { _ in let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) let buildOptions = try BuildCommandOptions.parse([]) @@ -1850,15 +2023,19 @@ struct TemplateTests{ cwd: templatePath, transitiveFolder: nil ) - + // Verify build configuration works with template - #expect(tool.fileSystem.exists(templatePath.appending("Package.swift")), "Package.swift should exist") + #expect( + tool.fileSystem.exists(templatePath.appending("Package.swift")), + "Package.swift should exist" + ) } } } } // MARK: - InitTemplatePackage Tests + @Suite( .tags( Tag.TestSize.medium, @@ -1867,31 +2044,31 @@ struct TemplateTests{ ), ) struct InitTemplatePackageTests { - - @Test func createsTemplatePackageWithValidConfiguration() async throws { + @Test + func createsTemplatePackageWithValidConfiguration() async throws { try fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in try testWithTemporaryDirectory { tempDir in let packagePath = tempDir.appending("TestPackage") let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - + // Create package dependency for template let dependency = SwiftRefactor.PackageDependency.fileSystem( SwiftRefactor.PackageDependency.FileSystem( path: templatePath.pathString ) ) - - let initPackage = InitTemplatePackage( + + let initPackage = try InitTemplatePackage( name: "TestPackage", initMode: dependency, fileSystem: tool.fileSystem, packageType: .executable, supportedTestingLibraries: [.xctest], destinationPath: packagePath, - installedSwiftPMConfiguration: try tool.getHostToolchain().installedSwiftPMConfiguration + installedSwiftPMConfiguration: tool.getHostToolchain().installedSwiftPMConfiguration ) - + // Test package configuration #expect(initPackage.packageName == "TestPackage") #expect(initPackage.packageType == .executable) @@ -1900,31 +2077,32 @@ struct TemplateTests{ } } - @Test func writesPackageStructureWithTemplateDependency() async throws { + @Test + func writesPackageStructureWithTemplateDependency() async throws { try fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in try testWithTemporaryDirectory { tempDir in let packagePath = tempDir.appending("TestPackage") let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - + let dependency = SwiftRefactor.PackageDependency.fileSystem( SwiftRefactor.PackageDependency.FileSystem( path: templatePath.pathString ) ) - - let initPackage = InitTemplatePackage( + + let initPackage = try InitTemplatePackage( name: "TestPackage", initMode: dependency, fileSystem: tool.fileSystem, packageType: .executable, supportedTestingLibraries: [.xctest], destinationPath: packagePath, - installedSwiftPMConfiguration: try tool.getHostToolchain().installedSwiftPMConfiguration + installedSwiftPMConfiguration: tool.getHostToolchain().installedSwiftPMConfiguration ) try initPackage.setupTemplateManifest() - + // Verify package structure was created #expect(tool.fileSystem.exists(packagePath)) #expect(tool.fileSystem.exists(packagePath.appending("Package.swift"))) @@ -1933,7 +2111,8 @@ struct TemplateTests{ } } - @Test func handlesInvalidTemplatePath() async throws { + @Test + func handlesInvalidTemplatePath() async throws { try await testWithTemporaryDirectory { tempDir in let invalidTemplatePath = tempDir.appending("NonexistentTemplate") let options = try GlobalOptions.parse([]) @@ -1941,13 +2120,18 @@ struct TemplateTests{ // Should handle invalid template path gracefully await #expect(throws: (any Error).self) { - let _ = try await TemplatePackageInitializer.inferPackageType(from: invalidTemplatePath, templateName: "foo", swiftCommandState: tool) + _ = try await TemplatePackageInitializer.inferPackageType( + from: invalidTemplatePath, + templateName: "foo", + swiftCommandState: tool + ) } } } } // MARK: - Integration Tests for Template Workflows + @Suite( .tags( Tag.TestSize.large, @@ -1956,7 +2140,6 @@ struct TemplateTests{ ), ) struct TemplateWorkflowIntegrationTests { - @Test( .skipHostOS(.windows, "Template operations not fully supported in test environment"), arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), @@ -1980,10 +2163,10 @@ struct TemplateTests{ packageIdentity: nil, swiftCommandState: tool ) - + let resolvedPath = try await resolver.resolve() #expect(resolvedPath == templatePath) - + // Create package dependency builder let dependencyBuilder = DefaultPackageDependencyBuilder( templateSource: .local, @@ -1994,45 +2177,45 @@ struct TemplateTests{ registryRequirement: nil, resolvedTemplatePath: resolvedPath ) - + let packageDependency = try dependencyBuilder.makePackageDependency() - + // Verify dependency was created correctly if case .fileSystem(let fileSystemDep) = packageDependency { #expect(fileSystemDep.path == resolvedPath.pathString) } else { Issue.record("Expected fileSystem dependency, got \(packageDependency)") } - + // Create template package - let initPackage = InitTemplatePackage( + let initPackage = try InitTemplatePackage( name: "TestPackage", initMode: packageDependency, fileSystem: tool.fileSystem, packageType: .executable, supportedTestingLibraries: [.xctest], destinationPath: packagePath, - installedSwiftPMConfiguration: try tool.getHostToolchain().installedSwiftPMConfiguration + installedSwiftPMConfiguration: tool.getHostToolchain().installedSwiftPMConfiguration ) - + try initPackage.setupTemplateManifest() - + // Verify complete package structure #expect(tool.fileSystem.exists(packagePath)) expectFileExists(at: packagePath.appending("Package.swift")) expectDirectoryExists(at: packagePath.appending("Sources")) /* Bad memory access error here - // Verify package builds successfully - try await executeSwiftBuild( - packagePath, - configuration: data.config, - buildSystem: data.buildSystem - ) - - let buildPath = packagePath.appending(".build") - expectDirectoryExists(at: buildPath) - */ + // Verify package builds successfully + try await executeSwiftBuild( + packagePath, + configuration: data.config, + buildSystem: data.buildSystem + ) + + let buildPath = packagePath.appending(".build") + expectDirectoryExists(at: buildPath) + */ } } } @@ -2055,10 +2238,10 @@ struct TemplateTests{ let sourceControlURL = SourceControlURL(stringLiteral: templateRepoPath.pathString) let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - + // Test Git template resolution let sourceControlRequirement = SwiftRefactor.PackageDependency.SourceControl.Requirement.branch("main") - + let resolver = try TemplatePathResolver( source: .git, templateDirectory: nil, @@ -2068,22 +2251,23 @@ struct TemplateTests{ packageIdentity: nil, swiftCommandState: tool ) - + let resolvedPath = try await resolver.resolve() #expect(localFileSystem.exists(resolvedPath)) - + // Verify template was fetched correctly with expected files #expect(localFileSystem.exists(resolvedPath.appending("Package.swift"))) #expect(localFileSystem.exists(resolvedPath.appending("Templates"))) } } - @Test func pluginCoordinationWithBuildSystemIntegration() async throws { //Build provider not initialized. + @Test + func pluginCoordinationWithBuildSystemIntegration() async throws { // Build provider not initialized. try await fixture(name: "Miscellaneous/InitTemplates/ExecutableTemplate") { templatePath in try await testWithTemporaryDirectory { tempDir in let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - + // Test plugin coordination with build system let coordinator = TemplatePluginCoordinator( buildSystem: .native, @@ -2093,11 +2277,11 @@ struct TemplateTests{ args: ["--name", "TestPackage"], branches: [] ) - + // Test coordinator functionality #expect(coordinator.buildSystem == .native) #expect(coordinator.scratchDirectory == tempDir) - + // Test build support static API let buildOptions = try BuildCommandOptions.parse([]) try await TemplateBuildSupport.buildForTesting( @@ -2105,20 +2289,21 @@ struct TemplateTests{ buildOptions: buildOptions, testingFolder: templatePath ) - + // Verify they can work together (no errors thrown) #expect(coordinator.buildSystem == .native) } } } - @Test func packageDependencyBuildingWithVersionResolution() async throws { + @Test + func packageDependencyBuildingWithVersionResolution() async throws { let options = try GlobalOptions.parse([]) let tool = try SwiftCommandState.makeMockState(options: options) - + let lowerBoundVersion = Version(stringLiteral: "1.2.0") let higherBoundVersion = Version(stringLiteral: "3.0.0") - + // Test version requirement resolution integration let versionResolver = DependencyRequirementResolver( packageIdentity: "test.package", @@ -2130,23 +2315,23 @@ struct TemplateTests{ upToNextMinorFrom: nil, to: higherBoundVersion ) - + let sourceControlRequirement = try versionResolver.resolveSourceControl() let registryRequirement = try await versionResolver.resolveRegistry() - + // Test dependency building with resolved requirements - let dependencyBuilder = DefaultPackageDependencyBuilder( + let dependencyBuilder = try DefaultPackageDependencyBuilder( templateSource: .git, packageName: "TestPackage", templateURL: "https://github.com/example/template.git", templatePackageID: "test.package", sourceControlRequirement: sourceControlRequirement, registryRequirement: registryRequirement, - resolvedTemplatePath: try AbsolutePath(validating: "/fake/path") + resolvedTemplatePath: AbsolutePath(validating: "/fake/path") ) - + let gitDependency = try dependencyBuilder.makePackageDependency() - + // Verify dependency structure if case .sourceControl(let sourceControlDep) = gitDependency { #expect(sourceControlDep.location == "https://github.com/example/template.git") @@ -2163,6 +2348,7 @@ struct TemplateTests{ } // MARK: - End-to-End Template Initialization Tests + @Suite( .tags( Tag.TestSize.large, @@ -2171,9 +2357,11 @@ struct TemplateTests{ ), ) struct EndToEndTemplateInitializationTests { - @Test func templateInitializationErrorHandling() async throws { + @Test + func templateInitializationErrorHandling() async throws { try await testWithTemporaryDirectory { tempDir in let packagePath = tempDir.appending("TestPackage") + try FileManager.default.createDirectory(at: packagePath.asURL, withIntermediateDirectories: true, attributes: nil) let nonexistentPath = tempDir.appending("nonexistent-template") let options = try GlobalOptions.parse(["--package-path", packagePath.pathString]) let tool = try SwiftCommandState.makeMockState(options: options) @@ -2199,11 +2387,11 @@ struct TemplateTests{ ) let initializer = try configuration.makeInitializer() - + // Change to package directory try tool.fileSystem.changeCurrentWorkingDirectory(to: packagePath) try tool.fileSystem.createDirectory(packagePath, recursive: true) - + try await initializer.run() } @@ -2212,7 +2400,8 @@ struct TemplateTests{ } } - @Test func standardPackageInitializerFallback() async throws { + @Test + func standardPackageInitializerFallback() async throws { try await testWithTemporaryDirectory { tmpPath in let fs = localFileSystem let path = tmpPath.appending("Foo") @@ -2224,7 +2413,7 @@ struct TemplateTests{ let configuration = try PackageInitConfiguration( swiftCommandState: tool, name: "TestPackage", - initMode: "executable", // Standard package type + initMode: "executable", // Standard package type testLibraryOptions: TestLibraryOptions.parse([]), buildOptions: BuildCommandOptions.parse([]), globalOptions: options, @@ -2241,13 +2430,15 @@ struct TemplateTests{ let initializer = try configuration.makeInitializer() #expect(initializer is StandardPackageInitializer) - + // Change to package directory try await initializer.run() // Verify standard package was created #expect(tool.fileSystem.exists(path.appending("Package.swift"))) - #expect(try fs.getDirectoryContents(path.appending("Sources").appending("TestPackage")) == ["TestPackage.swift"]) + #expect(try fs + .getDirectoryContents(path.appending("Sources").appending("TestPackage")) == ["TestPackage.swift"] + ) } } } From 6681eb17dd69656371411d2385745f289a81bc0c Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 23 Sep 2025 13:26:06 -0400 Subject: [PATCH 119/225] formatting + reverting unnecessary changes --- .../_InternalInitSupport/PackageDependencyBuilder.swift | 5 +++++ Sources/PackageRegistryCommand/PackageRegistryCommand.swift | 2 +- Tests/CommandsTests/TemplateTests.swift | 6 +++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift index e410eb8bc59..5d32392d7bc 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift @@ -54,8 +54,13 @@ struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { /// The registry package identifier, if the template source is registry-based. let templatePackageID: String? + /// The version requirements for fetching a template from git. let sourceControlRequirement: PackageDependency.SourceControl.Requirement? + + /// The version requirements for fetching a template from registry. let registryRequirement: PackageDependency.Registry.Requirement? + + /// The location of the template on disk. let resolvedTemplatePath: Basics.AbsolutePath /// Constructs a package dependency kind based on the selected template source. diff --git a/Sources/PackageRegistryCommand/PackageRegistryCommand.swift b/Sources/PackageRegistryCommand/PackageRegistryCommand.swift index 1858a16bcd3..f41004fcd8e 100644 --- a/Sources/PackageRegistryCommand/PackageRegistryCommand.swift +++ b/Sources/PackageRegistryCommand/PackageRegistryCommand.swift @@ -31,7 +31,7 @@ public struct PackageRegistryCommand: AsyncParsableCommand { Unset.self, Login.self, Logout.self, - Publish.self + Publish.self, ], helpNames: [.short, .long, .customLong("help", withSingleDash: true)] ) diff --git a/Tests/CommandsTests/TemplateTests.swift b/Tests/CommandsTests/TemplateTests.swift index 12368f890e5..c2ce35bd551 100644 --- a/Tests/CommandsTests/TemplateTests.swift +++ b/Tests/CommandsTests/TemplateTests.swift @@ -2361,7 +2361,11 @@ struct TemplateTests { func templateInitializationErrorHandling() async throws { try await testWithTemporaryDirectory { tempDir in let packagePath = tempDir.appending("TestPackage") - try FileManager.default.createDirectory(at: packagePath.asURL, withIntermediateDirectories: true, attributes: nil) + try FileManager.default.createDirectory( + at: packagePath.asURL, + withIntermediateDirectories: true, + attributes: nil + ) let nonexistentPath = tempDir.appending("nonexistent-template") let options = try GlobalOptions.parse(["--package-path", packagePath.pathString]) let tool = try SwiftCommandState.makeMockState(options: options) From 91b8626df1e697c4b218bb063548e7c31b25b624 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 23 Sep 2025 14:10:25 -0400 Subject: [PATCH 120/225] documentation --- .../InitTemplates/ExecutableTemplate/Package.swift | 2 +- Package.swift | 3 +-- Sources/Commands/PackageCommands/Init.swift | 12 +++++++++++- .../Commands/PackageCommands/PluginCommand.swift | 1 - .../Commands/TestCommands/TestTemplateCommand.swift | 5 +++++ .../_InternalInitSupport/PackageInitializer.swift | 12 +++++++++++- .../TemplateTestDirectoryManager.swift | 3 +++ Sources/CoreCommands/SwiftCommandState.swift | 12 ++++++++++++ Sources/SourceControl/GitRepository.swift | 13 ++++++++++++- .../TemplateDirectoryManager.swift | 7 +++++++ 10 files changed, 63 insertions(+), 7 deletions(-) diff --git a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift index d2399bd40c3..a2983f0ae0e 100644 --- a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift +++ b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:999.0.0 +// swift-tools-version:6.3.0 import PackageDescription diff --git a/Package.swift b/Package.swift index 775cc39739a..b39d25997a9 100644 --- a/Package.swift +++ b/Package.swift @@ -613,8 +613,7 @@ let package = Package( "Workspace", "XCBuildSupport", "SwiftBuildSupport", - "SwiftFixIt", - "PackageRegistry", + "SwiftFixIt" ] + swiftSyntaxDependencies(["SwiftIDEUtils", "SwiftRefactor"]), exclude: ["CMakeLists.txt", "README.md"], swiftSettings: swift6CompatibleExperimentalFeatures + [ diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 0df5fc260f6..b905ff8fe1c 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -23,6 +23,7 @@ import TSCUtility import Workspace extension SwiftPackageCommand { + /// Initialize a new package. struct Init: AsyncSwiftCommand { static let configuration = CommandConfiguration( abstract: "Initialize a new package." @@ -54,6 +55,7 @@ extension SwiftPackageCommand { @OptionGroup(visibility: .hidden) var testLibraryOptions: TestLibraryOptions + /// Provide custom package name. @Option(name: .customLong("name"), help: "Provide custom package name.") var packageName: String? @@ -98,7 +100,7 @@ extension SwiftPackageCommand { @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? - /// Validation step to build package post generation and run if package is of type executable + /// Validation step to build package post generation and run if package is of type executable. @Flag( name: .customLong("validate-package"), help: "Run 'swift build' after package generation to validate the template." @@ -168,6 +170,7 @@ extension InitPackage.PackageType { } } +/// Holds the configuration needed to initialize a package. struct PackageInitConfiguration { let packageName: String let cwd: Basics.AbsolutePath @@ -294,6 +297,7 @@ struct PackageInitConfiguration { } } +/// Represents version flags for package dependencies. public struct VersionFlags { let exact: Version? let revision: String? @@ -303,6 +307,7 @@ public struct VersionFlags { let to: Version? } +/// Protocol for resolving template sources from configuration parameters. protocol TemplateSourceResolver { func resolveSource( directory: Basics.AbsolutePath?, @@ -318,6 +323,7 @@ protocol TemplateSourceResolver { ) throws } +/// Default implementation of template source resolution. public struct DefaultTemplateSourceResolver: TemplateSourceResolver { let cwd: AbsolutePath let fileSystem: FileSystem @@ -334,6 +340,7 @@ public struct DefaultTemplateSourceResolver: TemplateSourceResolver { return nil } + /// Validates the provided template source configuration. func validate( templateSource: InitTemplatePackage.TemplateSource, directory: Basics.AbsolutePath?, @@ -360,10 +367,12 @@ public struct DefaultTemplateSourceResolver: TemplateSourceResolver { } } + /// Determines if the provided package ID is a valid registry package identity. private func isValidRegistryPackageIdentity(_ packageID: String) -> Bool { PackageIdentity.plain(packageID).isRegistry } + /// Validates if a given URL or path is a valid Git source. func isValidGitSource(_ input: String, fileSystem: FileSystem) -> Bool { if input.hasPrefix("http://") || input.hasPrefix("https://") || input.hasPrefix("git@") || input .hasPrefix("ssh://") @@ -383,6 +392,7 @@ public struct DefaultTemplateSourceResolver: TemplateSourceResolver { return false } + /// Validates that the provided path exists and is accessible. private func isValidSwiftPackage(path: AbsolutePath) throws { if !self.fileSystem.exists(path) { throw SourceResolverError.invalidDirectoryPath(path) diff --git a/Sources/Commands/PackageCommands/PluginCommand.swift b/Sources/Commands/PackageCommands/PluginCommand.swift index 0d23a09b978..8bc238af22a 100644 --- a/Sources/Commands/PackageCommands/PluginCommand.swift +++ b/Sources/Commands/PackageCommands/PluginCommand.swift @@ -430,7 +430,6 @@ struct PluginCommand: AsyncSwiftCommand { let plugin = $0.underlying as! PluginModule // Filter out any non-command plugins and any whose verb is different. guard case .command(let intent, _) = plugin.capability else { return false } - return verb == intent.invocationVerb } } diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index ac01b65f5e8..347efc9270c 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -44,6 +44,7 @@ extension DispatchTimeInterval { } extension SwiftTestCommand { + /// Test the various outputs of a template. struct Template: AsyncSwiftCommand { static let configuration = CommandConfiguration( abstract: "Test the various outputs of a template" @@ -55,9 +56,11 @@ extension SwiftTestCommand { @OptionGroup() var sharedOptions: SharedOptions + /// Specify name of the template. @Option(help: "Specify name of the template") var templateName: String? + /// Specify the output path of the created templates. @Option( name: .customLong("output-path"), help: "Specify the output path of the created templates.", @@ -74,6 +77,7 @@ extension SwiftTestCommand { ) var args: [String] = [] + /// Specify the branch of the template you want to test. @Option( name: .customLong("branches"), parsing: .upToNextOption, @@ -81,6 +85,7 @@ extension SwiftTestCommand { ) var branches: [String] = [] + /// Dry-run to display argument tree. @Flag(help: "Dry-run to display argument tree") var dryRun: Bool = false diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index c473b54dcdf..1546cacc20a 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -16,10 +16,12 @@ import Workspace import class PackageModel.Manifest +/// Protocol for package initialization implementations. protocol PackageInitializer { func run() async throws } +/// Initializes a package from a template source. struct TemplatePackageInitializer: PackageInitializer { let packageName: String let cwd: Basics.AbsolutePath @@ -35,6 +37,7 @@ struct TemplatePackageInitializer: PackageInitializer { let args: [String] let swiftCommandState: SwiftCommandState + /// Runs the template initialization process. func run() async throws { do { var sourceControlRequirement: PackageDependency.SourceControl.Requirement? @@ -156,7 +159,7 @@ struct TemplatePackageInitializer: PackageInitializer { } } - // Will have to add checking for git + registry too + /// Infers the package type from a template at the given path. static func inferPackageType( from templatePath: Basics.AbsolutePath, templateName: String?, @@ -190,6 +193,7 @@ struct TemplatePackageInitializer: PackageInitializer { } } + /// Finds the template name from a manifest. static func findTemplateName(from manifest: Manifest) throws -> String { let templateTargets = manifest.targets.compactMap { target -> String? in if let options = target.templateInitializationOptions, @@ -210,6 +214,7 @@ struct TemplatePackageInitializer: PackageInitializer { } } + /// Finds the template name from a template path. func findTemplateName(from templatePath: Basics.AbsolutePath) async throws -> String { try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { workspace, root in let rootManifests = try await workspace.loadRootManifests( @@ -225,6 +230,7 @@ struct TemplatePackageInitializer: PackageInitializer { } } + /// Sets up the package with the template dependency. private func setUpPackage( builder: DefaultPackageDependencyBuilder, packageType: InitPackage.PackageType, @@ -244,6 +250,7 @@ struct TemplatePackageInitializer: PackageInitializer { return templatePackage } + /// Errors that can occur during template package initialization. enum TemplatePackageInitializerError: Error, CustomStringConvertible { case invalidManifestInTemplate(String) case templateNotFound(String) @@ -265,6 +272,7 @@ struct TemplatePackageInitializer: PackageInitializer { } } +/// Initializes a package using built-in templates. struct StandardPackageInitializer: PackageInitializer { let packageName: String let initMode: String? @@ -272,6 +280,7 @@ struct StandardPackageInitializer: PackageInitializer { let cwd: Basics.AbsolutePath let swiftCommandState: SwiftCommandState + /// Runs the standard package initialization process. func run() async throws { guard let initModeString = self.initMode else { throw StandardPackageInitializerError.missingInitMode @@ -310,6 +319,7 @@ struct StandardPackageInitializer: PackageInitializer { try initPackage.writePackageStructure() } + /// Errors that can occur during standard package initialization. enum StandardPackageInitializerError: Error, CustomStringConvertible { case missingInitMode case unsupportedPackageType(String) diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift index 11668157ec4..dbf4d17db84 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift @@ -4,6 +4,7 @@ import Foundation import PackageModel import Workspace +/// Manages directories for template testing operations. public struct TemplateTestingDirectoryManager { let fileSystem: FileSystem let helper: TemporaryDirectoryHelper @@ -15,11 +16,13 @@ public struct TemplateTestingDirectoryManager { self.observabilityScope = observabilityScope } + /// Creates temporary directories for testing operations. public func createTemporaryDirectories(directories: Set) throws -> [Basics.AbsolutePath] { let tempDir = try helper.createTemporaryDirectory() return try self.helper.createSubdirectories(in: tempDir, names: Array(directories)) } + /// Creates the output directory for test results. public func createOutputDirectory( outputDirectoryPath: Basics.AbsolutePath, swiftCommandState: SwiftCommandState diff --git a/Sources/CoreCommands/SwiftCommandState.swift b/Sources/CoreCommands/SwiftCommandState.swift index 94c98322680..ac937f9e2e3 100644 --- a/Sources/CoreCommands/SwiftCommandState.swift +++ b/Sources/CoreCommands/SwiftCommandState.swift @@ -1314,6 +1314,18 @@ extension Basics.Diagnostic { } extension SwiftCommandState { + /// Temporarily switches to a different package directory and executes the provided closure. + /// + /// This method temporarily changes the current working directory and workspace context + /// to operate on a different package. It handles all the necessary state management + /// including workspace initialization, file system changes, and cleanup. + /// + /// - Parameters: + /// - packagePath: The absolute path to switch to + /// - createPackagePath: Whether to create the directory if it doesn't exist + /// - perform: The closure to execute in the temporary workspace context + /// - Returns: The result of the performed closure + /// - Throws: Any error thrown by the closure or during workspace setup public func withTemporaryWorkspace( switchingTo packagePath: AbsolutePath, createPackagePath: Bool = true, diff --git a/Sources/SourceControl/GitRepository.swift b/Sources/SourceControl/GitRepository.swift index 7baea16d677..cb8c97577c3 100644 --- a/Sources/SourceControl/GitRepository.swift +++ b/Sources/SourceControl/GitRepository.swift @@ -224,6 +224,18 @@ public struct GitRepositoryProvider: RepositoryProvider, Cancellable { } } + /// Creates a working copy from a bare repository. + /// + /// This method creates a working copy (checkout) from a bare repository source. + /// It supports both editable and shared modes of operation. + /// + /// - Parameters: + /// - repository: The repository specifier + /// - sourcePath: Path to the bare repository source + /// - destinationPath: Path where the working copy should be created + /// - editable: If true, creates an editable clone; if false, uses shared storage + /// - Returns: A WorkingCheckout instance for the created working copy + /// - Throws: Git operation errors if cloning or setup fails public func createWorkingCopyFromBare( repository: RepositorySpecifier, sourcePath: Basics.AbsolutePath, @@ -238,7 +250,6 @@ public struct GitRepositoryProvider: RepositoryProvider, Cancellable { destinationPath.pathString, [] ) - // The default name of the remote. let origin = "origin" // In destination repo remove the remote which will be pointing to the source repo. diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift b/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift index 69e32e2f5fd..9a5ea51c686 100644 --- a/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift +++ b/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift @@ -1,6 +1,7 @@ import Basics import Foundation +/// A helper for managing temporary directories used in filesystem operations. public struct TemporaryDirectoryHelper { let fileSystem: FileSystem @@ -8,6 +9,7 @@ public struct TemporaryDirectoryHelper { self.fileSystem = fileSystem } + /// Creates a temporary directory with an optional name. public func createTemporaryDirectory(named name: String? = nil) throws -> Basics.AbsolutePath { let dirName = name ?? UUID().uuidString let dirPath = try fileSystem.tempDirectory.appending(component: dirName) @@ -15,6 +17,7 @@ public struct TemporaryDirectoryHelper { return dirPath } + /// Creates multiple subdirectories within a parent directory. public func createSubdirectories(in parent: Basics.AbsolutePath, names: [String]) throws -> [Basics.AbsolutePath] { return try names.map { name in let path = parent.appending(component: name) @@ -23,16 +26,19 @@ public struct TemporaryDirectoryHelper { } } + /// Checks if a directory exists at the given path. public func directoryExists(_ path: Basics.AbsolutePath) -> Bool { return fileSystem.exists(path) } + /// Removes a directory if it exists. public func removeDirectoryIfExists(_ path: Basics.AbsolutePath) throws { if fileSystem.exists(path) { try fileSystem.removeFileTree(path) } } + /// Copies the contents of one directory to another. public func copyDirectoryContents(from sourceDir: AbsolutePath, to destinationDir: AbsolutePath) throws { let contents = try fileSystem.getDirectoryContents(sourceDir) for entry in contents { @@ -43,6 +49,7 @@ public struct TemporaryDirectoryHelper { } } +/// Errors that can occur during directory management operations. public enum DirectoryManagerError: Error, CustomStringConvertible { case failedToRemoveDirectory(path: Basics.AbsolutePath, underlying: Error) case foundManifestFile(path: Basics.AbsolutePath) From c62dba8783c38fb593e01174222a699686e6b710 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 24 Sep 2025 09:41:31 -0400 Subject: [PATCH 121/225] documentation for authoring, initializing from, and testing templates --- Documentation/Templates.md | 498 +++++++++++++++++++++++++++++++++++++ Documentation/Usage.md | 74 ++++++ 2 files changed, 572 insertions(+) create mode 100644 Documentation/Templates.md diff --git a/Documentation/Templates.md b/Documentation/Templates.md new file mode 100644 index 00000000000..d0b1f79ae99 --- /dev/null +++ b/Documentation/Templates.md @@ -0,0 +1,498 @@ +# Getting Started with Templates + +This guide provides a brief overview of Swift Package Manager templates, describes how a package can make use of templates, and shows how to get started writing your own templates. + +## Overview + +User-defined custom _templates_ allow generation of packages whose functionality goes beyond the hard-coded templates provided by Swift Package Manager. Package templates are written in Swift using Swift Argument Parser and the `PackagePlugin` API provided by the Swift Package Manager. + +A template is represented in the SwiftPM package manifest as a target of the `templateTarget` type — and should be available to other packages by declaring a corresponding `template` product. Source code for a template is normally located in a directory under the `Templates` directory in the package, but this can be customized. However, as seen below, authors will also need to write the source code for a plugin. + +Templates are an abstraction of two types of modules: +- a template _executable_ that performs the file generation and project setup +- a command-line _plugin_ that safely invokes the executable + +The command-line plugin allows the template executable to run in a separate process, and (on platforms that support sandboxing) it is wrapped in a sandbox that prevents network access as well as attempts to write to arbitrary locations in the file system. Template plugins have access to the representation of the package model, which can be used by the template whenever the context of a package is needed — for example, to infer sensible defaults or validate user inputs against existing package structure. + +The executable allows authors to define user-facing interfaces which gather important consumer input needed by the template to run, using Swift Argument Parser for a rich command-line experience with subcommands, options, and flags. + +## Using a Package Template + +A package template is available to the package that defines it and to any other package that can reference the template package as a source, as it is mandatory to declare it as a product. + +Templates are invoked using the `swift package init` command: + +```shell +❯ swift package init --type MyTemplate --url https://github.com/author/template-example +``` + +Templates can be sourced from package registries, Git repositories, or local paths: + +```bash +# From a package registry +swift package init --type MyTemplate --package-id author.template-example + +# From a Git repository +swift package init --type MyTemplate --url https://github.com/author/template-example + +# From a local directory +swift package init --type MyTemplate --path /path/to/template +``` + +Any command line arguments that appear after the template type are passed to the template executable — these can be used to skip interactive prompts or specify configuration: + +```bash +swift package init --type ServerTemplate --package-id example.templates -- crud --database postgresql --readme +``` + +Templates support the same versioning constraints as regular package dependencies: exact, range, branches, and revisions: + +```bash +swift package init --type MyTemplate --package-id author.template --from 1.0.0 +swift package init --type MyTemplate --url https://github.com/author/template --branch main +``` + +The `--build-package` flag can be used to automatically build the template output: + +```bash +swift package init --type MyTemplate --package-id author.template --build-package +``` + +## Writing a Template + +The first step when writing a package template is to decide what kind of template you need and what base package structure it should start with. Templates can generate any kind of Swift package — executables, libraries, plugins, or even empty packages that will be further customized. + +### Declaring a template in the package manifest + +Like all package components, templates are declared in the package manifest. This is done using a `templateTarget` entry in the `targets` section of the package. Templates must be visible to other packages in order to be ran. Thus, there needs to be a corresponding `template` entry in the `products` section as well: + +```swift +// swift-tools-version: 6.1 +import PackageDescription + +let package = Package( + name: "MyTemplates", + products: [ + .template(name: "LibraryTemplate"), + .template(name: "ExecutableTemplate"), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + ], + targets: [ + .templateTarget( + name: "LibraryTemplate", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + initialPackageType: .library, + description: "Generate a Swift library package" + ), + .templateTarget( + name: "ExecutableTemplate", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + initialPackageType: .executable, + templatePermissions: [ + .writeToPackageDirectory(reason: "Generate source files and documentation"), + ], + description: "Generate an executable package with optional features" + ), + ] +) +``` + +The `templateTarget` declares the name and capability of the template, along with its dependencies. The `initialPackageType` specifies the base package structure that SwiftPM will set up before invoking the template — this can be `.library`, `.executable`, `.tool`, `.buildToolPlugin`, `.commandPlugin`, `.macro`, or `.empty`. + +The Swift script files that implement the logic of the template are expected to be in a directory named the same as the template, located under the `Templates` subdirectory of the package. The template also expects Swift script files in a directory with the same name as the template, alongside a `Plugin` suffix, located under the `Plugins` subdirectory of the package. + +The `template` product is what makes the template visible to other packages. The name of the template product must match the name of the target. + +#### Template target dependencies + +The dependencies specify the packages that will be available for use by the template executable. Each dependency can be any package product — commonly this includes Swift Argument Parser for command-line interface handling, but can also include utilities for file generation, string processing, or network requests if needed. + +#### Template permissions + +Templates specify what permissions they need through the `templatePermissions` parameter. Common permissions include: + +```swift +templatePermissions: [ + .writeToPackageDirectory(reason: "Generate project files"), + .allowNetworkConnections(scope: .none, reason: "Download additional resources"), +] +``` + +### Implementing the template command plugin script + +The command plugin for a template acts as a bridge between SwiftPM and the template executable. By default, Swift Package Manager looks for plugin implementations in subdirectories of the `Plugins` directory named with the template name followed by "Plugin". + +```swift +import Foundation +import PackagePlugin + +@main +struct LibraryTemplatePlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws { + let tool = try context.tool(named: "LibraryTemplate") + let packageDirectory = context.package.directoryURL.path + + let process = Process() + let stderrPipe = Pipe() + + process.executableURL = URL(filePath: tool.url.path()) + process.arguments = ["--package-directory", packageDirectory] + + arguments.filter { $0 != "--" } + process.standardError = stderrPipe + + try process.run() + process.waitUntilExit() + + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderrOutput = String(data: stderrData, encoding: .utf8) ?? "" + + if process.terminationStatus != 0 { + throw TemplateError.executionFailed( + code: process.terminationStatus, + stderrOutput: stderrOutput + ) + } + } + + enum TemplateError: Error, CustomStringConvertible { + case executionFailed(code: Int32, stderrOutput: String) + + var description: String { + switch self { + case .executionFailed(let code, let stderrOutput): + return """ + Template execution failed with exit code \(code). + + Error output: + \(stderrOutput) + """ + } + } + } +} +``` + +The plugin receives a `context` parameter that provides access to the consumer's package model and tool paths, similar to other SwiftPM plugins. The plugin is responsible for invoking the template executable with the appropriate arguments. + +### Implementing the template executable + +Template executables are Swift command-line programs that use Swift Argument Parser. The executable can define user-facing options, flags, arguments, subcommands, and hidden arguments that can be filled by the template plugin's `context`: + +```swift +import ArgumentParser +import Foundation +import SystemPackage + +@main +struct LibraryTemplate: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "library-template", + abstract: "Generate a Swift library package with configurable features" + ) + + @OptionGroup(visibility: .hidden) + var packageOptions: PackageOptions + + @Argument(help: "Name of the library") + var name: String + + @Flag(help: "Include example usage in README") + var examples: Bool = false + + func run() throws { + guard let packageDirectory = packageOptions.packageDirectory else { + throw TemplateError.missingPackageDirectory + } + + print("Generating library '\(name)' at \(packageDirectory)") + + // Update Package.swift with the library name + try updatePackageManifest(name: name, at: packageDirectory) + + // Create the main library file + try createLibrarySource(name: name, at: packageDirectory) + + // Create tests + try createTests(name: name, at: packageDirectory) + + if examples { + try createReadmeWithExamples(name: name, at: packageDirectory) + } + + print("Library template completed successfully!") + } + + func updatePackageManifest(name: String, at directory: String) throws { + let packagePath = "\(directory)/Package.swift" + var content = try String(contentsOfFile: packagePath) + + // Update package name and target names + content = content.replacingOccurrences(of: "name: \"Template\"", with: "name: \"\(name)\"") + content = content.replacingOccurrences(of: "\"Template\"", with: "\"\(name)\"") + + try content.write(toFile: packagePath, atomically: true, encoding: .utf8) + } + + func createLibrarySource(name: String, at directory: String) throws { + let sourceContent = """ + /// \(name) provides functionality for [describe your library]. + public struct \(name) { + /// Creates a new instance of \(name). + public init() {} + + /// A sample method demonstrating the library's capabilities. + public func hello() -> String { + "Hello from \(name)!" + } + } + """ + + let sourcePath = "\(directory)/Sources/\(name)/\(name).swift" + try FileManager.default.createDirectory(atPath: "\(directory)/Sources/\(name)", + withIntermediateDirectories: true) + try sourceContent.write(toFile: sourcePath, atomically: true, encoding: .utf8) + } + + func createTests(name: String, at directory: String) throws { + let testContent = """ + import Testing + @testable import \(name) + + struct \(name)Tests { + @Test + func testHello() { + let library = \(name)() + #expect(library.hello() == "Hello from \(name)!") + } + } + """ + + let testPath = "\(directory)/Tests/\(name)Tests/\(name)Tests.swift" + try FileManager.default.createDirectory(atPath: "\(directory)/Tests/\(name)Tests", + withIntermediateDirectories: true) + try testContent.write(toFile: testPath, atomically: true, encoding: .utf8) + } + + func createReadmeWithExamples(name: String, at directory: String) throws { + let readmeContent = """ + # \(name) + + A Swift library that provides [describe functionality]. + + ## Usage + + ```swift + import \(name) + + let library = \(name)() + print(library.hello()) // Prints: Hello from \(name)! + ``` + + ## Installation + + Add \(name) to your Package.swift dependencies: + + ```swift + dependencies: [ + .package(url: "https://github.com/yourname/\(name.lowercased())", from: "1.0.0") + ] + ``` + """ + + try readmeContent.write(toFile: "\(directory)/README.md", atomically: true, encoding: .utf8) + } +} + +struct PackageOptions: ParsableArguments { + @Option(help: .hidden) + var packageDirectory: String? +} + +enum TemplateError: Error { + case missingPackageDirectory +} +``` + +### Using package context for intelligent defaults + +Template plugins have access to the package context, which can be used to make intelligent decisions about defaults or validate user inputs. Here's an example of how a template can use context information: + +```swift +import PackagePlugin +import Foundation + +@main +struct SimpleTemplatePlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws { + let tool = try context.tool(named: "SimpleTemplate") + let packageDirectory = context.package.directoryURL.path + + // Extract information from the package context + let packageName = context.package.displayName + let existingTargets = context.package.targets.map { $0.name } + + // Pass context information to the template executable + var templateArgs = [ + "--package-directory", packageDirectory, + "--package-name", packageName, + "--existing-targets", existingTargets.joined(separator: ",") + ] + + templateArgs.append(contentsOf: arguments.filter { $0 != "--" }) + + let process = Process() + process.executableURL = URL(filePath: tool.url.path()) + process.arguments = templateArgs + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + throw TemplateError.executionFailed(code: process.terminationStatus) + } + } +} +``` + +The corresponding template executable can then use this context to provide the template with essential information regarding the consumer's package: + +```swift +@main +struct IntelligentTemplate: ParsableCommand { + @OptionGroup(visibility: .hidden) + var packageOptions: PackageOptions + + @Option(help: .hidden) + var packageName: String? + + @Option(help: .hidden) + var existingTargets: String? + + @Option(help: "Name for the new component") + var componentName: String? + + func run() throws { + // Use package context to provide intelligent defaults + let inferredName = componentName ?? packageName?.appending("Utils") ?? "Component" + let existingTargetList = existingTargets?.split(separator: ",").map(String.init) ?? [] + + // Validate that we're not creating duplicate targets + if existingTargetList.contains(inferredName) { + throw TemplateError.targetAlreadyExists(inferredName) + } + + print("Creating component '\(inferredName)' (inferred from package context)") + // ... rest of template implementation + } +} +``` + +### Templates with subcommands + +Templates can use subcommands to create branching decision trees, allowing users to choose between different variants: + +```swift +@main +struct MultiVariantTemplate: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "multivariant-template", + abstract: "Generate different types of Swift projects", + subcommands: [WebApp.self, CLI.self, Library.self] + ) + + @OptionGroup(visibility: .hidden) + var packageOptions: PackageOptions + + @Flag(help: "Include comprehensive documentation") + var documentation: Bool = false + + func run() throws { + ... + } +} + +struct WebApp: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "webapp", + abstract: "Generate a web application" + ) + + @ParentCommand var template: MultiVariantTemplate + + @Option(help: "Web framework to use") + var framework: WebFramework = .vapor + + @Flag(help: "Include authentication support") + var auth: Bool = false + + func run() throws { + print("Generating web app with \(framework.rawValue) framework") + + if template.documentation { + print("Including comprehensive documentation") + } + + // Generate web app specific files... + } +} + +struct CLI: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "cli", + abstract: "Generate a command-line tool" + ) + + @ParentCommand var template: MultiVariantTemplate + + @Flag(help: "Include shell completion support") + var completion: Bool = false + + func run() throws { + template.run() + print("Generating CLI tool") + // Generate CLI specific files... + } +} + +enum WebFramework: String, ExpressibleByArgument, CaseIterable { + case vapor, hummingbird +} +``` + +Subcommands can access shared logic and state from their parent command using the `@ParentCommand` property wrapper. This enables a clean seperation of logic between the different layers of commands, while still allowing sequential execution and reuse of common configuration or setup code define at the higher levels. + +## Testing Templates + +SwiftPM provides a built-in command for testing templates comprehensively: + +```shell +❯ swift test template --template-name MyTemplate --output-path ./test-output +``` + +This command will: +1. Build the template executable +2. Prompt for all required inputs +3. Generate each possible decision path through subcommands +4. Validate that each variant builds successfully +5. Report results in a summary format + +For templates with many variants, you can provide predetermined arguments to test specific paths: + +```shell +❯ swift test template --template-name MultiVariantTemplate --output-path ./test-output webapp --framework vapor --auth +``` + +Templates can also include unit tests for their logic by factoring out file generation and validation code into testable functions. + diff --git a/Documentation/Usage.md b/Documentation/Usage.md index 88e3a92a2b3..fce1945aa30 100644 --- a/Documentation/Usage.md +++ b/Documentation/Usage.md @@ -8,6 +8,7 @@ * [Creating a Library Package](#creating-a-library-package) * [Creating an Executable Package](#creating-an-executable-package) * [Creating a Macro Package](#creating-a-macro-package) + * [Creating a Package based on a custom user-defined template](#creating-a-package-based-on-a-custom-user-defined-template) * [Defining Dependencies](#defining-dependencies) * [Publishing a Package](#publishing-a-package) * [Requiring System Libraries](#requiring-system-libraries) @@ -88,6 +89,79 @@ Evolution proposal for [Expression Macros](https://github.com/swiftlang/swift-ev and the WWDC [Write Swift macros](https://developer.apple.com/videos/play/wwdc2023/10166) video. See further documentation on macros in [The Swift Programming Language](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/macros/) book. + +### Creating a Package based on a custom user-defined template + +SwiftPM can create packages based on custom user-defined templates distributed as Swift packages. These templates can be obtained from local directories, Git repositories, or package registries, and provide interactive configuration through command-line arguments. +To create a package from a custom template, use the `swift package init` command with the `--type` option along with a template source: + +```bash +# From a package registry +$ swift package init --type MyTemplate --package-id author.template-example + +# From a Git repository +$ swift package init --type MyTemplate --url https://github.com/author/template-example + +# From a local directory +$ swift package init --type MyTemplate --path /path/to/template +``` + +The template will prompt you for configuration options during initialization: + +```bash +$ swift package init --type ServerTemplate --package-id example.server-templates +Building template package... +Build of product 'ServerTemplate' complete! (3.2s) + +Add a README.md file with an introduction and tour of the code: [y/N] y + +Choose from the following: + +• Name: crud + About: Generate CRUD server with database support +• Name: bare + About: Generate a minimal server + +Type the name of the option: +crud + +Pick a database system for data storage. [sqlite3, postgresql] (default: sqlite3): +postgresql + +Building for debugging... +Build of product 'ServerTemplate' complete! (1.1s) +``` + +Templates support the same versioning options as regular Swift package dependencies: + +```bash +# Specific version +$ swift package init --type MyTemplate --package-id author.template --exact 1.2.0 + +# Version range +$ swift package init --type MyTemplate --package-id author.template --from 1.0.0 + +# Specific branch +$ swift package init --type MyTemplate --url https://github.com/author/template --branch main + +# Specific revision +$ swift package init --type MyTemplate --url https://github.com/author/template --revision abc123 +``` + +You can provide template arguments directly to skip interactive prompts: + +```bash +$ swift package init --type ServerTemplate --package-id example.server-templates crud --database postgresql --readme true +``` + +Use the `--build-package` flag to automatically build and validate the generated package: + +```bash +$ swift package init --type MyTemplate --package-id author.template --build-package +``` + +This ensures your template generates valid, buildable Swift packages. + ## Defining Dependencies To depend on a package, define the dependency and the version in the manifest of From 2050d3ff4ea74afb0398b7b89ba881d332fddec8 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 24 Sep 2025 14:53:43 -0400 Subject: [PATCH 122/225] documentation improvements --- Documentation/Templates.md | 20 +++++++++----------- Sources/Commands/PackageCommands/Init.swift | 8 ++++---- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/Documentation/Templates.md b/Documentation/Templates.md index d0b1f79ae99..efd31a06c19 100644 --- a/Documentation/Templates.md +++ b/Documentation/Templates.md @@ -6,20 +6,18 @@ This guide provides a brief overview of Swift Package Manager templates, describ User-defined custom _templates_ allow generation of packages whose functionality goes beyond the hard-coded templates provided by Swift Package Manager. Package templates are written in Swift using Swift Argument Parser and the `PackagePlugin` API provided by the Swift Package Manager. -A template is represented in the SwiftPM package manifest as a target of the `templateTarget` type — and should be available to other packages by declaring a corresponding `template` product. Source code for a template is normally located in a directory under the `Templates` directory in the package, but this can be customized. However, as seen below, authors will also need to write the source code for a plugin. +A template is represented in the SwiftPM package manifest as a target of the `templateTarget` type and should be available to other packages by declaring a corresponding `template` product. Source code for a template is normally located in a directory under the `Templates` directory in the package, but this can be customized. However, as seen below, authors will also need to write the source code for a plugin. Templates are an abstraction of two types of modules: - a template _executable_ that performs the file generation and project setup - a command-line _plugin_ that safely invokes the executable -The command-line plugin allows the template executable to run in a separate process, and (on platforms that support sandboxing) it is wrapped in a sandbox that prevents network access as well as attempts to write to arbitrary locations in the file system. Template plugins have access to the representation of the package model, which can be used by the template whenever the context of a package is needed — for example, to infer sensible defaults or validate user inputs against existing package structure. +The command-line plugin allows the template executable to run in a separate process, and (on platforms that support sandboxing) it is wrapped in a sandbox that prevents network access as well as attempts to write to arbitrary locations in the file system. Template plugins have access to the representation of the package model, which can be used by the template whenever the context of a package is needed; for example, to infer sensible defaults or validate user inputs against existing package structure. The executable allows authors to define user-facing interfaces which gather important consumer input needed by the template to run, using Swift Argument Parser for a rich command-line experience with subcommands, options, and flags. ## Using a Package Template -A package template is available to the package that defines it and to any other package that can reference the template package as a source, as it is mandatory to declare it as a product. - Templates are invoked using the `swift package init` command: ```shell @@ -52,7 +50,7 @@ swift package init --type MyTemplate --package-id author.template --from 1.0.0 swift package init --type MyTemplate --url https://github.com/author/template --branch main ``` -The `--build-package` flag can be used to automatically build the template output: +The `--validate-package` flag can be used to automatically build the template output: ```bash swift package init --type MyTemplate --package-id author.template --build-package @@ -60,7 +58,7 @@ swift package init --type MyTemplate --package-id author.template --build-packag ## Writing a Template -The first step when writing a package template is to decide what kind of template you need and what base package structure it should start with. Templates can generate any kind of Swift package — executables, libraries, plugins, or even empty packages that will be further customized. +The first step when writing a package template is to decide what kind of template you need and what base package structure it should start with. Templates can build off of any kind of Swift package: executables, libraries, plugins, or even empty packages that will be further customized. ### Declaring a template in the package manifest @@ -80,7 +78,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), ], targets: [ - .templateTarget( + .template( name: "LibraryTemplate", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), @@ -88,7 +86,7 @@ let package = Package( initialPackageType: .library, description: "Generate a Swift library package" ), - .templateTarget( + .template( name: "ExecutableTemplate", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), @@ -111,7 +109,7 @@ The `template` product is what makes the template visible to other packages. The #### Template target dependencies -The dependencies specify the packages that will be available for use by the template executable. Each dependency can be any package product — commonly this includes Swift Argument Parser for command-line interface handling, but can also include utilities for file generation, string processing, or network requests if needed. +The dependencies specify the packages that will be available for use by the template executable. Each dependency can be any package product. Commonly this includes Swift Argument Parser for command-line interface handling, but can also include utilities for file generation, string processing, or network requests if needed. #### Template permissions @@ -322,9 +320,9 @@ enum TemplateError: Error { } ``` -### Using package context for intelligent defaults +### Using package context for package defaults -Template plugins have access to the package context, which can be used to make intelligent decisions about defaults or validate user inputs. Here's an example of how a template can use context information: +Template plugins have access to the package context, which can be used by template authors to fill certain arguments to make package generation easier. Here's an example of how a template can use context information: ```swift import PackagePlugin diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index b905ff8fe1c..4cc9cd2ca6b 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -63,15 +63,15 @@ extension SwiftPackageCommand { var buildOptions: BuildCommandOptions /// Path to a local template. - @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) + @Option(name: .customLong("path"), help: "Path to the package containing a template.", completion: .directory) var templateDirectory: Basics.AbsolutePath? /// Git URL of the template. - @Option(name: .customLong("url"), help: "The git URL of the template.") + @Option(name: .customLong("url"), help: "The git URL of the package containing a template.") var templateURL: String? /// Package Registry ID of the template. - @Option(name: .customLong("package-id"), help: "The package identifier of the template") + @Option(name: .customLong("package-id"), help: "The package identifier of the package containing a template.") var templatePackageID: String? // MARK: - Versioning Options for Remote Git Templates and Registry templates @@ -103,7 +103,7 @@ extension SwiftPackageCommand { /// Validation step to build package post generation and run if package is of type executable. @Flag( name: .customLong("validate-package"), - help: "Run 'swift build' after package generation to validate the template." + help: "Run 'swift build' after package generation to validate the template output." ) var validatePackage: Bool = false From 33281ef0cda7b8bc4ec74f616175a6fc5268245c Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 30 Sep 2025 13:51:14 -0400 Subject: [PATCH 123/225] fixed optional bug when prompting + added templates examples --- Examples/init-templates/Package.swift | 71 + .../PartsServicePlugin.swift | 24 + .../ServerTemplatePlugin.swift | 52 + .../Template1Plugin/Template1Plugin.swift | 54 + .../Template2Plugin/Template2Plugin.swift | 35 + Examples/init-templates/README.md | 21 + .../Templates/PartsService/main.swift | 348 ++++ .../Templates/ServerTemplate/main.swift | 1790 +++++++++++++++++ .../Templates/Template1/Template.swift | 68 + .../StencilTemplates/EnumExtension.stencil | 24 + .../StencilTemplates/StaticColorSets.stencil | 30 + .../StencilTemplates/StructColors.stencil | 27 + .../Templates/Template2/Template.swift | 132 ++ .../Tests/PartsServiceTests.swift | 69 + .../ServerTemplateTests.swift | 67 + .../init-templates/Tests/TemplateTest.swift | 80 + .../TestCommands/TestTemplateCommand.swift | 1 - .../TemplatePathResolver.swift | 1 + .../InitTemplatePackage.swift | 61 +- Tests/CommandsTests/TemplateTests.swift | 79 + 20 files changed, 3018 insertions(+), 16 deletions(-) create mode 100644 Examples/init-templates/Package.swift create mode 100644 Examples/init-templates/Plugins/PartsServicePlugin/PartsServicePlugin.swift create mode 100644 Examples/init-templates/Plugins/ServerTemplatePlugin/ServerTemplatePlugin.swift create mode 100644 Examples/init-templates/Plugins/Template1Plugin/Template1Plugin.swift create mode 100644 Examples/init-templates/Plugins/Template2Plugin/Template2Plugin.swift create mode 100644 Examples/init-templates/README.md create mode 100644 Examples/init-templates/Templates/PartsService/main.swift create mode 100644 Examples/init-templates/Templates/ServerTemplate/main.swift create mode 100644 Examples/init-templates/Templates/Template1/Template.swift create mode 100644 Examples/init-templates/Templates/Template2/StencilTemplates/EnumExtension.stencil create mode 100644 Examples/init-templates/Templates/Template2/StencilTemplates/StaticColorSets.stencil create mode 100644 Examples/init-templates/Templates/Template2/StencilTemplates/StructColors.stencil create mode 100644 Examples/init-templates/Templates/Template2/Template.swift create mode 100644 Examples/init-templates/Tests/PartsServiceTests.swift create mode 100644 Examples/init-templates/Tests/ServerTemplateTests/ServerTemplateTests.swift create mode 100644 Examples/init-templates/Tests/TemplateTest.swift diff --git a/Examples/init-templates/Package.swift b/Examples/init-templates/Package.swift new file mode 100644 index 00000000000..a49af757dd6 --- /dev/null +++ b/Examples/init-templates/Package.swift @@ -0,0 +1,71 @@ +// swift-tools-version:6.3.0 +import PackageDescription + + +let testTargets: [Target] = [.testTarget( + name: "ServerTemplateTests", + dependencies: [ + "ServerTemplate", + ] +)] + + +let package = Package( + name: "SimpleTemplateExample", + products: + .template(name: "PartsService") + + .template(name: "Template1") + + .template(name: "Template2") + + .template(name: "ServerTemplate"), + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", exact: "main"), + .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2"), + .package(url: "https://github.com/stencilproject/Stencil.git", from: "0.15.1"), + ], + targets: testTargets + .template( + name: "PartsService", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "SystemPackage", package: "swift-system"), + ], + + initialPackageType: .executable, + description: "This template generates a simple parts management service using Hummingbird, and Fluent!" + + ) + .template( + name: "Template1", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "SystemPackage", package: "swift-system"), + ], + + initialPackageType: .executable, + templatePermissions: [ + .allowNetworkConnections(scope: .none, reason: "Need network access to help generate a template") + ], + description: "This is a simple template that uses Swift string interpolation." + + ) + .template( + name: "Template2", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "SystemPackage", package: "swift-system"), + .product(name: "Stencil", package: "Stencil") + + ], + resources: [ + .process("StencilTemplates") + ], + initialPackageType: .executable, + description: "This is a template that uses Stencil templating." + + ) + .template( + name: "ServerTemplate", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "SystemPackage", package: "swift-system"), + ], + initialPackageType: .executable, + description: "A set of starter Swift Server projects." + ) +) diff --git a/Examples/init-templates/Plugins/PartsServicePlugin/PartsServicePlugin.swift b/Examples/init-templates/Plugins/PartsServicePlugin/PartsServicePlugin.swift new file mode 100644 index 00000000000..88e85839309 --- /dev/null +++ b/Examples/init-templates/Plugins/PartsServicePlugin/PartsServicePlugin.swift @@ -0,0 +1,24 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct PartsServiceTemplatePlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws { + let tool = try context.tool(named: "PartsService") + let packageDirectory = context.package.directoryURL.path + let packageName = context.package.displayName + + let process = Process() + + process.executableURL = URL(filePath: tool.url.path()) + process.arguments = ["--pkg-dir", packageDirectory, "--name", packageName] + arguments.filter { $0 != "--" } + + try process.run() + process.waitUntilExit() + } +} diff --git a/Examples/init-templates/Plugins/ServerTemplatePlugin/ServerTemplatePlugin.swift b/Examples/init-templates/Plugins/ServerTemplatePlugin/ServerTemplatePlugin.swift new file mode 100644 index 00000000000..5730b3ab2d5 --- /dev/null +++ b/Examples/init-templates/Plugins/ServerTemplatePlugin/ServerTemplatePlugin.swift @@ -0,0 +1,52 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct ServerTemplatePlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws { + let tool = try context.tool(named: "ServerTemplate") + let packageDirectory = context.package.directoryURL.path + + let process = Process() + + let stderrPipe = Pipe() + + process.executableURL = URL(filePath: tool.url.path()) + process.arguments = ["--pkg-dir", packageDirectory] + arguments.filter { $0 != "--" } + process.standardError = stderrPipe + + try process.run() + process.waitUntilExit() + + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderrOutput = String(data: stderrData, encoding: .utf8) ?? "" + + if process.terminationStatus != 0 { + throw PluginError.executionFailed(code: process.terminationStatus, stderrOutput: stderrOutput) + } + } + + enum PluginError: Error, CustomStringConvertible { + case executionFailed(code: Int32, stderrOutput: String) + + var description: String { + switch self { + case .executionFailed(let code, let stderrOutput): + return """ + + Plugin subprocess failed with exit code \(code). + + Output: + \(stderrOutput) + + """ + } + } + } + +} diff --git a/Examples/init-templates/Plugins/Template1Plugin/Template1Plugin.swift b/Examples/init-templates/Plugins/Template1Plugin/Template1Plugin.swift new file mode 100644 index 00000000000..bf90f6127a3 --- /dev/null +++ b/Examples/init-templates/Plugins/Template1Plugin/Template1Plugin.swift @@ -0,0 +1,54 @@ +// +// plugin.swift +// TemplateWorkflow +// +// Created by John Bute on 2025-04-14. +// +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct TemplatePlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws { + let tool = try context.tool(named: "Template1") + let packageDirectory = context.package.directoryURL.path + let process = Process() + let stderrPipe = Pipe() + + process.executableURL = URL(filePath: tool.url.path()) + process.arguments = ["--pkg-dir", packageDirectory] + arguments.filter { $0 != "--" } + process.standardError = stderrPipe + + try process.run() + process.waitUntilExit() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderrOutput = String(data: stderrData, encoding: .utf8) ?? "" + + if process.terminationStatus != 0 { + throw PluginError.executionFailed(code: process.terminationStatus, stderrOutput: stderrOutput) + } + + } + enum PluginError: Error, CustomStringConvertible { + case executionFailed(code: Int32, stderrOutput: String) + + var description: String { + switch self { + case .executionFailed(let code, let stderrOutput): + return """ + + Plugin subprocess failed with exit code \(code). + + Output: + \(stderrOutput) + + """ + } + } + } +} diff --git a/Examples/init-templates/Plugins/Template2Plugin/Template2Plugin.swift b/Examples/init-templates/Plugins/Template2Plugin/Template2Plugin.swift new file mode 100644 index 00000000000..9cdddda7850 --- /dev/null +++ b/Examples/init-templates/Plugins/Template2Plugin/Template2Plugin.swift @@ -0,0 +1,35 @@ +// +// Untitled.swift +// TemplateWorkflow +// +// Created by John Bute on 2025-04-23. +// + +// +// plugin.swift +// TemplateWorkflow +// +// Created by John Bute on 2025-04-14. +// +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct DeclarativeTemplatePlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws{ + let tool = try context.tool(named: "Template2") + let process = Process() + + process.executableURL = URL(filePath: tool.url.path()) + process.arguments = arguments.filter({$0 != "--"}) + + try process.run() + process.waitUntilExit() + } + +} diff --git a/Examples/init-templates/README.md b/Examples/init-templates/README.md new file mode 100644 index 00000000000..9aba69d981a --- /dev/null +++ b/Examples/init-templates/README.md @@ -0,0 +1,21 @@ +# Swift package templating example + +--- + +This template project is a simple example of how a template author can create a template and generate a swift projects, utilizing the `swift package init` capability in swift package manager (to come). + +## Parts Service + +The parts service template can generate a REST service using Hummingbird (app server), and Fluent (ORM) with configurable database management system (SQLite3, and PostgreSQL). There are various switches to customize your project. + +Invoke the parts service generator like this: + +``` +swift run parts-service --pkg-dir +``` + +You can find the additional information and parameters by invoking the help: + +``` +swift run parts-service --help +``` diff --git a/Examples/init-templates/Templates/PartsService/main.swift b/Examples/init-templates/Templates/PartsService/main.swift new file mode 100644 index 00000000000..d69e3471fbf --- /dev/null +++ b/Examples/init-templates/Templates/PartsService/main.swift @@ -0,0 +1,348 @@ +import ArgumentParser +import SystemPackage +import Foundation + +struct fs { + static var shared: FileManager { FileManager.default } +} + +extension FileManager { + func rm(atPath path: FilePath) throws { + try self.removeItem(atPath: path.string) + } +} + +extension FilePath { + static func / (left: FilePath, right: String) -> FilePath { + left.appending(right) + } +} + +extension String { + func write(toFile: FilePath) throws { + // Create the directory if it doesn't yet exist + try? fs.shared.createDirectory(atPath: toFile.removingLastComponent().string, withIntermediateDirectories: true) + + try self.write(toFile: toFile.string, atomically: true, encoding: .utf8) + } + + func append(toFile file: FilePath) throws { + let data = self.data(using: .utf8) + try data?.append(toFile: file) + } + + func indenting(_ level: Int) -> String { + self.split(separator: "\n", omittingEmptySubsequences: false).joined(separator: "\n" + String(repeating: " ", count: level)) + } +} + +extension Data { + func append(toFile file: FilePath) throws { + if let fileHandle = FileHandle(forWritingAtPath: file.string) { + defer { + fileHandle.closeFile() + } + fileHandle.seekToEndOfFile() + fileHandle.write(self) + } else { + try write(to: URL(fileURLWithPath: file.string)) + } + } +} + +enum Database: String, ExpressibleByArgument, CaseIterable { + case sqlite3, postgresql + + var packageDep: String { + switch self { + case .sqlite3: + ".package(url: \"https://github.com/vapor/fluent-sqlite-driver.git\", from: \"4.0.0\")," + case .postgresql: + ".package(url: \"https://github.com/vapor/fluent-postgres-driver.git\", from: \"2.10.1\")," + } + } + + var targetDep: String { + switch self { + case .sqlite3: + ".product(name: \"FluentSQLiteDriver\", package: \"fluent-sqlite-driver\")," + case .postgresql: + ".product(name: \"FluentPostgresDriver\", package: \"fluent-postgres-driver\")," + } + } + + var taskListItem: String { + switch self { + case .sqlite3: + "[x] - Create SQLite3 DB (`Scripts/create-db.sh`)" + case .postgresql: + "[x] - Create PostgreSQL DB (`Scripts/create-db.sh`)" + } + } + + var appServerUse: String { + switch self { + case .sqlite3: + """ + // add sqlite database + fluent.databases.use(.sqlite(.file("part.sqlite")), as: .sqlite) + """ + case .postgresql: + """ + // add PostgreSQL database + app.databases.use( + .postgres( + configuration: .init( + hostname: "localhost", + username: "vapor", + password: "vapor", + database: "part", + tls: .disable + ) + ), + as: .psql + ) + """ + } + } + + var commandLineCreate: String { + switch self { + case .sqlite3: + "sqlite3 part.sqlite \"create table part (id VARCHAR PRIMARY KEY,description VARCHAR);\"" + case .postgresql: + """ + createdb part + # TODO complete the rest of the command-line script for PostgreSQL table/user creation + """ + } + } +} + +func packageSwift(db: Database, name: String) -> String { + """ + // swift-tools-version: 6.1 + + import PackageDescription + + let package = Package( + name: "part-service", + platforms: [ + .macOS(.v14), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"), + .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"), + .package(url: "https://github.com/hummingbird-project/hummingbird-fluent.git", from: "2.0.0"), + \(db.packageDep.indenting(2)) + ], + targets: [ + .target( + name: "Models", + dependencies: [ + \(db.targetDep.indenting(3)) + ] + ), + .executableTarget( + name: "\(name)", + dependencies: [ + .target(name: "Models"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Hummingbird", package: "hummingbird"), + .product(name: "HummingbirdFluent", package: "hummingbird-fluent"), + \(db.targetDep.indenting(3)) + ] + ), + ] + ) + """ +} + +func genReadme(db: Database) -> String { + """ + # Parts Management + + Manage your parts using the power of Swift, Hummingbird, and Fluent! + + \(db.taskListItem) + [x] - Add a Hummingbird app server, router, and endpoint for parts (`Sources/App/main.swift`) + [x] - Create a model for part (`Sources/Models/Part.swift`) + + ## Getting Started + + Create the part database if you haven't already done so. + + ``` + ./Scripts/create-db.sh + ``` + + Start the application. + + ``` + swift run + ``` + + Curl the parts endpoint to see the list of parts: + + ``` + curl http://127.0.0.1:8080/parts + ``` + """ +} + +func appServer(db: Database, migration: Bool) -> String { + """ + import ArgumentParser + import Hummingbird + \( db == .sqlite3 ? + "import FluentSQLiteDriver" : + "import FluentPostgresDriver" + ) + import HummingbirdFluent + import Models + + \( migration ? + """ + // An example migration. + struct CreatePartMigration: Migration { + func prepare(on database: Database) -> EventLoopFuture { + fatalError("Implement part migration prepare") + } + + func revert(on database: Database) -> EventLoopFuture { + fatalError("Implement part migration revert") + } + } + """: "" ) + + @main + struct PartServiceGenerator: AsyncParsableCommand { + \( migration ? "@Flag var migrate: Bool = false" : "" ) + mutating func run() async throws { + var logger = Logger(label: "PartService") + logger.logLevel = .debug + let fluent = Fluent(logger: logger) + + \(db.appServerUse) + + \( migration ? + """ + await fluent.migrations.add(CreatePartMigration()) + + // migrate + if self.migrate { + try await fluent.migrate() + } + """.indenting(2) : "" ) + + // create router and add a single GET /parts route + let router = Router() + router.get("parts") { request, _ -> [Part] in + return try await Part.query(on: fluent.db()).all() + } + + // create application using router + let app = Application( + router: router, + configuration: .init(address: .hostname("127.0.0.1", port: 8080)) + ) + + // run hummingbird application + try await app.runService() + } + } + """ +} + +func partModel(db: Database) -> String { + """ + \( db == .sqlite3 ? + "import FluentSQLiteDriver" : + "import FluentPostgresDriver" + ) + + public final class Part: Model, @unchecked Sendable { + // Name of the table or collection. + public static let schema = "part" + + // Unique identifier for this Part. + @ID(key: .id) + public var id: UUID? + + // The Part's description. + @Field(key: "description") + public var description: String + + // Creates a new, empty Part. + public init() { } + + // Creates a new Part with all properties set. + public init(id: UUID? = nil, description: String) { + self.id = id + self.description = description + } + } + """ +} + +func createDbScript(db: Database) -> String { + """ + #!/bin/bash + + \(db.commandLineCreate) + """ +} + +@main +struct PartServiceGenerator: ParsableCommand { + public static let configuration = CommandConfiguration( + abstract: "This template gets you started with a service to track your parts with app server and database." + ) + + @Option(help: .init(visibility: .hidden)) + var pkgDir: String? + + @Flag(help: "Add a README.md file with and introduction and tour of the code") + var readme: Bool = false + + @Option(help: "Pick a database system for part storage and retrieval.") + var database: Database = .sqlite3 + + @Flag(help: "Add a starting database migration routine.") + var migration: Bool = false + + @Option(help: .init(visibility: .hidden)) + var name: String = "App" + + mutating func run() throws { + guard let pkgDir = self.pkgDir else { + fatalError("No --pkg-dir was provided.") + } + guard case let pkgDir = FilePath(pkgDir) else { fatalError() } + + print(pkgDir.string) + + // Remove the main.swift left over from the base executable template, if it exists + try? fs.shared.rm(atPath: pkgDir / "Sources/main.swift") + + // Start from scratch with the Package.swift + try? fs.shared.rm(atPath: pkgDir / "Package.swift") + + try packageSwift(db: self.database, name: name).write(toFile: pkgDir / "Package.swift") + if self.readme { + try genReadme(db: self.database).write(toFile: pkgDir / "README.md") + } + + try? fs.shared.rm(atPath: pkgDir / "Sources/\(name)") + try appServer(db: self.database, migration: self.migration).write(toFile: pkgDir / "Sources/\(name)/main.swift") + try partModel(db: self.database).write(toFile: pkgDir / "Sources/Models/Part.swift") + + let script = pkgDir / "Scripts/create-db.sh" + try createDbScript(db: self.database).write(toFile: script) + try fs.shared.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.string) + + if self.database == .sqlite3 { + try "\npart.sqlite".append(toFile: pkgDir / ".gitignore") + } + } +} diff --git a/Examples/init-templates/Templates/ServerTemplate/main.swift b/Examples/init-templates/Templates/ServerTemplate/main.swift new file mode 100644 index 00000000000..99f9a3d3526 --- /dev/null +++ b/Examples/init-templates/Templates/ServerTemplate/main.swift @@ -0,0 +1,1790 @@ +import ArgumentParser +import SystemPackage +import Foundation + +struct fs { + static var shared: FileManager { FileManager.default } +} +extension FileManager { + func rm(atPath path: FilePath) throws { + try self.removeItem(atPath: path.string) + } + + func csl(atPath linkPath: FilePath, pointTo relativeTarget: FilePath) throws { + let linkURL = URL(fileURLWithPath: linkPath.string) + let destinationURL = URL(fileURLWithPath: relativeTarget.string, relativeTo: linkURL.deletingLastPathComponent()) + try self.createSymbolicLink(at: linkURL, withDestinationURL: destinationURL) + } +} + +extension FilePath { + static func / (left: FilePath, right: String) -> FilePath { + left.appending(right) + } + + func relative(to base: FilePath) -> FilePath { + let targetURL = URL(fileURLWithPath: self.string) + let baseURL = URL(fileURLWithPath: base.string, isDirectory: true) + + let relativeURL = targetURL.relativePath(from: baseURL) + return FilePath(relativeURL) + } +} + +extension URL { + /// Compute the relative path from one URL to another + func relativePath(from base: URL) -> String { + let targetComponents = self.standardized.pathComponents + let baseComponents = base.standardized.pathComponents + + var index = 0 + while index < targetComponents.count && + index < baseComponents.count && + targetComponents[index] == baseComponents[index] { + index += 1 + } + + let up = Array(repeating: "..", count: baseComponents.count - index) + let down = targetComponents[index...] + + return (up + down).joined(separator: "/") + } +} + + +extension String { + func write(toFile: FilePath) throws { + // Create the directory if it doesn't yet exist + try? fs.shared.createDirectory(atPath: toFile.removingLastComponent().string, withIntermediateDirectories: true) + + try self.write(toFile: toFile.string, atomically: true, encoding: .utf8) + } + + func append(toFile file: FilePath) throws { + let data = self.data(using: .utf8) + try data?.append(toFile: file) + } + + func indenting(_ level: Int) -> String { + self.split(separator: "\n", omittingEmptySubsequences: false).joined(separator: "\n" + String(repeating: " ", count: level)) + } +} + +extension Data { + func append(toFile file: FilePath) throws { + if let fileHandle = FileHandle(forWritingAtPath: file.string) { + defer { + fileHandle.closeFile() + } + fileHandle.seekToEndOfFile() + fileHandle.write(self) + } else { + try write(to: URL(fileURLWithPath: file.string)) + } + } +} + +enum ServerType: String, ExpressibleByArgument, CaseIterable { + case crud, bare + + var description: String { + switch self { + case .crud: + return "CRUD" + case .bare: + return "Bare" + } + } + + //Package.swift manifest file writing + var packageDep: String { + switch self { + case .crud: + """ + // Server scaffolding + .package(url: "https://github.com/vapor/vapor", from: "4.0.0"), + .package(url: "https://github.com/swift-server/swift-service-lifecycle", from: "2.1.0"), + .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), + .package(url: "https://github.com/swift-server/swift-openapi-vapor", from: "1.0.0"), + + // Telemetry + .package(url: "https://github.com/apple/swift-log", .upToNextMajor(from: "1.5.2")), + .package(url: "https://github.pie.apple.com/swift-server/swift-logback", from: "2.3.1"), + .package(url: "https://github.com/apple/swift-metrics", from: "2.3.4"), + .package(url: "https://github.com/swift-server/swift-prometheus", from: "2.1.0"), + .package(url: "https://github.com/apple/swift-distributed-tracing", from: "1.2.0"), + .package(url: "https://github.com/swift-otel/swift-otel", .upToNextMinor(from: "0.11.0")), + + // Database + .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"), + .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"), + + // HTTP client + .package(url: "https://github.com/swift-server/async-http-client", from: "1.25.0"), + + """ + case .bare: + """ + // Server + .package(url: "https://github.com/vapor/vapor", from: "4.0.0"), + """ + } + } + + var targetName: String { + switch self { + case .bare: + "BareHTTPServer" + case .crud: + "CRUDHTTPServer" + + } + } + + var platform: String { + switch self { + case .bare: + ".macOS(.v13)" + case .crud: + ".macOS(.v14)" + } + } + + var targetDep: String { + switch self { + case .crud: + """ + // Server scaffolding + .product(name: "Vapor", package: "vapor"), + .product(name: "ServiceLifecycle", package: "swift-service-lifecycle"), + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + .product(name: "OpenAPIVapor", package: "swift-openapi-vapor"), + + // Telemetry + .product(name: "Logging", package: "swift-log"), + .product(name: "Logback", package: "swift-logback"), + .product(name: "Metrics", package: "swift-metrics"), + .product(name: "Prometheus", package: "swift-prometheus"), + .product(name: "Tracing", package: "swift-distributed-tracing"), + .product(name: "OTel", package: "swift-otel"), + .product(name: "OTLPGRPC", package: "swift-otel"), + + // Database + .product(name: "Fluent", package: "fluent"), + .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), + + // HTTP client + .product(name: "AsyncHTTPClient", package: "async-http-client"), + """ + case .bare: + """ + // Server + .product(name: "Vapor", package: "vapor") + """ + } + } + + var plugin: String { + switch self { + case .bare: + "" + case .crud: + """ + plugins: [ + .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator") + ] + """ + + } + } + + + //Readme items + + var features: String { + switch self { + case .bare: + """ + - base server (Vapor) + - a single `/health` endpoint + - logging to stdout + """ + case .crud: + """ + - base server + - OpenAPI-generated server stubs + - Telemetry: logging to a file and stdout, metrics emitted over Prometheus, traces emitted over OTLP + - PostgreSQL database + - HTTP client for making upstream calls + """ + + } + } + + var callingLocally : String { + switch self { + case .bare: + """ + In another window, test the health check: `curl http://localhost:8080/health`. + """ + case .crud: + """ + ### Health check + + ```sh + curl -f http://localhost:8080/health + ``` + + ### Create a TODO + + ```sh + curl -X POST http://localhost:8080/api/todos --json '{"contents":"Smile more :)"}' + { + "contents" : "Smile more :)", + "id" : "066E0A57-B67B-4C41-9DFF-99C738664EBD" + } + ``` + + ### List TODOs + + ```sh + curl -X GET http://localhost:8080/api/todos + { + "items" : [ + { + "contents" : "Smile more :)", + "id" : "066E0A57-B67B-4C41-9DFF-99C738664EBD" + } + ] + } + ``` + + ### Get a single TODO + + ```sh + curl -X GET http://localhost:8080/api/todos/066E0A57-B67B-4C41-9DFF-99C738664EBD + { + "contents" : "hello_again", + "id" : "A8E02E7C-1451-4CF9-B5C5-A33E92417454" + } + ``` + + ### Delete a TODO + + ```sh + curl -X DELETE http://localhost:8080/api/todos/066E0A57-B67B-4C41-9DFF-99C738664EBD + ``` + + ### Triggering a synthetic crash + + For easier testing of crash log uploading behavior, this template server also includes an operation for intentionally + crashing the server. + + > Warning: Consider removing this endpoint or guarding it with admin auth before deploying to production. + + ```sh + curl -f -X POST http://localhost:8080/api/crash + ``` + + The JSON crash log then appears in the `/logs` directory in the container. + + ## Viewing the API docs + + Run: `open http://localhost:8080/openapi.html`, from where you can make test HTTP requests to the local server. + + ## Viewing telemetry + + Run (and leave running) `docker-compose -f Deploy/Local/docker-compose.yaml up`, and make a few test requests in a separate Terminal window. + + Afterwards, this is how you can view the emitted logs, metrics, and traces. + + ### Logs + + If running from `docker-compose`: + + ```sh + docker exec local-crud-1 tail -f /tmp/crud_server.log + ``` + + If running in VS Code/Xcode, logs will be emitted in the IDE's console. + + ### Metrics + + Run: + + ```sh + open http://localhost:9090/graph?g0.expr=http_requests_total&g0.tab=1&g0.display_mode=lines&g0.show_exemplars=0&g0.range_input=1h + ``` + + to see the `http_requests_total` metric counts. + + ### Traces + + Run: + + ```sh + open http://localhost:16686/search?limit=20&lookback=1h&service=CRUDHTTPServer + ``` + + to see traces, which you can click on to reveal the individual spans with attributes. + + ## Configuration + + The service is configured using the following environment variables, all of which are optional with defaults. + + Some of these values are overriden in `docker-compose.yaml` for running locally, but if you're deploying in a production environment, you'd want to customize them further for easier operations. + + - `SERVER_ADDRESS` (default: `"0.0.0.0"`): The local address the server listens on. + - `SERVER_PORT` (default: `8080`): The local post the server listens on. + - `LOG_FORMAT` (default: `json`, possible values: `json`, `keyValue`): The output log format used for both file and console logging. + - `LOG_FILE` (default: `/tmp/crud_server.log`): The file to write logs to. + - `LOG_LEVEL` (default: `debug`, possible values: `trace`, `debug`, `info`, `notice`, `warning`, `error`): The level at which to log, includes all levels more severe as well. + - `LOG_BUFFER_SIZE` (default: `1024`): The number of log events to keep in memory before discarding new events if the log handler can't write into the backing file/console fast enough. + - `OTEL_EXPORTER_OTLP_ENDPOINT` (default: `localhost:4317`): The otel-collector URL. + - `OTEL_EXPORTER_OTLP_INSECURE` (default: `false`): Whether to allow an insecure connection when no scheme is provided in `OTEL_EXPORTER_OTLP_ENDPOINT`. + - `POSTGRES_URL` (default: `postgres://postgres@localhost:5432/postgres?sslmode=disable`): The URL to connect to the Postgres instance. + - `POSTGRES_MTLS` (default: nil): Set to `1` in order to use mTLS for authenticating with Postgres. + - `POSTGRES_MTLS_CERT_PATH` (default: nil): The path to the client certificate chain in a PEM file. + - `POSTGRES_MTLS_KEY_PATH` (default: nil): The path to the client private key in a PEM file. + - `POSTGRES_MTLS_ADDITIONAL_TRUST_ROOTS` (default: nil): One or more comma-separated paths to additional trust roots. + """ + } + } + + var deployToKube: String { + switch self { + case .crud: + "" + case .bare: + + """ + ## Deploying to Kube + + Check out [`Deploy/Kube`](Deploy/Kube) for instructions on deploying to Apple Kube. + + """ + + } + + } +} + +func packageSwift(serverType: ServerType) -> String { + """ + // swift-tools-version: 6.1 + // The swift-tools-version declares the minimum version of Swift required to build this package. + + import PackageDescription + + let package = Package( + name: "\(serverType.targetName.indenting(1))", + platforms: [ + \(serverType.platform.indenting(2)) + ], + dependencies: [ + \(serverType.packageDep.indenting(2)) + ], + targets: [ + .executableTarget( + name: "\(serverType.targetName.indenting(3))", + dependencies: [ + \(serverType.targetDep.indenting(4)) + ], + path: "Sources", + \(serverType.plugin.indenting(3)) + + ), + ] + ) + """ +} + +func genRioTemplatePkl(serverType: ServerType) -> String { + """ + /// For more information on how to configure this module, visit: + \(serverType == .crud ? + """ + /// https://pkl.apple.com/apple-package-docs/artifacts.apple.com/pkl/pkl/rio/1.3.3/Rio/index.html + /// https://pkl.apple.com/apple-package-docs/artifacts.apple.com/pkl/pkl/rio/current/Rio/index.html#_overview + """ : + """ + /// + """ + ) + @ModuleInfo { minPklVersion = "0.24.0" } + amends "package://artifacts.apple.com/pkl/pkl/rio@1.3.1#/Rio.pkl" + + // --- + + // !!! This is a template for your Rio file. + // Fill in the variables below first, and then rename this file to `rio.pkl`. + + /// The docker.apple.com/OWNER part of the pushed docker image. + local dockerOwnerName: String = "CHANGE_ME" + + /// The docker.apple.com/owner/REPO part of the pushed docker image. + local dockerRepoName: String = "CHANGE_ME" + + // --- + + schemaVersion = "2.0" + pipelines { + new { + group = "publish" + branchRules { + includePatterns { + "main" + } + } + machine { + baseImage = "docker.apple.com/cpbuild/cp-build:latest" + } + build { + template = "freestyle:v4:publish" + steps { + #"echo "noop""# + } + } + package { + version = "${GIT_BRANCH}-#{GIT_COMMIT}" + dockerfile { + new { + dockerfilePath = "Dockerfile" + perApplication = false + publish { + new { + repo = "docker.apple.com/\\(dockerOwnerName)/\\(dockerRepoName)" + } + } + } + } + } + } + new { + group = "build" + branchRules { + includePatterns { + "main" + } + } + machine { + baseImage = "docker.apple.com/cpbuild/cp-build:latest" + } + build { + template = "freestyle:v4:prb" + steps { + #"echo "noop""# + } + } + package { + version = "${GIT_BRANCH}-#{GIT_COMMIT}" + dockerfile { + new { + dockerfilePath = "Dockerfile" + perApplication = false + } + } + } + } + \(serverType == .crud ? + """ + + new { + group = "validate-openapi" + branchRules { + includePatterns { + "main" + } + } + machine { + baseImage = "docker-upstream.apple.com/dshanley/vacuum:latest" + } + build { + template = "freestyle:v4:prb" + steps { + #\""" + /usr/local/bin/vacuum lint -dq ./Public/openapi.yaml + \"""# + } + } + } + } + """ : "}") + + notify { + pullRequestComment { + postOnFailure = false + postOnSuccess = false + } + commitStatus { + enabled = true + } + } + """ +} + +func genDockerFile(serverType: ServerType) -> String { + + """ + ARG SWIFT_VERSION=6.1 + ARG UBI_VERSION=9 + + FROM docker.apple.com/base-images/ubi${UBI_VERSION}/swift${SWIFT_VERSION}-builder AS builder + + WORKDIR /code + + # First just resolve dependencies. + # This creates a cached layer that can be reused + # as long as your Package.swift/Package.resolved + # files do not change. + COPY ./Package.* ./ + RUN swift package resolve \\ + $([ -f ./Package.resolved ] && echo "--force-resolved-versions" || true) + + # Copy the Sources dir into container + COPY ./Sources ./Sources + \(serverType == .crud ? "COPY ./Public ./Public" : "") + + # Build the application, with optimizations + RUN swift build -c release --product \(serverType == .crud ? "CRUDHTTPServer" : "BareHTTPServer") + + FROM docker.apple.com/base-images/ubi${UBI_VERSION}-minimal/swift${SWIFT_VERSION}-runtime + + USER root + RUN mkdir -p /app/bin + COPY --from=builder /code/.build/release/\(serverType == .crud ? "CRUDHTTPServer" : "BareHTTPServer") /app/bin + \(serverType == .crud ? "COPY --from=builder /code/Public /app/Public" : "") + RUN mkdir -p /logs \(serverType == .bare ? "&& chown $NON_ROOT_USER_ID /logs" : "") + \(serverType == .crud ? "# Intentionally run as root, for now." : "USER $NON_ROOT_USER_ID") + + WORKDIR /app + ENV SWIFT_BACKTRACE=interactive=no,color=no,output-to=/logs,format=json,symbolicate=fast + CMD /app/bin/\(serverType == .crud ? "CRUDHTTPServer" : "BareHTTPServer") serve + EXPOSE 8080 + + """ +} +func genReadMe(serverType: ServerType) -> String { + """ + # \(serverType.targetName.uppercased()) + + A simple starter project for a server with the following features: + + \(serverType.features) + + ## Configuration/secrets + + ⚠️ This sample project is missing a configuration/secrets reader library for now. + + We are building one, follow this radar for progress: [rdar://148970365](rdar://148970365) (Swift Configuration: internal preview) + + In the meantime, the recommendation is: + - for environment variables, use `ProcessInfo.processInfo.environment` directly + - for JSON/YAML files, use [`JSONDecoder`](https://developer.apple.com/documentation/foundation/jsondecoder)/[`Yams`](https://github.com/jpsim/Yams), respectively, with a [`Decodable`](https://developer.apple.com/documentation/foundation/encoding-and-decoding-custom-types) custom type + - for Newcastle properties, use the [swift-newcastle-properties](https://github.pie.apple.com/swift-server/swift-newcastle-properties) library directly + + The upcoming Swift Configuration library will offer a unified API to access all of the above, so should be easy to migrate to it once it's ready. + + ## Running locally + + In one Terminal window, start all the services with `docker-compose -f Deploy/Local/docker-compose.yaml up`. + + ## Running published container images (skip the local build) + + Same steps as in "Running locally", just comment out `build:` and uncomment `image:` in the `docker-compose.yaml` file. + + ## Calling locally + + \(serverType.callingLocally) + ## Enabling Rio + + This sample project comes with a `rio.template.pkl`, where you can just update the docker.apple.com repository you'd like to publish your service to, and rename the file to `rio.pkl` - and be ready to go to onboard to Rio. + + + \(serverType.deployToKube) + """ +} + +func genDockerCompose(server:ServerType) -> String { + switch server { + case .bare: + """ + version: "3.5" + services: + bare: + # Comment out "build:" and uncomment "image:" to pull the existing image from docker.apple.com + build: ../.. + # image: docker.apple.com/swift-server/starter-projects-bare-http-server:latest + ports: + - "8080:8080" + + # yaml-language-server: $schema=https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json + """ + case .crud: + """ + version: "3.5" + services: + crud: + # Comment out "build:" and uncomment "image:" to pull the existing image from docker.apple.com + build: ../.. + # image: docker.apple.com/swift-server/starter-projects-crud-http-server:latest + ports: + - "8080:8080" + environment: + LOG_FORMAT: keyValue + LOG_LEVEL: debug + LOG_FILE: /logs/crud.log + POSTGRES_URL: postgres://postgres@postgres:5432/postgres?sslmode=disable + OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 + volumes: + - ./logs:/logs + depends_on: + postgres: + condition: service_healthy + + postgres: + image: docker-upstream.apple.com/postgres:latest + environment: + POSTGRES_USER: postgres + POSTGRES_DB: postgres + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + command: ["--config=/etc/otel-collector-config.yaml"] + volumes: + - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml + ports: + - "4317:4317" # OTLP gRPC receiver + + prometheus: + image: prom/prometheus:latest + entrypoint: + - "/bin/prometheus" + - "--log.level=debug" + - "--config.file=/etc/prometheus/prometheus.yaml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + volumes: + - ./prometheus.yaml:/etc/prometheus/prometheus.yaml + ports: + - "9090:9090" # Prometheus web UI + + jaeger: + image: jaegertracing/all-in-one + ports: + - "16686:16686" # Jaeger Web UI + + # yaml-language-server: $schema=https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json + """ + } +} + +func genOtelCollectorConfig() -> String { + """ + receivers: + otlp: + protocols: + grpc: + endpoint: "otel-collector:4317" + + exporters: + debug: # Data sources: traces, metrics, logs + verbosity: detailed + + otlp/jaeger: # Data sources: traces + endpoint: "jaeger:4317" + tls: + insecure: true + + service: + pipelines: + traces: + receivers: [otlp] + exporters: [otlp/jaeger, debug] + + # yaml-language-server: $schema=https://raw.githubusercontent.com/srikanthccv/otelcol-jsonschema/main/schema.json + + """ +} + +func genPrometheus() -> String { + """ + scrape_configs: + - job_name: "crud" + scrape_interval: 5s + metrics_path: "/metrics" + static_configs: + - targets: ["crud:8080"] + + # yaml-language-server: $schema=http://json.schemastore.org/prometheus + """ +} + +func genOpenAPIFrontend() -> String { + """ + + + + + + + Pollercoaster API + +
+ + + + + """ +} + +func genOpenAPIBackend() -> String { + """ + openapi: '3.1.0' + info: + title: CRUDHTTPServer + description: Create, read, delete, and list TODOs. + version: 1.0.0 + servers: + - url: /api + description: Invoke methods on this server. + tags: + - name: TODOs + paths: + /todos: + get: + summary: Fetch a list of TODOs. + operationId: listTODOs + tags: + - TODOs + responses: + '200': + description: Returns the list of TODOs. + content: + application/json: + schema: + $ref: '#/components/schemas/PageOfTODOs' + post: + summary: Create a new TODO. + operationId: createTODO + tags: + - TODOs + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateTODORequest' + responses: + '201': + description: The TODO was created successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/TODODetail' + /todos/{todoId}: + parameters: + - $ref: '#/components/parameters/path.todoId' + get: + summary: Fetch the details of a single TODO. + operationId: getTODODetail + tags: + - TODOs + responses: + '200': + description: A successful response. + content: + application/json: + schema: + $ref: "#/components/schemas/TODODetail" + '404': + description: A TODO with this id was not found. + delete: + summary: Delete a TODO. + operationId: deleteTODO + tags: + - TODOs + responses: + '204': + description: Successfully deleted the TODO. + # Warning: Remove this endpoint in production, or guard it by admin auth. + # It's here for easy testing of crash log uploading. + /crash: + post: + summary: Trigger a crash for testing crash handling. + operationId: crash + tags: + - Admin + responses: + '200': + description: Won't actually return - the server will crash. + components: + parameters: + path.todoId: + name: todoId + in: path + required: true + schema: + type: string + format: uuid + schemas: + PageOfTODOs: + description: A single page of TODOs. + properties: + items: + type: array + items: + $ref: '#/components/schemas/TODODetail' + required: + - items + CreateTODORequest: + description: The metadata required to create a TODO. + properties: + contents: + description: The contents of the TODO. + type: string + required: + - contents + TODODetail: + description: The details of a TODO. + properties: + id: + description: A unique identifier of the TODO. + type: string + format: uuid + contents: + description: The contents of the TODO. + type: string + required: + - id + - contents + + """ +} + +func writeHelloWorld() -> String { + """ + // The Swift Programming Language + // https://docs.swift.org/swift-book + + @main + struct start { + static func main() { + print("Hello, world!") + } + } + + """ +} + + +enum CrudServerFiles { + + static func genTelemetryFile(logLevel: LogLevel, logPath: URL, logFormat: LogFormat, logBufferSize: Int) -> String { + """ + import ServiceLifecycle + import Logging + import Logback + import Foundation + import Vapor + import Metrics + import Prometheus + import Tracing + import OTel + import OTLPGRPC + + enum LogFormat: String { + case json = "json" + case keyValue = "keyValue" + } + + struct ShutdownService: Service { + var shutdown: @Sendable () async throws -> Void + func run() async throws { + try await gracefulShutdown() + try await shutdown() + } + } + + struct Telemetry { + var services: [Service] + var metricsCollector: PrometheusCollectorRegistry + } + + func configureTelemetryServices() async throws -> Telemetry { + + var services: [Service] = [] + let metricsCollector: PrometheusCollectorRegistry + + let logLevel = Logger.Level.\(logLevel) + + // Logging + do { + let logFormat = LogFormat.\(logFormat) + let logFile = "\(logPath)" + let logBufferSize: Int = \(logBufferSize) + print("Logging to file: \\(logFile) at level: \\(logLevel.name) using format: \\(logFormat.rawValue), buffer size: \\(logBufferSize)") + + var logAppenders: [LogAppender] = [] + let logFormatter: LogFormatterProtocol + switch logFormat { + case .json: + logFormatter = JSONLogFormatter(appName: "CRUDHTTPServer", mode: .full) + case .keyValue: + logFormatter = KeyValueLogFormatter() + } + + let logDirectory = URL(fileURLWithPath: logFile).deletingLastPathComponent() + + // 1. ensure the folder for the rotating log files exists + try FileManager.default.createDirectory(at: logDirectory, withIntermediateDirectories: true) + + // 2. create file log appender + let fileAppender = RollingFileLogAppender( + path: logFile, + formatter: logFormatter, + policy: RollingFileLogAppender.RollingPolicy.size(100_000_000) + ) + let fileAsyncAppender = AsyncLogAppender( + appender: fileAppender, + capacity: logBufferSize + ) + + logAppenders.append(fileAsyncAppender) + + // 3. create console log appender + let consoleAppender = ConsoleLogAppender(formatter: logFormatter) + let consoleAsyncAppender = AsyncLogAppender( + appender: consoleAppender, + capacity: logBufferSize + ) + logAppenders.append(consoleAsyncAppender) + + // 4. start and set the appenders + logAppenders.forEach { $0.start() } + let startedLogAppenders = logAppenders + + // 5. create config resolver + let configResolver = DefaultConfigLogResolver(level: logLevel, appenders: logAppenders) + Log.addConfigResolver(configResolver) + + // 6. registers `Logback` as the logging backend + Logback.LogHandler.bootstrap() + + Log.defaultPayload["app_name"] = .string("CRUDHTTPServer") + + services.append(ShutdownService(shutdown: { + startedLogAppenders.forEach { $0.stop() } + })) + } + + // Metrics + do { + let metricsRegistry = PrometheusCollectorRegistry() + metricsCollector = metricsRegistry + let metricsFactory = PrometheusMetricsFactory(registry: metricsRegistry) + MetricsSystem.bootstrap(metricsFactory) + } + + // Tracing + do { + // Generic otel + let environment = OTelEnvironment.detected() + let resourceDetection = OTelResourceDetection(detectors: [ + OTelProcessResourceDetector(), + OTelEnvironmentResourceDetector(environment: environment), + .manual(OTelResource(attributes: [ + "service.name": "CRUDHTTPServer", + ])) + ]) + let resource = await resourceDetection.resource( + environment: environment, + logLevel: logLevel + ) + + let tracer = OTelTracer( + idGenerator: OTelRandomIDGenerator(), + sampler: OTelConstantSampler(isOn: true), + propagator: OTelW3CPropagator(), + processor: OTelBatchSpanProcessor( + exporter: try OTLPGRPCSpanExporter( + configuration: .init(environment: environment) + ), + configuration: .init(environment: environment) + ), + environment: environment, + resource: resource + ) + services.append(tracer) + InstrumentationSystem.bootstrap(tracer) + } + + return .init(services: services, metricsCollector: metricsCollector) + } + + extension Logger { + @TaskLocal + static var _current: Logger? + + static var current: Logger { + get throws { + guard let _current else { + struct NoCurrentLoggerError: Error {} + throw NoCurrentLoggerError() + } + return _current + } + } + } + + struct RequestLoggerInjectionMiddleware: Vapor.AsyncMiddleware { + func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { + return try await Logger.$_current.withValue(request.logger) { + return try await next.respond(to: request) + } + } + } + + """ + } + static func getServerService() -> String { + """ + import Vapor + import ServiceLifecycle + import OpenAPIVapor + import AsyncHTTPClient + + func configureServer(_ app: Application) async throws -> ServerService { + app.middleware.use(RequestLoggerInjectionMiddleware()) + app.middleware.use(TracingMiddleware()) + app.traceAutoPropagation = true + + // A health endpoint. + app.get("health") { _ in + "ok\\n" + } + + // Add Vapor middleware to serve the contents of the Public/ directory. + app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) + + // Redirect "/" and "/openapi" to openapi.html, which serves the Swagger UI. + app.get("openapi") { $0.redirect(to: "/openapi.html", redirectType: .normal) } + app.get { $0.redirect(to: "/openapi.html", redirectType: .normal) } + + // Create app state. + let handler = APIHandler(db: app.db) + + // Register the generated handlers. + let transport = VaporTransport(routesBuilder: app) + try handler.registerHandlers( + on: transport, + serverURL: Servers.Server1.url(), + configuration: .init(), + middlewares: [] + ) + + // Uncomment the code below if you'd like to make upstream HTTP calls. + // let httpClient = HTTPClient() + // let responseStatus = try await httpClient + // .execute(.init(url: "https://apple.com/"), deadline: .distantFuture) + // .status + + return ServerService(app: app) + } + + struct ServerService: Service { + var app: Application + func run() async throws { + try await app.execute() + } + } + """ + } + + static func getOpenAPIConfig() -> String { + """ + generate: + - types + - server + namingStrategy: idiomatic + + """ + } + + static func genAPIHandler() -> String { + """ + import OpenAPIRuntime + import HTTPTypes + import Fluent + import Foundation + + /// The implementation of the API described by the OpenAPI document. + /// + /// To make changes, add a new operation in the openapi.yaml file, then rebuild + /// and add the suggested corresponding method in this type. + struct APIHandler: APIProtocol { + + var db: Database + + func listTODOs( + _ input: Operations.ListTODOs.Input + ) async throws -> Operations.ListTODOs.Output { + let dbTodos = try await db.query(DB.TODO.self).all() + let apiTodos = try dbTodos.map { todo in + Components.Schemas.TODODetail( + id: try todo.requireID(), + contents: todo.contents + ) + } + return .ok(.init(body: .json(.init(items: apiTodos)))) + } + + func createTODO( + _ input: Operations.CreateTODO.Input + ) async throws -> Operations.CreateTODO.Output { + switch input.body { + case .json(let todo): + let newId = UUID().uuidString + let contents = todo.contents + let dbTodo = DB.TODO() + dbTodo.id = newId + dbTodo.contents = contents + try await dbTodo.save(on: db) + return .created(.init(body: .json(.init( + id: newId, + contents: contents + )))) + } + } + + func getTODODetail( + _ input: Operations.GetTODODetail.Input + ) async throws -> Operations.GetTODODetail.Output { + let id = input.path.todoId + guard let foundTodo = try await DB.TODO.find(id, on: db) else { + return .notFound + } + return .ok(.init(body: .json(.init( + id: id, + contents: foundTodo.contents + )))) + } + + func deleteTODO( + _ input: Operations.DeleteTODO.Input + ) async throws -> Operations.DeleteTODO.Output { + try await db.query(DB.TODO.self).filter(\\.$id == input.path.todoId).delete() + return .noContent(.init()) + } + + // Warning: Remove this endpoint in production, or guard it by admin auth. + // It's here for easy testing of crash log uploading. + func crash(_ input: Operations.Crash.Input) async throws -> Operations.Crash.Output { + // Trigger a fatal error for crash testing + fatalError("Crash endpoint triggered for testing purposes - this is intentional crash handling behavior") + } + } + """ + + } + + static func genEntryPointFile( + serverAddress: String, + serverPort: Int + ) -> String { + """ + import Vapor + import ServiceLifecycle + import OpenAPIVapor + import Foundation + + @main + struct Entrypoint { + static func main() async throws { + + // Configure telemetry + let telemetry = try await configureTelemetryServices() + + // Create the server + let app = try await Vapor.Application.make() + do { + app.http.server.configuration.address = .hostname( + "\(serverAddress)", + port: \(serverPort) + ) + + // Configure the metrics endpoint + app.get("metrics") { _ in + var buffer: [UInt8] = [] + buffer.reserveCapacity(1024) + telemetry.metricsCollector.emit(into: &buffer) + return String(decoding: buffer, as: UTF8.self) + } + + // Configure the database + try await configureDatabase(app: app) + + // Configure the server + let serverService = try await configureServer(app) + + // Start the service group, which spins up all the service above + let services: [Service] = telemetry.services + [serverService] + let serviceGroup = ServiceGroup( + services: services, + gracefulShutdownSignals: [.sigint], + cancellationSignals: [.sigterm], + logger: app.logger + ) + try await serviceGroup.run() + } catch { + try await app.asyncShutdown() + app.logger.error("Top level error", metadata: ["error": "\\(error)"]) + try FileHandle.standardError.write(contentsOf: Data("Final error: \\(error)\\n".utf8)) + exit(1) + } + } + } + + """ + } + +} + +enum DatabaseFile { + + static func genDatabaseFileWithMTLS( + mtlsPath: URL, + mtlsKeyPath: URL, + mtlsAdditionalTrustRoots: [URL], + postgresURL: URL + ) -> String { + + func escape(_ string: String) -> String { + return string + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + + let postgresURLString = escape(postgresURL.absoluteString) + let certPathString = escape(mtlsPath.path) + let keyPathString = escape(mtlsKeyPath.path) + let trustRootsStrings = mtlsAdditionalTrustRoots + .map { "\"\(escape($0.path))\"" } + .joined(separator: ", ") + + return """ + import FluentPostgresDriver + import PostgresKit + import Fluent + import Vapor + import Foundation + + func configureDatabase(app: Application) async throws { + let postgresURL = URL(string:"\(postgresURLString)")! + var postgresConfiguration = try SQLPostgresConfiguration(url: postgresURL) + app.logger.info("Loading MTLS certificates for PostgreSQL") + let certPath = "\(certPathString)" + let keyPath = "\(keyPathString)" + let additionalTrustRoots: [String] = [\(trustRootsStrings)] + var tls: TLSConfiguration = .makeClientConfiguration() + + enum PostgresMtlsError: Error, CustomStringConvertible { + case certChain(String, Error) + case privateKey(String, Error) + case additionalTrustRoots(String, Error) + case nioSSLContextCreation(Error) + + var description: String { + switch self { + case .certChain(let string, let error): + return "Cert chain failed: \\(string): \\(error)" + case .privateKey(let string, let error): + return "Private key failed: \\(string): \\(error)" + case .additionalTrustRoots(let string, let error): + return "Additional trust roots failed: \\(string): \\(error)" + case .nioSSLContextCreation(let error): + return "NIOSSLContext creation failed: \\(error)" + } + } + } + + do { + tls.certificateChain = try NIOSSLCertificate.fromPEMFile(certPath).map { .certificate($0) } + } catch { + throw PostgresMtlsError.certChain(certPath, error) + } + do { + tls.privateKey = try .privateKey(.init(file: keyPath, format: .pem)) + } catch { + throw PostgresMtlsError.privateKey(keyPath, error) + } + do { + tls.additionalTrustRoots = try additionalTrustRoots.map { + try .certificates(NIOSSLCertificate.fromPEMFile($0)) + } + } catch { + throw PostgresMtlsError.additionalTrustRoots(additionalTrustRoots.joined(separator: ","), error) + } + do { + postgresConfiguration.coreConfiguration.tls = .require(try NIOSSLContext(configuration: tls)) + } catch { + throw PostgresMtlsError.nioSSLContextCreation(error) + } + app.databases.use(.postgres(configuration: postgresConfiguration), as: .psql) + app.migrations.add([ + Migrations.CreateTODOs(), + ]) + do { + try await app.autoMigrate() + } catch { + app.logger.error("Database setup error", metadata: ["error": .string(String(reflecting: error))]) + throw error + } + } + + enum DB { + final class TODO: Model, @unchecked Sendable { + static let schema = "todos" + + @ID(custom: "id", generatedBy: .user) + var id: String? + + @Field(key: "contents") + var contents: String + } + } + + enum Migrations { + struct CreateTODOs: AsyncMigration { + func prepare(on database: Database) async throws { + try await database.schema(DB.TODO.schema) + .field("id", .string, .identifier(auto: false)) + .field("contents", .string, .required) + .create() + } + + func revert(on database: Database) async throws { + try await database + .schema(DB.TODO.schema) + .delete() + } + } + } + """ + } + + + static func genDatabaseFileWithoutMTLS(postgresURL: URL) -> String { + + func escape(_ string: String) -> String { + return string + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + + let postgresURLString = escape(postgresURL.absoluteString) + + return """ + import FluentPostgresDriver + import PostgresKit + import Fluent + import Vapor + import Foundation + + func configureDatabase(app: Application) async throws { + let postgresURL = "\(postgresURLString)" + var postgresConfiguration = try SQLPostgresConfiguration(url: postgresURL) + app.databases.use(.postgres(configuration: postgresConfiguration), as: .psql) + app.migrations.add([ + Migrations.CreateTODOs(), + ]) + do { + try await app.autoMigrate() + } catch { + app.logger.error("Database setup error", metadata: ["error": .string(String(reflecting: error))]) + throw error + } + } + + enum DB { + final class TODO: Model, @unchecked Sendable { + static let schema = "todos" + + @ID(custom: "id", generatedBy: .user) + var id: String? + + @Field(key: "contents") + var contents: String + } + } + + enum Migrations { + struct CreateTODOs: AsyncMigration { + func prepare(on database: Database) async throws { + try await database.schema(DB.TODO.schema) + .field("id", .string, .identifier(auto: false)) + .field("contents", .string, .required) + .create() + } + + func revert(on database: Database) async throws { + try await database + .schema(DB.TODO.schema) + .delete() + } + } + } + """ + } + +} + +enum BareServerFiles { + static func genEntryPointFile( + serverAddress: String, + serverPort: Int + ) -> String { + """ + import Vapor + + @main + struct Entrypoint { + static func main() async throws { + + // Create the server + let app = try await Vapor.Application.make() + app.http.server.configuration.address = .hostname( + "\(serverAddress)", + port: \(serverPort) + ) + try await configureServer(app) + try await app.execute() + } + } + + """ + } + + static func genServerFile() -> String { + """ + import Vapor + + func configureServer(_ app: Application) async throws { + + // A health endpoint. + app.get("health") { _ in + "ok\\n" + } + } + """ + } +} + + + +@main +struct ServerGenerator: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "server-generator", + abstract: "This template gets you started with starting to experiment with servers in swift.", + subcommands: [ + CRUD.self, + Bare.self + ], + ) + + @OptionGroup(visibility: .hidden) + var packageOptions: PkgDir + + mutating func run() throws { + guard let pkgDir = self.packageOptions.pkgDir else { + throw ValidationError("No --pkg-dir was provided.") + } + let packageDir = FilePath(pkgDir) + // Remove the main.swift left over from the base executable template, if it exists + try? fs.shared.rm(atPath: packageDir / "Sources") + } +} + +// MARK: - CRUD Command +public struct CRUD: ParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "crud", + abstract: "Generate CRUD server", + subcommands: [MTLS.self, NoMTLS.self] + ) + + @ParentCommand var serverGenerator: ServerGenerator + + @Option(help: "Set the logging level.") + var logLevel: LogLevel = .debug + + @Option(help: "Set the logging format.") + var logFormat: LogFormat = .json + + + @Option(help: "Set the logging file path.") + var logPath: String = "/tmp/crud_server.log" + + @Option(help: "Set logging buffer size (in bytes).") + var logBufferSize: Int = 1024 + + @OptionGroup + var serverOptions: SharedOptionsServers + + + public init() {} + mutating public func run() throws { + + try serverGenerator.run() + + guard let pkgDir = self.serverGenerator.packageOptions.pkgDir else { + throw ValidationError("No --pkg-dir was provided.") + } + + let packageDir = FilePath(pkgDir) + + + guard let url = URL(string: logPath) else { + throw ValidationError("Invalid log path: \(logPath)") + } + + let logURLPath = CLIURL(url) + + // Start from scratch with the Package.swift + try? fs.shared.rm(atPath: packageDir / "Package.swift") + + // Create base package + try packageSwift(serverType: .crud).write(toFile: packageDir / "Package.swift") + + if serverOptions.readMe.readMe { + try genReadMe(serverType: .crud).write(toFile: packageDir / "README.md") + } + try genRioTemplatePkl(serverType: .crud).write(toFile: packageDir / "rio.template.pkl") + try genDockerFile(serverType: .crud).write(toFile: packageDir / "Dockerfile.txt") + + //Create files for local folder + + try genDockerCompose(server: .crud).write(toFile: packageDir / "Deploy/Local/docker-compose.yaml") + try genOtelCollectorConfig().write(toFile: packageDir / "Deploy/Local/otel-collector-config.yaml") + try genPrometheus().write(toFile: packageDir / "Deploy/Local/prometheus.yaml") + + //Create files for public folder + try genOpenAPIBackend().write(toFile: packageDir / "Public/openapi.yaml") + try genOpenAPIFrontend().write(toFile: packageDir / "Public/openapi.html") + + //Create source files + try CrudServerFiles.genAPIHandler().write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/APIHandler.swift") + try CrudServerFiles.getOpenAPIConfig().write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/openapi-generator-config.yaml") + try CrudServerFiles.getServerService().write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/ServerService.swift") + try CrudServerFiles.genEntryPointFile(serverAddress: self.serverOptions.host, serverPort: self.serverOptions.port).write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/EntryPoint.swift") + try CrudServerFiles.genTelemetryFile(logLevel: self.logLevel, logPath: logURLPath.url, logFormat: self.logFormat, logBufferSize: self.logBufferSize).write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/Telemetry.swift") + + let targetPath = packageDir / "Public/openapi.yaml" + let linkPath = packageDir / "Sources/\(ServerType.crud.targetName)/openapi.yaml" + + // Compute the relative path from linkPath's parent to targetPath + let relativeTarget = targetPath.relative(to: linkPath.removingLastComponent()) + + try fs.shared.csl(atPath: linkPath, pointTo: relativeTarget) + } +} + +// MARK: - MTLS Subcommand +struct MTLS: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "mtls", + abstract: "Set up mutual TLS" + ) + + @ParentCommand var crud: CRUD + + @Option(help: "Path to MTLS certificate.") + var mtlsPath: CLIURL + + @Option(help: "Path to MTLS private key.") + var mtlsKeyPath: CLIURL + + @Option(help: "Paths to additional trust root certificates (PEM format).") + var mtlsAdditionalTrustRoots: [CLIURL] = [] + + @Option(help: "PostgreSQL database connection URL.") + var postgresURL: String = "postgres://postgres@localhost:5432/postgres?sslmode=disable" + + mutating func run() throws { + + try crud.run() + guard let pkgDir = self.crud.serverGenerator.packageOptions.pkgDir else { + throw ValidationError("No --pkg-dir was provided.") + } + + guard let url = URL(string: postgresURL) else { + throw ValidationError("Invalid URL: \(postgresURL)") + } + + let postgresURLComponents = CLIURL(url) + + + let packageDir = FilePath(pkgDir) + + let urls = self.mtlsAdditionalTrustRoots.map { $0.url } + + try DatabaseFile.genDatabaseFileWithMTLS(mtlsPath: self.mtlsPath.url, mtlsKeyPath: self.mtlsKeyPath.url, mtlsAdditionalTrustRoots: urls, postgresURL: postgresURLComponents.url).write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/Database.swift") + } +} + +struct NoMTLS: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "no-mtls", + abstract: "Do not set up mutual TLS" + ) + + @ParentCommand var crud: CRUD + + @Option(help: "PostgreSQL database connection URL.") + var postgresURL: String = "postgres://postgres@localhost:5432/postgres?sslmode=disable" + + mutating func run() throws { + + + try crud.run() + + guard let pkgDir = self.crud.serverGenerator.packageOptions.pkgDir else { + throw ValidationError("No --pkg-dir was provided.") + } + + guard let url = URL(string: postgresURL) else { + throw ValidationError("Invalid URL: \(postgresURL)") + } + + let postgresURLComponents = CLIURL(url) + + + let packageDir = FilePath(pkgDir) + + try DatabaseFile.genDatabaseFileWithoutMTLS(postgresURL: postgresURLComponents.url).write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/Database.swift") + } +} + + +// MARK: - Bare Command +struct Bare: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "bare", + abstract: "Generate a bare server" + ) + + @ParentCommand var serverGenerator: ServerGenerator + + @OptionGroup + var serverOptions: SharedOptionsServers + + mutating func run() throws { + + try self.serverGenerator.run() + + guard let pkgDir = self.serverGenerator.packageOptions.pkgDir else { + throw ValidationError("No --pkg-dir was provided.") + } + + let packageDir = FilePath(pkgDir) + + // Start from scratch with the Package.swift + try? fs.shared.rm(atPath: packageDir / "Package.swift") + + //Generate base package + try packageSwift(serverType: .bare).write(toFile: packageDir / "Package.swift") + if serverOptions.readMe.readMe { + try genReadMe(serverType: .bare).write(toFile: packageDir / "README.md") + } + try genRioTemplatePkl(serverType: .bare).write(toFile: packageDir / "rio.template.pkl") + try genDockerFile(serverType: .bare).write(toFile: packageDir / "Dockerfile.txt") + + + //Generate files for Deployment + try genDockerCompose(server: .bare).write(toFile: packageDir / "Deploy/Local/docker-compose.yaml") + + + // Generate sources files for bare http server + try BareServerFiles.genEntryPointFile(serverAddress: self.serverOptions.host, serverPort: self.serverOptions.port).write(toFile: packageDir / "Sources/\(ServerType.bare.targetName)/Entrypoint.swift") + try BareServerFiles.genServerFile().write(toFile: packageDir / "Sources/\(ServerType.bare.targetName)/Server.swift") + + } +} + +struct CLIURL: ExpressibleByArgument, Decodable { + let url: URL + + // Failable init for CLI arguments (strings) + init?(argument: String) { + guard let url = URL(string: argument) else { return nil } + self.url = url + } + + // Non-failable init for defaults from URL type + init(_ url: URL) { + self.url = url + } + + // Conform to Decodable by decoding a string and parsing URL + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let urlString = try container.decode(String.self) + guard let url = URL(string: urlString) else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid URL string.") + } + self.url = url + } +} + + +// MARK: - Shared option commands that are used to show inheritances of arguments and flags +struct PkgDir: ParsableArguments { + @Option(help: .hidden) + var pkgDir: String? +} + +struct readMe: ParsableArguments { + @Flag(help: "Add a README.md file with an introduction to the server + configuration?") + var readMe: Bool = false +} + +struct SharedOptionsServers: ParsableArguments { + @OptionGroup + var readMe: readMe + + @Option(help: "Server Port") + var port: Int = 8080 + + @Option(help: "Server Host") + var host: String = "0.0.0.0" +} + +public enum LogLevel: String, ExpressibleByArgument, CaseIterable, CustomStringConvertible { + case trace, debug, info, notice, warning, error, critical + public var description: String { rawValue } +} + +public enum LogFormat: String, ExpressibleByArgument, CaseIterable, CustomStringConvertible { + case json, keyValue + public var description: String { rawValue } +} diff --git a/Examples/init-templates/Templates/Template1/Template.swift b/Examples/init-templates/Templates/Template1/Template.swift new file mode 100644 index 00000000000..68f3fc1efb4 --- /dev/null +++ b/Examples/init-templates/Templates/Template1/Template.swift @@ -0,0 +1,68 @@ +import ArgumentParser +import Foundation +import SystemPackage + +extension FilePath { + static func / (left: FilePath, right: String) -> FilePath { + left.appending(right) + } +} + +extension String { + func write(toFile: FilePath) throws { + try self.write(toFile: toFile.string, atomically: true, encoding: .utf8) + } +} + +//basic structure of a template that uses string interpolation +@main +struct HelloTemplateTool: ParsableCommand { + + @OptionGroup(visibility: .hidden) + var packageOptions: PkgDir + + //swift argument parser needed to expose arguments to template generator + @Option(help: "The name of your app") + var name: String + + @Flag(help: "Include a README?") + var includeReadme: Bool = false + + //entrypoint of the template executable, that generates just a main.swift and a readme.md + func run() throws { + + guard let pkgDir = packageOptions.pkgDir else { + throw ValidationError("No --pkg-dir was provided.") + } + + let fs = FileManager.default + + let packageDir = FilePath(pkgDir) + + let mainFile = packageDir / "Sources" / name / "main.swift" + + try fs.createDirectory(atPath: mainFile.removingLastComponent().string, withIntermediateDirectories: true) + + try """ + // This is the entry point to your command-line app + print("Hello, \(name)!") + + """.write(toFile: mainFile) + + if includeReadme { + try """ + # \(name) + This is a new Swift app! + """.write(toFile: packageDir / "README.md") + } + + print("Project generated at \(packageDir)") + } +} + + +// MARK: - Shared option commands that are used to show inheritances of arguments and flags +struct PkgDir: ParsableArguments { + @Option(help: .hidden) + var pkgDir: String? +} diff --git a/Examples/init-templates/Templates/Template2/StencilTemplates/EnumExtension.stencil b/Examples/init-templates/Templates/Template2/StencilTemplates/EnumExtension.stencil new file mode 100644 index 00000000000..e48ff3c1f20 --- /dev/null +++ b/Examples/init-templates/Templates/Template2/StencilTemplates/EnumExtension.stencil @@ -0,0 +1,24 @@ +import SwiftUI +enum {{ enumName }} { + {% for palette in palettes %} + case {{ palette.name | lowercase }} + + {% for color in palette.colors %} + static let {{ color.name | lowercase }} = Color(red: {{ color.red }}, green: {{ color.green }}, blue: {{ color.blue }}, alpha: {{ color.alpha }}) + {% endfor %} + {% endfor %} +} + +{% if publicAccess %} +public extension {{ enumName }} { + {% for palette in palettes %} + static var {{ palette.name | lowercase }}: [Color] { + return [ + {% for color in palette.colors %} + {{ enumName }}.{{ color.name | lowercase }}, + {% endfor %} + ] + } + {% endfor %} +} +{% endif %} diff --git a/Examples/init-templates/Templates/Template2/StencilTemplates/StaticColorSets.stencil b/Examples/init-templates/Templates/Template2/StencilTemplates/StaticColorSets.stencil new file mode 100644 index 00000000000..5e1045f464e --- /dev/null +++ b/Examples/init-templates/Templates/Template2/StencilTemplates/StaticColorSets.stencil @@ -0,0 +1,30 @@ +import SwiftUI + +enum {{ enumName }}: String, CaseIterable { + {% for palette in palettes %} + case {{ palette.name | lowercase }} + {% endfor %} + + {% for palette in palettes %} + static var {{ palette.name | lowercase }}Colors: [Color] { + return [ + {% for color in palette.colors %} + Color(red: {{ color.red }}, green: {{ color.green }}, blue: {{ color.blue }}, opacity: {{ color.alpha }}), + {% endfor %} + ] + } + {% endfor %} +} + +{% if publicAccess %} +public extension {{ enumName }} { + var colors: [Color] { + switch self { + {% for palette in palettes %} + case .{{ palette.name | lowercase }}: + return {{ enumName }}.{{ palette.name | lowercase }}Colors + {% endfor %} + } + } +} +{% endif %} diff --git a/Examples/init-templates/Templates/Template2/StencilTemplates/StructColors.stencil b/Examples/init-templates/Templates/Template2/StencilTemplates/StructColors.stencil new file mode 100644 index 00000000000..c3a198feb12 --- /dev/null +++ b/Examples/init-templates/Templates/Template2/StencilTemplates/StructColors.stencil @@ -0,0 +1,27 @@ + +import SwiftUI + +struct {{ enumName }} { + + {% for palette in palettes %} + struct {{ palette.name | capitalize }} { + {% for color in palette.colors %} + static let {{ color.name | lowercase }} = Color(red: {{ color.red }}, green: {{ color.green }}, blue: {{ color.blue }}, opacity: {{ color.alpha }}) + {% endfor %} + } + {% endfor %} +} + +{% if publicAccess %} +public extension {{ enumName }} { + {% for palette in palettes %} + static var {{ palette.name | lowercase }}: [Color] { + return [ + {% for color in palette.colors %} + {{ enumName }}.{{ palette.name | capitalize }}.{{ color.name | lowercase }}, + {% endfor %} + ] + } + {% endfor %} +} +{% endif %} diff --git a/Examples/init-templates/Templates/Template2/Template.swift b/Examples/init-templates/Templates/Template2/Template.swift new file mode 100644 index 00000000000..22dd1b7f41b --- /dev/null +++ b/Examples/init-templates/Templates/Template2/Template.swift @@ -0,0 +1,132 @@ +//TEMPLATE: TemplateCLI + +import ArgumentParser +import Foundation +import Stencil +import PathKit +import Foundation +//basic structure of a template that uses string interpolation + + +import ArgumentParser + +@main +struct TemplateDeclarative: ParsableCommand { + + enum Template: String, ExpressibleByArgument, CaseIterable { + case EnumExtension + case StructColors + case StaticColorSets + + var path: String { + switch self { + case .EnumExtension: + "EnumExtension.stencil" + case .StructColors: + "StructColors.stencil" + case .StaticColorSets: + "StaticColorSets.stencil" + } + } + + var name: String { + switch self { + case .EnumExtension: + "EnumExtension" + case .StructColors: + "StructColors" + case .StaticColorSets: + "StaticColorSets" + } + } + } + //swift argument parser needed to expose arguments to template generator + @Option(name: [.customLong("template")], help: "Choose one template: \(Template.allCases.map(\.rawValue).joined(separator: ", "))") + var template: Template + + @Option(name: [.customLong("enumName"), .long], help: "Name of the generated enum") + var enumName: String = "AppColors" + + @Flag(name: .shortAndLong, help: "Use public access modifier") + var publicAccess: Bool = false + + @Option(name: [.customLong("palette"), .long], parsing: .upToNextOption, help: "Palette name of the format PaletteName:name=#RRGGBBAA") + var palettes: [String] + + + var templatesDirectory = "./MustacheTemplates" + + func run() throws { + + let parsedPalettes: [[String: Any]] = try palettes.map { paletteString in + let parts = paletteString.split(separator: ":", maxSplits: 1) + guard parts.count == 2 else { + throw ValidationError("Each --palette must be in the format PaletteName:name=#RRGGBBAA,...") + } + + let paletteName = String(parts[0]) + let colorEntries = parts[1].split(separator: ",") + + let colors = try colorEntries.map { entry in + let colorParts = entry.split(separator: "=") + guard colorParts.count == 2 else { + throw ValidationError("Color entry must be in format name=#RRGGBBAA") + } + + let name = String(colorParts[0]) + let hex = colorParts[1].trimmingCharacters(in: CharacterSet(charactersIn: "#")) + guard hex.count == 8 else { + throw ValidationError("Hex must be 8 characters (RRGGBBAA)") + } + + return [ + "name": name, + "red": String(hex.prefix(2)), + "green": String(hex.dropFirst(2).prefix(2)), + "blue": String(hex.dropFirst(4).prefix(2)), + "alpha": String(hex.dropFirst(6)) + ] + } + + return [ + "name": paletteName, + "colors": colors + ] + } + + let context: [String: Any] = [ + + "enumName": enumName, + "publicAccess": publicAccess, + + "palettes": parsedPalettes + ] + + + + if let url = Bundle.module.url(forResource: "\(template.name)", withExtension: "stencil") { + print("Template URL: \(url)") + + + let path = url.deletingLastPathComponent() + let environment = Environment(loader: FileSystemLoader(paths: [Path(path.path)])) + + + + let rendered = try environment.renderTemplate(name: "\(template.path)", context: context) + + print(rendered) + try rendered.write(toFile: "User.swift", atomically: true, encoding: .utf8) + + } else { + print("Template not found.") + } + + + + + } + + +} + diff --git a/Examples/init-templates/Tests/PartsServiceTests.swift b/Examples/init-templates/Tests/PartsServiceTests.swift new file mode 100644 index 00000000000..ecc5e570750 --- /dev/null +++ b/Examples/init-templates/Tests/PartsServiceTests.swift @@ -0,0 +1,69 @@ +import Testing +import Foundation + +@Suite +final class PartsServiceTemplateTests { + + //Struct to collect output from a process + struct processOutput { + let terminationStatus: Int32 + let output: String + + init(terminationStatus: Int32, output: String) { + self.terminationStatus = terminationStatus + self.output = output + } + } + + //function for running a process given arguments, executable, and a directory + func run(executableURL: URL, args: [String], directory: URL? = nil) throws -> processOutput{ + let process = Process() + process.executableURL = executableURL + process.arguments = args + + process.currentDirectoryURL = directory + + let pipe = Pipe() + process.standardOutput = pipe + process.standardOutput = pipe + + try process.run() + process.waitUntilExit() + + let outputData = pipe.fileHandleForReading.readDataToEndOfFile() + + let output = String(decoding: outputData, as: UTF8.self) + + return processOutput(terminationStatus: process.terminationStatus, output: output) + } + + // test case for your template + @Test + func testTemplate1_generatesExpectedFilesAndCompiles() throws { + // Setup temp directory for generating template + let fileManager = FileManager.default + let tempDir = fileManager.temporaryDirectory.appendingPathComponent("TemplateTest-\(UUID())") + + if fileManager.fileExists(atPath: tempDir.path) { + try fileManager.removeItem(at: tempDir) + } + try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true) + + // Path to built parts-service executable + let binary = productsDirectory.appendingPathComponent("parts-service") + + let output = try run(executableURL: binary, args: ["--pkg-dir", tempDir.path, "--readme"], directory: tempDir) + #expect(output.terminationStatus == 0, "parts-service should exit cleanly") + + let buildOutput = try run(executableURL: URL(fileURLWithPath: "/usr/bin/env"), args: ["swift", "build", "--package-path", tempDir.path]) + + #expect(buildOutput.terminationStatus == 0, "swift package builds") + } + + + // Find the built products directory when using SwiftPM test + var productsDirectory: URL { + URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(".build/debug") + } +} + diff --git a/Examples/init-templates/Tests/ServerTemplateTests/ServerTemplateTests.swift b/Examples/init-templates/Tests/ServerTemplateTests/ServerTemplateTests.swift new file mode 100644 index 00000000000..4cd5fbbd753 --- /dev/null +++ b/Examples/init-templates/Tests/ServerTemplateTests/ServerTemplateTests.swift @@ -0,0 +1,67 @@ +import Testing +import Foundation +@testable import ServerTemplate + +struct CrudServerFilesTests { + @Test + func testGenTelemetryFileContainsLoggingConfig() { + let logPath = URL(fileURLWithPath: "/tmp/test.log") + + let logURLPath = CLIURL(logPath) + + let generated = CrudServerFiles.genTelemetryFile( + logLevel: .info, + logPath: logPath, + logFormat: .json, + logBufferSize: 2048 + ) + + #expect(generated.contains("file:///tmp/test.log")) + #expect(generated.contains("let logBufferSize: Int = 2048")) + #expect(generated.contains("Logger.Level.info")) + #expect(generated.contains("LogFormat.json")) + } +} + + + +struct EntryPointTests { + @Test + func testGenEntryPointFileContainsServerAddressAndPort() { + + let serverAddress = "127.0.0.1" + let serverPort = 9090 + let code = CrudServerFiles.genEntryPointFile(serverAddress: serverAddress, serverPort: serverPort) + #expect(code.contains("\"\(serverAddress)\",")) + #expect(code.contains("port: \(serverPort)")) + #expect(code.contains("configureDatabase")) + #expect(code.contains("configureTelemetryServices")) + } +} + + +struct OpenAPIConfigTests { + @Test + func testOpenAPIConfigContainsGenerateSection() { + let config = CrudServerFiles.getOpenAPIConfig() + #expect(config.contains("generate:")) + #expect(config.contains("- types")) + #expect(config.contains("- server")) + } +} + + +struct APIHandlerTests { + @Test + func testGenAPIHandlerIncludesOperations() { + let code = CrudServerFiles.genAPIHandler() + #expect(code.contains("func listTODOs")) + #expect(code.contains("func createTODO")) + #expect(code.contains("func getTODODetail")) + #expect(code.contains("func deleteTODO")) + #expect(code.contains("func crash")) + } +} + + + diff --git a/Examples/init-templates/Tests/TemplateTest.swift b/Examples/init-templates/Tests/TemplateTest.swift new file mode 100644 index 00000000000..a5fe1307e4f --- /dev/null +++ b/Examples/init-templates/Tests/TemplateTest.swift @@ -0,0 +1,80 @@ +import Testing +import Foundation + +//a possible look into how to test templates +@Suite +final class TemplateCLITests { + + //Struct to collect output from a process + struct processOutput { + let terminationStatus: Int32 + let output: String + + init(terminationStatus: Int32, output: String) { + self.terminationStatus = terminationStatus + self.output = output + } + } + + //function for running a process given arguments, executable, and a directory + func run(executableURL: URL, args: [String], directory: URL? = nil) throws -> processOutput{ + let process = Process() + process.executableURL = executableURL + process.arguments = args + + process.currentDirectoryURL = directory + + let pipe = Pipe() + process.standardOutput = pipe + process.standardOutput = pipe + + try process.run() + process.waitUntilExit() + + let outputData = pipe.fileHandleForReading.readDataToEndOfFile() + + let output = String(decoding: outputData, as: UTF8.self) + + return processOutput(terminationStatus: process.terminationStatus, output: output) + } + + // test case for your template + @Test + func testTemplate1_generatesExpectedFilesAndCompiles() throws { + // Setup temp directory for generating template + let fileManager = FileManager.default + let tempDir = fileManager.temporaryDirectory.appendingPathComponent("Template1Test-\(UUID())") + let appName = "TestApp" + + if fileManager.fileExists(atPath: tempDir.path) { + try fileManager.removeItem(at: tempDir) + } + try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true) + + // Path to built TemplateCLI executable + let binary = productsDirectory.appendingPathComponent("simple-template1-tool") + + let output = try run(executableURL: binary, args: ["--name", appName, "--include-readme"], directory: tempDir) + #expect(output.terminationStatus == 0, "TemplateCLI should exit cleanly") + + // Check files + let mainSwift = tempDir.appendingPathComponent("Sources/\(appName)/main.swift") + let readme = tempDir.appendingPathComponent("README.md") + + #expect(fileManager.fileExists(atPath: mainSwift.path), "main.swift is generated") + #expect(fileManager.fileExists(atPath: readme.path), "README.md is generated") + + let outputBinary = tempDir.appendingPathComponent("main_executable") + + let compileOutput = try run(executableURL: URL(fileURLWithPath: "/usr/bin/env"), args: ["swiftc", mainSwift.path, "-o", outputBinary.path]) + + #expect(compileOutput.terminationStatus == 0, "swift file compiles") + } + + + // Find the built products directory when using SwiftPM test + var productsDirectory: URL { + URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(".build/debug") + } +} + diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 347efc9270c..891804ee56b 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -334,7 +334,6 @@ extension SwiftTestCommand { var buildDuration: DispatchTimeInterval = .never var logPath: String? = nil - var pluginOutput = "" do { let log = destinationAbsolutePath.appending("generation-output.log").pathString let (origOut, origErr) = try redirectStdoutAndStderr(to: log) diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index abdd100e7de..c293fedd55d 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -215,6 +215,7 @@ struct GitTemplateFetcher: TemplateFetcher { if self.isPermissionError(error) { throw GitTemplateFetcherError.authenticationRequired(source: self.source, error: error) } + swiftCommandState.observabilityScope.emit(error) throw GitTemplateFetcherError.cloneFailed(source: self.source) } } diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift index 4e33890aaac..a35a990a23e 100644 --- a/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift +++ b/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift @@ -14,7 +14,7 @@ import Foundation import SPMBuildCore import SwiftParser import SwiftSyntax -import System + import TSCBasic import TSCUtility @@ -631,16 +631,15 @@ public final class TemplatePromptingSystem { switch arg.kind { case .flag: - if !hasTTY && arg.isOptional == false && arg.defaultValue == nil { throw TemplateError.missingRequiredArgumentWithoutTTY(name: arg.valueName ?? "") - } + var confirmed: Bool? = nil if hasTTY { - confirmed = TemplatePromptingSystem.promptForConfirmation( + confirmed = try TemplatePromptingSystem.promptForConfirmation( prompt: promptMessage, - defaultBehavior: arg.defaultValue?.lowercased() == "true", + defaultBehavior: arg.defaultValue?.lowercased(), isOptional: arg.isOptional ) } @@ -658,7 +657,7 @@ public final class TemplatePromptingSystem { } if hasTTY { - let nilSuffix = arg.isOptional ? " (or enter \"nil\" to unset)" : "" + let nilSuffix = arg.isOptional && arg.defaultValue == nil ? " (or enter \"nil\" to unset)" : "" print(promptMessage + nilSuffix) } @@ -778,22 +777,54 @@ public final class TemplatePromptingSystem { /// - defaultBehavior: The default value if the user provides no input. /// - Returns: `true` if the user confirmed, otherwise `false`. - static func promptForConfirmation(prompt: String, defaultBehavior: Bool?, isOptional: Bool) -> Bool? { - var suffix = defaultBehavior == true ? " [Y/n]" : defaultBehavior == false ? " [y/N]" : " [y/n]" + static func promptForConfirmation(prompt: String, defaultBehavior: String?, isOptional: Bool) throws -> Bool? { + let defaultBool = defaultBehavior?.lowercased() == "true" + var suffix = defaultBehavior != nil ? + (defaultBool ? " [Y/n]" : " [y/N]") : " [y/n]" - if isOptional { - suffix = suffix + "or enter \"nil\" to unset." + if isOptional && defaultBehavior == nil { + suffix = suffix + " or enter \"nil\" to unset." } + print(prompt + suffix, terminator: " ") + guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { - return defaultBehavior ?? false + if let defaultBehavior = defaultBehavior { + return defaultBehavior == "true" + } else if isOptional { + return nil + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: "confirmation") + } } switch input { - case "y", "yes": return true - case "n", "no": return false - case "nil": return nil - default: return defaultBehavior ?? false + case "y", "yes": + return true + case "n", "no": + return false + case "nil": + if isOptional { + return nil + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: "confirmation") + } + case "": + if let defaultBehavior = defaultBehavior { + return defaultBehavior == "true" + } else if isOptional { + return nil + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: "confirmation") + } + default: + if let defaultBehavior = defaultBehavior { + return defaultBehavior == "true" + } else if isOptional { + return nil + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: "confirmation") + } } } diff --git a/Tests/CommandsTests/TemplateTests.swift b/Tests/CommandsTests/TemplateTests.swift index c2ce35bd551..5d7a0b25239 100644 --- a/Tests/CommandsTests/TemplateTests.swift +++ b/Tests/CommandsTests/TemplateTests.swift @@ -1835,6 +1835,85 @@ struct TemplateTests { #expect(result.contains("TestPackage")) // Post-terminator args should be handled separately } + + @Test + func handlesConditionalNilSuffixForOptions() throws { + // Test that "nil" suffix only shows for optional arguments without defaults + + // Test optional option without default, should show nil suffix + let optionalWithoutDefault = ArgumentInfoV0( + kind: .option, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: false, + parsingStrategy: .default, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "optional-param")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "optional-param"), + valueName: "optional-param", + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "Optional parameter", + discussion: nil + ) + + // Test optional option with default, should NOT show nil suffix + let optionalWithDefault = ArgumentInfoV0( + kind: .option, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: false, + parsingStrategy: .default, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "output")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "output"), + valueName: "output", + defaultValue: "stdout", + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "Output parameter", + discussion: nil + ) + + // Test required option, should NOT show nil suffix + let requiredOption = ArgumentInfoV0( + kind: .option, + shouldDisplay: true, + sectionTitle: nil, + isOptional: false, + isRepeating: false, + parsingStrategy: .default, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "name")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "name"), + valueName: "name", + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "Name parameter", + discussion: nil + ) + + // Optional without default should allow nil suffix + #expect(optionalWithoutDefault.isOptional == true) + #expect(optionalWithoutDefault.defaultValue == nil) + let shouldShowNilForOptionalWithoutDefault = optionalWithoutDefault.isOptional && optionalWithoutDefault.defaultValue == nil + #expect(shouldShowNilForOptionalWithoutDefault == true) + + // Optional with default should NOT allow nil suffix + #expect(optionalWithDefault.isOptional == true) + #expect(optionalWithDefault.defaultValue == "stdout") + let shouldShowNilForOptionalWithDefault = optionalWithDefault.isOptional && optionalWithDefault.defaultValue == nil + #expect(shouldShowNilForOptionalWithDefault == false) + + // Required should NOT allow nil suffix + #expect(requiredOption.isOptional == false) + let shouldShowNilForRequired = requiredOption.isOptional && requiredOption.defaultValue == nil + #expect(shouldShowNilForRequired == false) + } } // MARK: - Template Plugin Coordinator Tests From 7ef7d5e51b37be8d002cdca093d703b80291ae82 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 1 Oct 2025 10:49:38 -0400 Subject: [PATCH 124/225] fixed printing --- Examples/init-templates/Package.swift | 2 +- .../_InternalTemplateTestSupport/TemplateTesterManager.swift | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Examples/init-templates/Package.swift b/Examples/init-templates/Package.swift index a49af757dd6..d58da541839 100644 --- a/Examples/init-templates/Package.swift +++ b/Examples/init-templates/Package.swift @@ -18,7 +18,7 @@ let package = Package( .template(name: "Template2") + .template(name: "ServerTemplate"), dependencies: [ - .package(url: "https://github.com/apple/swift-argument-parser", exact: "main"), + .package(url: "https://github.com/apple/swift-argument-parser", branch: "main"), .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2"), .package(url: "https://github.com/stencilproject/Stencil.git", from: "0.15.1"), ], diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index ea0c8fae523..768b4320957 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -726,9 +726,6 @@ public class TemplateTestPromptingSystem { branchDepth: 0 ) - for path in paths { - print(path.displayFormat()) - } return paths } From 44e038ca1c3b814216a503bccd840ddcf4cba525 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 14 May 2025 13:36:12 -0400 Subject: [PATCH 125/225] created command for templates + added commands for local and git --- Sources/Commands/PackageCommands/Init.swift | 6 +- .../PackageCommands/PackageTemplates.swift | 123 ++++++++++++++++++ Sources/Workspace/InitTemplatePackage.swift | 110 ++++++++++++++++ 3 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 Sources/Commands/PackageCommands/PackageTemplates.swift create mode 100644 Sources/Workspace/InitTemplatePackage.swift diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 7057845a3df..c5d1336a93d 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -23,7 +23,11 @@ import SPMBuildCore extension SwiftPackageCommand { struct Init: SwiftCommand { public static let configuration = CommandConfiguration( - abstract: "Initialize a new package.") + abstract: "Initialize a new package.", + subcommands: [ + PackageTemplates.self + ] + ) @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions diff --git a/Sources/Commands/PackageCommands/PackageTemplates.swift b/Sources/Commands/PackageCommands/PackageTemplates.swift new file mode 100644 index 00000000000..e8dc593ef89 --- /dev/null +++ b/Sources/Commands/PackageCommands/PackageTemplates.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2014-2022 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 + +@_spi(SwiftPMInternal) +import CoreCommands + +import PackageModel +import Workspace +import SPMBuildCore +import Workspacex +import TSCUtility + + +extension SwiftPackageCommand { + struct PackageTemplates: SwiftCommand { + public static let configuration = CommandConfiguration( + commandName: "template", + abstract: "Initialize a new package based on a template." + ) + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + @Option( + name: .customLong("template-type"), + help: ArgumentHelp("Package type:", discussion: """ + - registry : Use a template from a package registry. + - git : Use a template from a Git repository. + - local : Use a template from a local directory. + """)) + var templateType: TemplateType + + @Option(name: .customLong("package-name"), help: "Provide the name for the new package.") + var packageName: String? + + //package-path provides the consumer's package + + @Option( + name: .customLong("template-path"), + help: "Specify the package path to operate on (default current directory). This changes the working directory before any other operation.", + completion: .directory + ) + public var templateDirectory: AbsolutePath + + //options for type git + @Option(help: "The exact package version to depend on.") + var exact: Version? + + @Option(help: "The specific package revision to depend on.") + var revision: String? + + @Option(help: "The branch of the package to depend on.") + var branch: String? + + @Option(help: "The package version to depend on (up to the next major version).") + var from: Version? + + @Option(help: "The package version to depend on (up to the next minor version).") + var upToNextMinorFrom: Version? + + @Option(help: "Specify upper bound on the package version range (exclusive).") + var to: Version? + + + enum TemplateType: String, Codable, CaseIterable, ExpressibleByArgument { + case local + case git + case registry + } + + + func run(_ swiftCommandState: SwiftCommandState) throws { + + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") + } + + switch self.templateType { + case .local: + try self.generateFromLocalTemplate( + packagePath: templateDirectory, + ) + case .git: + try self.generateFromGitTemplate() + case .generateFromRegistryTemplate: + try self.generateFromRegistryTemplate() + } + + } + + private func generateFromLocalTemplate( + packagePath: AbsolutePath + ) throws { + + let template = InitTemplatePackage(initMode: templateType, packageName: packageName, templatePath: templateDirectory, fileSystem: swiftCommandState.fileSystem) + } + + + private func generateFromGitTemplate( + ) throws { + + } + + private func generateFromRegistryTemplate( + ) throws { + + } + } +} + + diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift new file mode 100644 index 00000000000..213a36b1101 --- /dev/null +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -0,0 +1,110 @@ +// +// InitTemplatePackage.swift +// SwiftPM +// +// Created by John Bute on 2025-05-13. +// +import Basics +import PackageModel +import SPMBuildCore +import TSCUtility +@_spi(SwiftPMInternal) +import Foundation +import Commands + + +final class InitTemplatePackage { + + + + var initMode: TemplateType + + + var packageName: String? + + var templatePath: AbsolutePath + + let fileSystem: FileSystem + + init(initMode: InitPackage.PackageType, packageName: String? = nil, templatePath: AbsolutePath, fileSystem: FileSystem) { + self.initMode = initMode + self.packageName = packageName + self.templatePath = templatePath + self.fileSystem = fileSystem + + } + + + private func checkTemplateExists(templatePath: AbsolutePath) throws { + //Checks if there is a package in directory, if it contains a .template command line-tool and if it contains a /template folder. + + //check if the path does exist + guard self.fileSystem.exists(templatePath) else { + throw TemplateError.invalidPath + } + + // Check if Package.swift exists in the directory + let manifest = templatePath.appending(component: Manifest.filename) + guard self.fileSystem.exists(manifest) else { + throw TemplateError.invalidPath + } + + //check if package.swift contains a .plugin + + //check if it contains a template folder + + } + + func initPackage(_ swiftCommandState: SwiftCommandState) throws { + + //Logic here for initializing initial package (should find better way to organize this but for now) + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") + } + + let packageName = self.packageName ?? cwd.basename + + // Testing is on by default, with XCTest only enabled explicitly. + // For macros this is reversed, since we don't support testing + // macros with Swift Testing yet. + var supportedTestingLibraries = Set() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.swiftTesting) + } + + let initPackage = try InitPackage( + name: packageName, + packageType: initMode, + supportedTestingLibraries: supportedTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, + fileSystem: swiftCommandState.fileSystem + ) + initPackage.progressReporter = { message in + print(message) + } + try initPackage.writePackageStructure() + } +} + +private enum TemplateError: Swift.Error { + case invalidPath + case manifestAlreadyExists +} + + +extension TemplateError: CustomStringConvertible { + var description: String { + switch self { + case .manifestAlreadyExists: + return "a manifest file already exists in this directory" + case let .invalidPath: + return "Path does not exist, or is invalid." + } + } +} From f72d1d3374d5adb2e076f14abe99fe8c780917ca Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 21 May 2025 13:12:59 -0400 Subject: [PATCH 126/225] initial push for templates in package.swift prototype --- .../PackageCommands/PackageTemplates.swift | 18 +- .../PackageDescriptionSerialization.swift | 30 ++++ ...geDescriptionSerializationConversion.swift | 45 +++++ Sources/PackageDescription/Target.swift | 112 +++++++++++- .../PackageLoading/ManifestJSONParser.swift | 58 ++++++- Sources/PackageLoading/ManifestLoader.swift | 3 +- .../Manifest/TargetDescription.swift | 162 +++++++++++++++++- .../ManifestSourceGeneration.swift | 69 +++++++- Sources/Workspace/InitTemplatePackage.swift | 30 ++-- Tests/FunctionalTests/PluginTests.swift | 22 ++- 10 files changed, 517 insertions(+), 32 deletions(-) diff --git a/Sources/Commands/PackageCommands/PackageTemplates.swift b/Sources/Commands/PackageCommands/PackageTemplates.swift index e8dc593ef89..423031a27c8 100644 --- a/Sources/Commands/PackageCommands/PackageTemplates.swift +++ b/Sources/Commands/PackageCommands/PackageTemplates.swift @@ -19,10 +19,10 @@ import CoreCommands import PackageModel import Workspace import SPMBuildCore -import Workspacex import TSCUtility + extension SwiftPackageCommand { struct PackageTemplates: SwiftCommand { public static let configuration = CommandConfiguration( @@ -40,7 +40,7 @@ extension SwiftPackageCommand { - git : Use a template from a Git repository. - local : Use a template from a local directory. """)) - var templateType: TemplateType + var templateType: InitTemplatePackage.TemplateType @Option(name: .customLong("package-name"), help: "Provide the name for the new package.") var packageName: String? @@ -72,13 +72,6 @@ extension SwiftPackageCommand { @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? - - - enum TemplateType: String, Codable, CaseIterable, ExpressibleByArgument { - case local - case git - case registry - } func run(_ swiftCommandState: SwiftCommandState) throws { @@ -91,17 +84,19 @@ extension SwiftPackageCommand { case .local: try self.generateFromLocalTemplate( packagePath: templateDirectory, + swiftCommandState: swiftCommandState ) case .git: try self.generateFromGitTemplate() - case .generateFromRegistryTemplate: + case .registry: try self.generateFromRegistryTemplate() } } private func generateFromLocalTemplate( - packagePath: AbsolutePath + packagePath: AbsolutePath, + swiftCommandState: SwiftCommandState ) throws { let template = InitTemplatePackage(initMode: templateType, packageName: packageName, templatePath: templateDirectory, fileSystem: swiftCommandState.fileSystem) @@ -121,3 +116,4 @@ extension SwiftPackageCommand { } +extension InitTemplatePackage.TemplateType: ExpressibleByArgument {} diff --git a/Sources/PackageDescription/PackageDescriptionSerialization.swift b/Sources/PackageDescription/PackageDescriptionSerialization.swift index 8ade7137333..fc18ebaef8b 100644 --- a/Sources/PackageDescription/PackageDescriptionSerialization.swift +++ b/Sources/PackageDescription/PackageDescriptionSerialization.swift @@ -180,6 +180,7 @@ enum Serialization { case binary case plugin case `macro` + case template } enum PluginCapability: Codable { @@ -210,6 +211,34 @@ enum Serialization { case plugin(name: String, package: String?) } + enum TemplateInitializationOptions: Codable { + case packageInit(templateType: TemplateType, executable: TargetDependency, templatePermissions: [TemplatePermissions]?, description: String) + } + + enum TemplateType: Codable { + /// A target that contains code for the Swift package's functionality. + case regular + /// A target that contains code for an executable's main module. + case executable + /// A target that contains tests for the Swift package's other targets. + case test + /// A target that adapts a library on the system to work with Swift + /// packages. + case `macro` + } + + enum TemplateNetworkPermissionScope: Codable { + case none + case local(ports: [Int]) + case all(ports: [Int]) + case docker + case unixDomainSocket + } + + enum TemplatePermissions: Codable { + case allowNetworkConnections(scope: TemplateNetworkPermissionScope, reason: String) + } + struct Target: Codable { let name: String let path: String? @@ -230,6 +259,7 @@ enum Serialization { let linkerSettings: [LinkerSetting]? let checksum: String? let pluginUsages: [PluginUsage]? + let templateInitializationOptions: TemplateInitializationOptions? } // MARK: - resource serialization diff --git a/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift b/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift index 09d4f73ebf3..b8d9219c98a 100644 --- a/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift +++ b/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift @@ -237,6 +237,7 @@ extension Serialization.TargetType { case .binary: self = .binary case .plugin: self = .plugin case .macro: self = .macro + case .template: self = .template } } } @@ -295,6 +296,49 @@ extension Serialization.PluginUsage { } } +extension Serialization.TemplateInitializationOptions { + init(_ usage: PackageDescription.Target.TemplateInitializationOptions) { + switch usage { + + case .packageInit(let templateType, let executable, let templatePermissions, let description): + self = .packageInit(templateType: .init(templateType), executable: .init(executable), templatePermissions: templatePermissions?.map { .init($0) }, description: description) + } + } +} +extension Serialization.TemplateType { + init(_ type: PackageDescription.Target.TemplateType) { + switch type { + case .regular: self = .regular + case .executable: self = .executable + case .macro: self = .macro + case .test: self = .test + } + } +} + +extension Serialization.TemplatePermissions { + init(_ permission: PackageDescription.TemplatePermissions) { + switch permission { + case .allowNetworkConnections(let scope, let reason): self = .allowNetworkConnections( + scope: .init(scope), + reason: reason + ) + } + } +} + +extension Serialization.TemplateNetworkPermissionScope { + init(_ scope: PackageDescription.TemplateNetworkPermissionScope) { + switch scope { + case .none: self = .none + case .local(let ports): self = .local(ports: ports) + case .all(let ports): self = .all(ports: ports) + case .docker: self = .docker + case .unixDomainSocket: self = .unixDomainSocket + } + } +} + extension Serialization.Target { init(_ target: PackageDescription.Target) { self.name = target.name @@ -316,6 +360,7 @@ extension Serialization.Target { self.linkerSettings = target.linkerSettings?.map { .init($0) } self.checksum = target.checksum self.pluginUsages = target.plugins?.map { .init($0) } + self.templateInitializationOptions = target.templateInitializationOptions.map { .init($0) } } } diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index 9c7183a82f4..0ef4f126847 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -39,6 +39,8 @@ public final class Target { case plugin /// A target that provides a Swift macro. case `macro` + /// A target that provides a Swift template + case template } /// The different types of a target's dependency on another entity. @@ -229,6 +231,25 @@ public final class Target { case plugin(name: String, package: String?) } + public var templateInitializationOptions: TemplateInitializationOptions? + + public enum TemplateType: String { + /// A target that contains code for the Swift package's functionality. + case regular + /// A target that contains code for an executable's main module. + case executable + /// A target that contains tests for the Swift package's other targets. + case test + /// A target that adapts a library on the system to work with Swift + /// packages. + case `macro` + } + + @available(_PackageDescription, introduced: 5.9) + public enum TemplateInitializationOptions { + case packageInit(templateType: TemplateType, executable: Dependency, templatePermissions: [TemplatePermissions]? = nil, description: String) + } + /// Construct a target. @_spi(PackageDescriptionInternal) public init( @@ -250,7 +271,8 @@ public final class Target { swiftSettings: [SwiftSetting]? = nil, linkerSettings: [LinkerSetting]? = nil, checksum: String? = nil, - plugins: [PluginUsage]? = nil + plugins: [PluginUsage]? = nil, + templateInitializationOptions: TemplateInitializationOptions? = nil ) { self.name = name self.dependencies = dependencies @@ -279,7 +301,8 @@ public final class Target { pkgConfig == nil && providers == nil && pluginCapability == nil && - checksum == nil + checksum == nil && + templateInitializationOptions == nil ) case .system: precondition( @@ -295,7 +318,8 @@ public final class Target { swiftSettings == nil && linkerSettings == nil && checksum == nil && - plugins == nil + plugins == nil && + templateInitializationOptions == nil ) case .binary: precondition( @@ -311,7 +335,8 @@ public final class Target { cxxSettings == nil && swiftSettings == nil && linkerSettings == nil && - plugins == nil + plugins == nil && + templateInitializationOptions == nil ) case .plugin: precondition( @@ -325,7 +350,8 @@ public final class Target { cxxSettings == nil && swiftSettings == nil && linkerSettings == nil && - plugins == nil + plugins == nil && + templateInitializationOptions == nil ) case .macro: precondition( @@ -336,7 +362,16 @@ public final class Target { providers == nil && pluginCapability == nil && cSettings == nil && - cxxSettings == nil + cxxSettings == nil && + templateInitializationOptions == nil + ) + case .template: + precondition( + url == nil && + pkgConfig == nil && + providers == nil && + pluginCapability == nil && + checksum == nil ) } } @@ -1234,6 +1269,26 @@ public final class Target { packageAccess: packageAccess, pluginCapability: capability) } + + @available(_PackageDescription, introduced: 6.0) + public static func template( + name: String, + templateInitializationOptions: TemplateInitializationOptions, + exclude: [String] = [], + executable: Dependency + ) -> Target { + return Target( + name: name, + dependencies: [], + path: nil, + exclude: exclude, + sources: nil, + publicHeadersPath: nil, + type: .template, + packageAccess: false, + templateInitializationOptions: templateInitializationOptions + ) + } } extension Target.Dependency { @@ -1562,3 +1617,48 @@ extension Target.PluginUsage: ExpressibleByStringLiteral { } } +/// The type of permission a plug-in requires. +/// +/// Supported types are ``allowNetworkConnections(scope:reason:)`` and ``writeToPackageDirectory(reason:)``. +@available(_PackageDescription, introduced: 6.0) +public enum TemplatePermissions { + /// Create a permission to make network connections. + /// + /// The command plug-in requires permission to make network connections. The `reason` string is shown + /// to the user at the time of request for approval, explaining why the plug-in is requesting access. + /// - Parameter scope: The scope of the permission. + /// - Parameter reason: A reason why the permission is needed. This is shown to the user when permission is sought. + @available(_PackageDescription, introduced: 6.0) + case allowNetworkConnections(scope: TemplateNetworkPermissionScope, reason: String) + +} + +/// The scope of a network permission. +/// +/// The scope can be none, local connections only, or all connections. +@available(_PackageDescription, introduced: 5.9) +public enum TemplateNetworkPermissionScope { + /// Do not allow network access. + case none + /// Allow local network connections; can be limited to a list of allowed ports. + case local(ports: [Int] = []) + /// Allow local and outgoing network connections; can be limited to a list of allowed ports. + case all(ports: [Int] = []) + /// Allow connections to Docker through UNIX domain sockets. + case docker + /// Allow connections to any UNIX domain socket. + case unixDomainSocket + + /// Allow local and outgoing network connections, limited to a range of allowed ports. + public static func all(ports: Range) -> TemplateNetworkPermissionScope { + return .all(ports: Array(ports)) + } + + /// Allow local network connections, limited to a range of allowed ports. + public static func local(ports: Range) -> TemplateNetworkPermissionScope { + return .local(ports: Array(ports)) + } +} + + + diff --git a/Sources/PackageLoading/ManifestJSONParser.swift b/Sources/PackageLoading/ManifestJSONParser.swift index 07ca0e6eacc..bfa6352bc4c 100644 --- a/Sources/PackageLoading/ManifestJSONParser.swift +++ b/Sources/PackageLoading/ManifestJSONParser.swift @@ -198,6 +198,7 @@ enum ManifestJSONParser { try target.exclude.forEach{ _ = try RelativePath(validating: $0) } let pluginUsages = target.pluginUsages?.map { TargetDescription.PluginUsage.init($0) } + let templateInitializationOptions = try target.templateInitializationOptions.map { try TargetDescription.TemplateInitializationOptions.init($0, identityResolver: identityResolver)} return try TargetDescription( name: target.name, @@ -215,7 +216,8 @@ enum ManifestJSONParser { pluginCapability: pluginCapability, settings: try Self.parseBuildSettings(target), checksum: target.checksum, - pluginUsages: pluginUsages + pluginUsages: pluginUsages, + templateInitializationOptions: templateInitializationOptions ) } @@ -566,6 +568,8 @@ extension TargetDescription.TargetKind { self = .plugin case .macro: self = .macro + case .template: + self = .template } } } @@ -631,6 +635,58 @@ extension TargetDescription.PluginUsage { } } +extension TargetDescription.TemplateInitializationOptions { + init (_ usage: Serialization.TemplateInitializationOptions, identityResolver: IdentityResolver) throws { + switch usage { + case .packageInit(let templateType, let executable, let templatePermissions, let description): + self = .packageInit(templateType: .init(templateType), executable: try .init(executable, identityResolver: identityResolver), templatePermissions: templatePermissions?.map { .init($0) }, description: description) + } + } +} + +extension TargetDescription.TemplateType { + init(_ type: Serialization.TemplateType) { + switch type { + case .regular: + self = .regular + case .executable: + self = .executable + case .test: + self = .test + case .macro: + self = .macro + } + } +} + +extension TargetDescription.TemplatePermission { + init(_ permission: Serialization.TemplatePermissions) { + switch permission { + case .allowNetworkConnections(let scope, let reason): + self = .allowNetworkConnections(scope: .init(scope), reason: reason) + } + + } +} + +extension TargetDescription.TemplateNetworkPermissionScope { + init(_ scope: Serialization.TemplateNetworkPermissionScope) { + switch scope { + case .none: + self = .none + case .local(let ports): + self = .local(ports: ports) + case .all(ports: let ports): + self = .all(ports: ports) + case .docker: + self = .docker + case .unixDomainSocket: + self = .unixDomainSocket + } + } +} + + extension TSCUtility.Version { init(_ version: Serialization.Version) { self.init( diff --git a/Sources/PackageLoading/ManifestLoader.swift b/Sources/PackageLoading/ManifestLoader.swift index 70a93854142..aac61e0611f 100644 --- a/Sources/PackageLoading/ManifestLoader.swift +++ b/Sources/PackageLoading/ManifestLoader.swift @@ -943,10 +943,11 @@ public final class ManifestLoader: ManifestLoaderProtocol { return evaluationResult // Return the result containing the error output } + // Read the JSON output that was emitted by libPackageDescription. let jsonOutput: String = try localFileSystem.readFileContents(jsonOutputFile) evaluationResult.manifestJSON = jsonOutput - + print(jsonOutput) // withTemporaryDirectory handles cleanup automatically return evaluationResult } diff --git a/Sources/PackageModel/Manifest/TargetDescription.swift b/Sources/PackageModel/Manifest/TargetDescription.swift index d336bedb60a..6f5fa5068dc 100644 --- a/Sources/PackageModel/Manifest/TargetDescription.swift +++ b/Sources/PackageModel/Manifest/TargetDescription.swift @@ -24,6 +24,7 @@ public struct TargetDescription: Hashable, Encodable, Sendable { case binary case plugin case `macro` + case template } /// Represents a target's dependency on another entity. @@ -194,6 +195,47 @@ public struct TargetDescription: Hashable, Encodable, Sendable { case plugin(name: String, package: String?) } + public let templateInitializationOptions: TemplateInitializationOptions? + + public enum TemplateInitializationOptions: Hashable, Sendable { + case packageInit(templateType: TemplateType, executable: Dependency, templatePermissions: [TemplatePermission]?, description: String) + } + + public enum TemplateType: String, Hashable, Codable, Sendable { + /// A target that contains code for the Swift package's functionality. + case regular + /// A target that contains code for an executable's main module. + case executable + /// A target that contains tests for the Swift package's other targets. + case test + /// A target that adapts a library on the system to work with Swift + /// packages. + case `macro` + } + + public enum TemplateNetworkPermissionScope: Hashable, Codable, Sendable { + case none + case local(ports: [Int]) + case all(ports: [Int]) + case docker + case unixDomainSocket + + public init?(_ scopeString: String, ports: [Int]) { + switch scopeString { + case "none": self = .none + case "local": self = .local(ports: ports) + case "all": self = .all(ports: ports) + case "docker": self = .docker + case "unix-socket": self = .unixDomainSocket + default: return nil + } + } + } + + public enum TemplatePermission: Hashable, Codable, Sendable { + case allowNetworkConnections(scope: TemplateNetworkPermissionScope, reason: String) + } + public init( name: String, dependencies: [Dependency] = [], @@ -210,7 +252,8 @@ public struct TargetDescription: Hashable, Encodable, Sendable { pluginCapability: PluginCapability? = nil, settings: [TargetBuildSettingDescription.Setting] = [], checksum: String? = nil, - pluginUsages: [PluginUsage]? = nil + pluginUsages: [PluginUsage]? = nil, + templateInitializationOptions: TemplateInitializationOptions? = nil ) throws { let targetType = String(describing: type) switch type { @@ -245,6 +288,14 @@ public struct TargetDescription: Hashable, Encodable, Sendable { propertyName: "checksum", value: checksum ?? "" ) } + if templateInitializationOptions != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "templateInitializationOptions", + value: String(describing: templateInitializationOptions!) + ) + } + case .system: if !dependencies.isEmpty { throw Error.disallowedPropertyInTarget( targetName: name, @@ -300,6 +351,13 @@ public struct TargetDescription: Hashable, Encodable, Sendable { propertyName: "pluginUsages", value: String(describing: pluginUsages!) ) } + if templateInitializationOptions != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "templateInitializationOptions", + value: String(describing: templateInitializationOptions!) + ) + } case .binary: if path == nil && url == nil { throw Error.binaryTargetRequiresEitherPathOrURL(targetName: name) } if !dependencies.isEmpty { throw Error.disallowedPropertyInTarget( @@ -362,6 +420,13 @@ public struct TargetDescription: Hashable, Encodable, Sendable { propertyName: "pluginUsages", value: String(describing: pluginUsages!) ) } + if templateInitializationOptions != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "templateInitializationOptions", + value: String(describing: templateInitializationOptions!) + ) + } case .plugin: if pluginCapability == nil { throw Error.pluginTargetRequiresPluginCapability(targetName: name) } if url != nil { throw Error.disallowedPropertyInTarget( @@ -406,6 +471,13 @@ public struct TargetDescription: Hashable, Encodable, Sendable { propertyName: "pluginUsages", value: String(describing: pluginUsages!) ) } + if templateInitializationOptions != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "templateInitializationOptions", + value: String(describing: templateInitializationOptions!) + ) + } case .macro: if url != nil { throw Error.disallowedPropertyInTarget( targetName: name, @@ -443,6 +515,53 @@ public struct TargetDescription: Hashable, Encodable, Sendable { propertyName: "pluginCapability", value: String(describing: pluginCapability!) ) } + if templateInitializationOptions != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "templateInitializationOptions", + value: String(describing: templateInitializationOptions!) + ) + } + case .template: + // List forbidden properties for `.template` targets + if url != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "url", + value: url ?? "" + ) } + if pkgConfig != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "pkgConfig", + value: pkgConfig ?? "" + ) } + if providers != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "providers", + value: String(describing: providers!) + ) } + if pluginCapability != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "pluginCapability", + value: String(describing: pluginCapability!) + ) } + if checksum != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "checksum", + value: checksum ?? "" + ) } + if templateInitializationOptions == nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "templateInitializationOptions", + value: String(describing: templateInitializationOptions!) + ) + } + } self.name = name @@ -461,6 +580,7 @@ public struct TargetDescription: Hashable, Encodable, Sendable { self.settings = settings self.checksum = checksum self.pluginUsages = pluginUsages + self.templateInitializationOptions = templateInitializationOptions } } @@ -586,6 +706,44 @@ extension TargetDescription.PluginUsage: Codable { } } +extension TargetDescription.TemplateInitializationOptions: Codable { + private enum CodingKeys: String, CodingKey { + case packageInit + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .packageInit(a1, a2, a3, a4): + var unkeyedContainer = container.nestedUnkeyedContainer(forKey: .packageInit) + try unkeyedContainer.encode(a1) + try unkeyedContainer.encode(a2) + if let permissions = a3 { + try unkeyedContainer.encode(a3) + } else { + try unkeyedContainer.encodeNil() + } + try unkeyedContainer.encode(a4) + } + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + guard let key = values.allKeys.first(where: values.contains) else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Did not find a matching key")) + } + switch key { + case .packageInit: + var unkeyedValues = try values.nestedUnkeyedContainer(forKey: key) + let templateType = try unkeyedValues.decode(TargetDescription.TemplateType.self) + let executable = try unkeyedValues.decode(TargetDescription.Dependency.self) + let templatePermissions = try unkeyedValues.decodeIfPresent([TargetDescription.TemplatePermission].self) + let description = try unkeyedValues.decode(String.self) + self = .packageInit(templateType: templateType, executable: executable, templatePermissions: templatePermissions ?? nil, description: description) + } + } +} + import protocol Foundation.LocalizedError private enum Error: LocalizedError, Equatable { @@ -606,3 +764,5 @@ private enum Error: LocalizedError, Equatable { } } } + + diff --git a/Sources/PackageModel/ManifestSourceGeneration.swift b/Sources/PackageModel/ManifestSourceGeneration.swift index 9c9a19a3a2d..d1818cb1fd3 100644 --- a/Sources/PackageModel/ManifestSourceGeneration.swift +++ b/Sources/PackageModel/ManifestSourceGeneration.swift @@ -437,7 +437,12 @@ fileprivate extension SourceCodeFragment { if let checksum = target.checksum { params.append(SourceCodeFragment(key: "checksum", string: checksum)) } - + + if let templateInitializationOptions = target.templateInitializationOptions { + let node = SourceCodeFragment(from: templateInitializationOptions) + params.append(SourceCodeFragment(key: "templateInitializationOptions", subnode: node)) + } + switch target.type { case .regular: self.init(enum: "target", subnodes: params, multiline: true) @@ -453,6 +458,8 @@ fileprivate extension SourceCodeFragment { self.init(enum: "plugin", subnodes: params, multiline: true) case .macro: self.init(enum: "macro", subnodes: params, multiline: true) + case .template: + self.init(enum: "template", subnodes: params, multiline: true) } } @@ -652,6 +659,66 @@ fileprivate extension SourceCodeFragment { } } + init(from templateInitializationOptions: TargetDescription.TemplateInitializationOptions) { + switch templateInitializationOptions { + case .packageInit(let templateType, let executable, let templatePermissions, let description): + var params: [SourceCodeFragment] = [] + + switch templateType { + case .regular: + self.init(enum: "target", subnodes: params, multiline: true) + case .executable: + self.init(enum: "executableTarget", subnodes: params, multiline: true) + case .test: + self.init(enum: "testTarget", subnodes: params, multiline: true) + case .macro: + self.init(enum: "macro", subnodes: params, multiline: true) + } + // Template type as an enum + + // Executable fragment + params.append(SourceCodeFragment(key: "executable", subnode: .init(from: executable))) + + // Permissions, if any + if let permissions = templatePermissions { + let permissionFragments = permissions.map { SourceCodeFragment(from:$0) } + params.append(SourceCodeFragment(key: "permissions", subnodes: permissionFragments)) + } + + // Description + params.append(SourceCodeFragment(key: "description", string: description)) + + self.init(enum: "packageInit", subnodes: params) + } + } + + init(from permission: TargetDescription.TemplatePermission) { + switch permission { + case .allowNetworkConnections(let scope, let reason): + let scope = SourceCodeFragment(key: "scope", subnode: .init(from: scope)) + let reason = SourceCodeFragment(key: "reason", string: reason) + self.init(enum: "allowNetworkConnections", subnodes: [scope, reason]) + } + } + + init(from networkPermissionScope: TargetDescription.TemplateNetworkPermissionScope) { + switch networkPermissionScope { + case .none: + self.init(enum: "none") + case .local(let ports): + let ports = SourceCodeFragment(key: "ports", subnodes: ports.map { SourceCodeFragment("\($0)") }) + self.init(enum: "local", subnodes: [ports]) + case .all(let ports): + let ports = SourceCodeFragment(key: "ports", subnodes: ports.map { SourceCodeFragment("\($0)") }) + self.init(enum: "all", subnodes: [ports]) + case .docker: + self.init(enum: "docker") + case .unixDomainSocket: + self.init(enum: "unixDomainSocket") + } + } + + /// Instantiates a SourceCodeFragment to represent a single target build setting. init(from setting: TargetBuildSettingDescription.Setting) { var params: [SourceCodeFragment] = [] diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 213a36b1101..81506213928 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -8,30 +8,38 @@ import Basics import PackageModel import SPMBuildCore import TSCUtility -@_spi(SwiftPMInternal) import Foundation -import Commands +import Basics +import PackageModel +import SPMBuildCore +import TSCUtility +public final class InitTemplatePackage { -final class InitTemplatePackage { - - - var initMode: TemplateType - + + public enum TemplateType: String, CustomStringConvertible { + case local = "local" + case git = "git" + case registry = "registry" + + public var description: String { + return rawValue + } + } + var packageName: String? var templatePath: AbsolutePath let fileSystem: FileSystem - init(initMode: InitPackage.PackageType, packageName: String? = nil, templatePath: AbsolutePath, fileSystem: FileSystem) { + public init(initMode: InitTemplatePackage.TemplateType, packageName: String? = nil, templatePath: AbsolutePath, fileSystem: FileSystem) { self.initMode = initMode self.packageName = packageName self.templatePath = templatePath self.fileSystem = fileSystem - } @@ -54,7 +62,7 @@ final class InitTemplatePackage { //check if it contains a template folder } - +/* func initPackage(_ swiftCommandState: SwiftCommandState) throws { //Logic here for initializing initial package (should find better way to organize this but for now) @@ -90,8 +98,10 @@ final class InitTemplatePackage { } try initPackage.writePackageStructure() } + */ } + private enum TemplateError: Swift.Error { case invalidPath case manifestAlreadyExists diff --git a/Tests/FunctionalTests/PluginTests.swift b/Tests/FunctionalTests/PluginTests.swift index 03f4af842b8..264c132e5ff 100644 --- a/Tests/FunctionalTests/PluginTests.swift +++ b/Tests/FunctionalTests/PluginTests.swift @@ -527,7 +527,7 @@ final class PluginTests { try localFileSystem.writeFileContents( manifestFile, string: """ - // swift-tools-version: 5.6 + // swift-tools-version: 6.1 import PackageDescription let package = Package( name: "MyPackage", @@ -535,6 +535,16 @@ final class PluginTests { .package(name: "HelperPackage", path: "VendoredDependencies/HelperPackage") ], targets: [ + .template( + name: "GenerateStuff", + + templateInitializationOptions: .packageInit( + templateType: .executable, + executable: .target(name: "MyLibrary"), + description: "A template that generates a starter executable package" + ), + executable: .target(name: "MyLibrary"), + ), .target( name: "MyLibrary", dependencies: [ @@ -578,6 +588,16 @@ final class PluginTests { public func Foo() { } """ ) + + let templateSourceFile = packageDir.appending(components: "Sources", "GenerateStuff", "generatestuff.swift") + try localFileSystem.createDirectory(templateSourceFile.parentDirectory, recursive: true) + try localFileSystem.writeFileContents( + templateSourceFile, + string: """ + public func Foo() { } + """ + ) + let printingPluginSourceFile = packageDir.appending(components: "Plugins", "PluginPrintingInfo", "plugin.swift") try localFileSystem.createDirectory(printingPluginSourceFile.parentDirectory, recursive: true) try localFileSystem.writeFileContents( From cf9fb4ed2582d377db0fe34132b0ea9d0a892862 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:02:02 -0400 Subject: [PATCH 127/225] fixtures + cleanup --- .../ShowTemplates/app/Package.swift | 33 +++++ .../Plugins/GenerateStuffPlugin/plugin.swift | 29 +++++ .../Templates/GenerateStuff/Template.swift | 53 ++++++++ .../app/Templates/GenerateStuff/main.swift | 53 ++++++++ .../app/Templates/GenerateThings/main.swift | 0 .../PackageCommands/PackageTemplates.swift | 119 ------------------ .../PackageCommands/ShowTemplates.swift | 0 Sources/CoreCommands/SwiftCommandState.swift | 2 +- 8 files changed, 169 insertions(+), 120 deletions(-) create mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Package.swift create mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateStuffPlugin/plugin.swift create mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/Template.swift create mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift create mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift delete mode 100644 Sources/Commands/PackageCommands/PackageTemplates.swift create mode 100644 Sources/Commands/PackageCommands/ShowTemplates.swift diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift new file mode 100644 index 00000000000..bddabe3cf92 --- /dev/null +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version:6.1 +import PackageDescription + +let package = Package( + name: "Dealer", + products: [.template( + name: "GenerateStuff" + ),], + + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2") + + ], + targets: Target.template( + name: "GenerateStuff", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "SystemPackage", package: "swift-system") + + ], + templateInitializationOptions: .packageInit( + templateType: .executable, + templatePermissions: [ + .allowNetworkConnections(scope: .local(ports: [1200]), reason: "why not") + ], + description: "A template that generates a starter executable package" + ) + + ) + +) + diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateStuffPlugin/plugin.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateStuffPlugin/plugin.swift new file mode 100644 index 00000000000..8318ddd1ef0 --- /dev/null +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateStuffPlugin/plugin.swift @@ -0,0 +1,29 @@ +// +// plugin.swift +// app +// +// Created by John Bute on 2025-06-03. +// + +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable=≠≠ +@main + +struct TemplatePlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws { + let tool = try context.tool(named: "GenerateStuff") + let process = Process() + + process.executableURL = URL(filePath: tool.url.path()) + process.arguments = arguments.filter { $0 != "--" } + + try process.run() + process.waitUntilExit() + } +} diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/Template.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/Template.swift new file mode 100644 index 00000000000..24d9af341c4 --- /dev/null +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/Template.swift @@ -0,0 +1,53 @@ +import ArgumentParser +import Foundation +import SystemPackage + +extension FilePath { + static func / (left: FilePath, right: String) -> FilePath { + left.appending(right) + } +} + +extension String { + func write(toFile: FilePath) throws { + try self.write(toFile: toFile.string, atomically: true, encoding: .utf8) + } +} + +//basic structure of a template that uses string interpolation +@main +struct HelloTemplateTool: ParsableCommand { + + //swift argument parser needed to expose arguments to template generator + @Option(help: "The name of your app") + var name: String + + @Flag(help: "Include a README?") + var includeReadme: Bool = false + + //entrypoint of the template executable, that generates just a main.swift and a readme.md + func run() throws { + let fs = FileManager.default + + let rootDir = FilePath(fs.currentDirectoryPath) + + let mainFile = rootDir / "Soures" / name / "main.swift" + + try fs.createDirectory(atPath: mainFile.removingLastComponent().string, withIntermediateDirectories: true) + + try """ + // This is the entry point to your command-line app + print("Hello, \(name)!") + + """.write(toFile: mainFile) + + if includeReadme { + try """ + # \(name) + This is a new Swift app! + """.write(toFile: rootDir / "README.md") + } + + print("Project generated at \(rootDir)") + } +} diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift new file mode 100644 index 00000000000..24d9af341c4 --- /dev/null +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift @@ -0,0 +1,53 @@ +import ArgumentParser +import Foundation +import SystemPackage + +extension FilePath { + static func / (left: FilePath, right: String) -> FilePath { + left.appending(right) + } +} + +extension String { + func write(toFile: FilePath) throws { + try self.write(toFile: toFile.string, atomically: true, encoding: .utf8) + } +} + +//basic structure of a template that uses string interpolation +@main +struct HelloTemplateTool: ParsableCommand { + + //swift argument parser needed to expose arguments to template generator + @Option(help: "The name of your app") + var name: String + + @Flag(help: "Include a README?") + var includeReadme: Bool = false + + //entrypoint of the template executable, that generates just a main.swift and a readme.md + func run() throws { + let fs = FileManager.default + + let rootDir = FilePath(fs.currentDirectoryPath) + + let mainFile = rootDir / "Soures" / name / "main.swift" + + try fs.createDirectory(atPath: mainFile.removingLastComponent().string, withIntermediateDirectories: true) + + try """ + // This is the entry point to your command-line app + print("Hello, \(name)!") + + """.write(toFile: mainFile) + + if includeReadme { + try """ + # \(name) + This is a new Swift app! + """.write(toFile: rootDir / "README.md") + } + + print("Project generated at \(rootDir)") + } +} diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/Commands/PackageCommands/PackageTemplates.swift b/Sources/Commands/PackageCommands/PackageTemplates.swift deleted file mode 100644 index 423031a27c8..00000000000 --- a/Sources/Commands/PackageCommands/PackageTemplates.swift +++ /dev/null @@ -1,119 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift open source project -// -// Copyright (c) 2014-2022 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 - -@_spi(SwiftPMInternal) -import CoreCommands - -import PackageModel -import Workspace -import SPMBuildCore -import TSCUtility - - - -extension SwiftPackageCommand { - struct PackageTemplates: SwiftCommand { - public static let configuration = CommandConfiguration( - commandName: "template", - abstract: "Initialize a new package based on a template." - ) - - @OptionGroup(visibility: .hidden) - var globalOptions: GlobalOptions - - @Option( - name: .customLong("template-type"), - help: ArgumentHelp("Package type:", discussion: """ - - registry : Use a template from a package registry. - - git : Use a template from a Git repository. - - local : Use a template from a local directory. - """)) - var templateType: InitTemplatePackage.TemplateType - - @Option(name: .customLong("package-name"), help: "Provide the name for the new package.") - var packageName: String? - - //package-path provides the consumer's package - - @Option( - name: .customLong("template-path"), - help: "Specify the package path to operate on (default current directory). This changes the working directory before any other operation.", - completion: .directory - ) - public var templateDirectory: AbsolutePath - - //options for type git - @Option(help: "The exact package version to depend on.") - var exact: Version? - - @Option(help: "The specific package revision to depend on.") - var revision: String? - - @Option(help: "The branch of the package to depend on.") - var branch: String? - - @Option(help: "The package version to depend on (up to the next major version).") - var from: Version? - - @Option(help: "The package version to depend on (up to the next minor version).") - var upToNextMinorFrom: Version? - - @Option(help: "Specify upper bound on the package version range (exclusive).") - var to: Version? - - - func run(_ swiftCommandState: SwiftCommandState) throws { - - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") - } - - switch self.templateType { - case .local: - try self.generateFromLocalTemplate( - packagePath: templateDirectory, - swiftCommandState: swiftCommandState - ) - case .git: - try self.generateFromGitTemplate() - case .registry: - try self.generateFromRegistryTemplate() - } - - } - - private func generateFromLocalTemplate( - packagePath: AbsolutePath, - swiftCommandState: SwiftCommandState - ) throws { - - let template = InitTemplatePackage(initMode: templateType, packageName: packageName, templatePath: templateDirectory, fileSystem: swiftCommandState.fileSystem) - } - - - private func generateFromGitTemplate( - ) throws { - - } - - private func generateFromRegistryTemplate( - ) throws { - - } - } -} - - -extension InitTemplatePackage.TemplateType: ExpressibleByArgument {} diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Sources/CoreCommands/SwiftCommandState.swift b/Sources/CoreCommands/SwiftCommandState.swift index a723cf99912..2e57782bb25 100644 --- a/Sources/CoreCommands/SwiftCommandState.swift +++ b/Sources/CoreCommands/SwiftCommandState.swift @@ -204,7 +204,7 @@ public final class SwiftCommandState { public let options: GlobalOptions /// Path to the root package directory, nil if manifest is not found. - private let packageRoot: AbsolutePath? + private var packageRoot: AbsolutePath? /// Helper function to get package root or throw error if it is not found. public func getPackageRoot() throws -> AbsolutePath { From fce612d6eab7b221a57f8777925545d650861039 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:02:45 -0400 Subject: [PATCH 128/225] deleting unnecessary fixtures --- .../app/Templates/GenerateStuff/main.swift | 53 ------------------- .../app/Templates/GenerateThings/main.swift | 8 +++ 2 files changed, 8 insertions(+), 53 deletions(-) delete mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift deleted file mode 100644 index 24d9af341c4..00000000000 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/main.swift +++ /dev/null @@ -1,53 +0,0 @@ -import ArgumentParser -import Foundation -import SystemPackage - -extension FilePath { - static func / (left: FilePath, right: String) -> FilePath { - left.appending(right) - } -} - -extension String { - func write(toFile: FilePath) throws { - try self.write(toFile: toFile.string, atomically: true, encoding: .utf8) - } -} - -//basic structure of a template that uses string interpolation -@main -struct HelloTemplateTool: ParsableCommand { - - //swift argument parser needed to expose arguments to template generator - @Option(help: "The name of your app") - var name: String - - @Flag(help: "Include a README?") - var includeReadme: Bool = false - - //entrypoint of the template executable, that generates just a main.swift and a readme.md - func run() throws { - let fs = FileManager.default - - let rootDir = FilePath(fs.currentDirectoryPath) - - let mainFile = rootDir / "Soures" / name / "main.swift" - - try fs.createDirectory(atPath: mainFile.removingLastComponent().string, withIntermediateDirectories: true) - - try """ - // This is the entry point to your command-line app - print("Hello, \(name)!") - - """.write(toFile: mainFile) - - if includeReadme { - try """ - # \(name) - This is a new Swift app! - """.write(toFile: rootDir / "README.md") - } - - print("Project generated at \(rootDir)") - } -} diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift index e69de29bb2d..ba2e9c4f78d 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift @@ -0,0 +1,8 @@ +// +// main.swift +// app +// +// Created by John Bute on 2025-06-03. +// + +print("hello, world!") From 499d0cfcc1c9785527f3ef9acf18253be37eb54a Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:03:24 -0400 Subject: [PATCH 129/225] added new dependencies to handle addDependency functionality of templates --- Package.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index f39bb5399ff..d78f790b19a 100644 --- a/Package.swift +++ b/Package.swift @@ -553,7 +553,8 @@ let package = Package( "SourceControl", "SPMBuildCore", .product(name: "OrderedCollections", package: "swift-collections"), - ], + "PackageModelSyntax", + ] + swiftSyntaxDependencies(["SwiftIDEUtils", "SwiftParser"]), exclude: ["CMakeLists.txt"], swiftSettings: commonExperimentalFeatures + [ .unsafeFlags(["-static"]), From 9938b4a0a80f87336eb8f17d00aa662264105006 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:06:47 -0400 Subject: [PATCH 130/225] build plan for building products and targets of type templates, will need to revisit in future --- .../Build/BuildDescription/ProductBuildDescription.swift | 8 +++++--- .../BuildDescription/SwiftModuleBuildDescription.swift | 4 ++-- .../BuildManifest/LLBuildManifestBuilder+Clang.swift | 2 +- .../BuildManifest/LLBuildManifestBuilder+Product.swift | 7 ++++++- .../BuildManifest/LLBuildManifestBuilder+Swift.swift | 6 +++--- Sources/Build/BuildOperation.swift | 2 +- Sources/Build/BuildPlan/BuildPlan+Product.swift | 4 ++-- Sources/Build/BuildPlan/BuildPlan.swift | 5 +++-- 8 files changed, 23 insertions(+), 15 deletions(-) diff --git a/Sources/Build/BuildDescription/ProductBuildDescription.swift b/Sources/Build/BuildDescription/ProductBuildDescription.swift index 5c91a7c11d6..b9a6a09783a 100644 --- a/Sources/Build/BuildDescription/ProductBuildDescription.swift +++ b/Sources/Build/BuildDescription/ProductBuildDescription.swift @@ -222,7 +222,7 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription args += ["-Xlinker", "-install_name", "-Xlinker", relativePath] } args += self.deadStripArguments - case .executable, .snippet: + case .executable, .snippet, .template: //john-to-revisit // Link the Swift stdlib statically, if requested. // TODO: unify this logic with SwiftTargetBuildDescription.stdlibArguments if self.buildParameters.linkingParameters.shouldLinkStaticSwiftStdlib { @@ -245,7 +245,7 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription // Support for linking tests against executables is conditional on the tools // version of the package that defines the executable product. let executableTarget = try product.executableModule - if let target = executableTarget.underlying as? SwiftModule, + if let target = executableTarget.underlying as? SwiftModule, self.toolsVersion >= .v5_5, self.buildParameters.driverParameters.canRenameEntrypointFunctionName, target.supportsTestableExecutablesFeature @@ -256,6 +256,8 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription } case .plugin: throw InternalError("unexpectedly asked to generate linker arguments for a plugin product") + /*case .template: //john-to-do revist + throw InternalError("unexpectedly asked to generate linker arguments for a template product")*/ } if let resourcesPath = self.buildParameters.toolchain.swiftResourcesPath(isStatic: isLinkingStaticStdlib) { @@ -312,7 +314,7 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription switch self.product.type { case .library(let type): useStdlibRpath = type == .dynamic - case .test, .executable, .snippet, .macro: + case .test, .executable, .snippet, .macro, .template: //john-to-revisit useStdlibRpath = true case .plugin: throw InternalError("unexpectedly asked to generate linker arguments for a plugin product") diff --git a/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift b/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift index b6b6c20fffa..667d6e2bfda 100644 --- a/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift +++ b/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift @@ -146,7 +146,7 @@ public final class SwiftModuleBuildDescription { // If we're an executable and we're not allowing test targets to link against us, we hide the module. let triple = buildParameters.triple let allowLinkingAgainstExecutables = [.coff, .macho, .elf].contains(triple.objectFormat) && self.toolsVersion >= .v5_5 - let dirPath = (target.type == .executable && !allowLinkingAgainstExecutables) ? self.tempsPath : self.modulesPath + let dirPath = ((target.type == .executable || target.type == .template) && !allowLinkingAgainstExecutables) ? self.tempsPath : self.modulesPath //john-to-revisit return dirPath.appending(component: "\(self.target.c99name).swiftmodule") } @@ -205,7 +205,7 @@ public final class SwiftModuleBuildDescription { switch self.target.type { case .library, .test: return true - case .executable, .snippet, .macro: + case .executable, .snippet, .macro, .template: //john-to-revisit // This deactivates heuristics in the Swift compiler that treats single-file modules and source files // named "main.swift" specially w.r.t. whether they can have an entry point. // diff --git a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift index 34476408fff..845fad6cebd 100644 --- a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift +++ b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift @@ -46,7 +46,7 @@ extension LLBuildManifestBuilder { case .product(let product, let productDescription): switch product.type { - case .executable, .snippet, .library(.dynamic), .macro: + case .executable, .snippet, .library(.dynamic), .macro, .template: //john-to-revisit guard let productDescription else { throw InternalError("No build description for product: \(product)") } diff --git a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift index 33eb6f4558f..61ff45102c9 100644 --- a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift +++ b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift @@ -69,6 +69,11 @@ extension LLBuildManifestBuilder { buildProduct.buildParameters.debuggingParameters.shouldEnableDebuggingEntitlement { shouldCodeSign = true linkedBinaryNode = try .file(buildProduct.binaryPath, isMutated: true) + } else if case .template = buildProduct.product.type, //john-to-revisit + buildProduct.buildParameters.triple.isMacOSX, + buildProduct.buildParameters.debuggingParameters.shouldEnableDebuggingEntitlement { + shouldCodeSign = true + linkedBinaryNode = try .file(buildProduct.binaryPath, isMutated: true) } else { shouldCodeSign = false linkedBinaryNode = try .file(buildProduct.binaryPath) @@ -199,7 +204,7 @@ extension ResolvedProduct { return staticLibraryName(for: self.name, buildParameters: buildParameters) case .library(.automatic): throw InternalError("automatic library not supported") - case .executable, .snippet: + case .executable, .snippet, .template: //john-to-revisit return executableName(for: self.name, buildParameters: buildParameters) case .macro: guard let macroModule = self.modules.first else { diff --git a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift index 0616405e89b..c8b63148616 100644 --- a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift +++ b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift @@ -228,12 +228,12 @@ extension LLBuildManifestBuilder { } // Depend on the binary for executable targets. - if module.type == .executable && prepareForIndexing == .off { + if (module.type == .executable || module.type == .template) && prepareForIndexing == .off {//john-to-revisit // FIXME: Optimize. Build plan could build a mapping between executable modules // and their products to speed up search here, which is inefficient if the plan // contains a lot of products. if let productDescription = try plan.productMap.values.first(where: { - try $0.product.type == .executable && + try ($0.product.type == .executable || $0.product.type == .template) && //john-to-revisit $0.product.executableModule.id == module.id && $0.destination == description.destination }) { @@ -267,7 +267,7 @@ extension LLBuildManifestBuilder { case .product(let product, let productDescription): switch product.type { - case .executable, .snippet, .library(.dynamic), .macro: + case .executable, .snippet, .library(.dynamic), .macro, .template: //john-to-revisit guard let productDescription else { throw InternalError("No description for product: \(product)") } diff --git a/Sources/Build/BuildOperation.swift b/Sources/Build/BuildOperation.swift index ee3b48adfb6..d901f14e72d 100644 --- a/Sources/Build/BuildOperation.swift +++ b/Sources/Build/BuildOperation.swift @@ -903,7 +903,7 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS // Look for a target with the same module name as the one that's being imported. if let importedTarget = self._buildPlan?.targets.first(where: { $0.module.c99name == importedModule }) { // For the moment we just check for executables that other targets try to import. - if importedTarget.module.type == .executable { + if importedTarget.module.type == .executable || importedTarget.module.type == .template { //john-to-revisit return "module '\(importedModule)' is the main module of an executable, and cannot be imported by tests and other targets" } if importedTarget.module.type == .macro { diff --git a/Sources/Build/BuildPlan/BuildPlan+Product.swift b/Sources/Build/BuildPlan/BuildPlan+Product.swift index f7b1bb41231..7e3a6800356 100644 --- a/Sources/Build/BuildPlan/BuildPlan+Product.swift +++ b/Sources/Build/BuildPlan/BuildPlan+Product.swift @@ -174,7 +174,7 @@ extension BuildPlan { product: $0.product, context: $0.destination ) } - case .test, .executable, .snippet, .macro: + case .test, .executable, .snippet, .macro, .template: //john-to-revisit return [] } } @@ -249,7 +249,7 @@ extension BuildPlan { // In tool version .v5_5 or greater, we also include executable modules implemented in Swift in // any test products... this is to allow testing of executables. Note that they are also still // built as separate products that the test can invoke as subprocesses. - case .executable, .snippet, .macro: + case .executable, .snippet, .macro, .template: //john-to-revisit if product.modules.contains(id: module.id) { guard let description else { throw InternalError("Could not find a description for module: \(module)") diff --git a/Sources/Build/BuildPlan/BuildPlan.swift b/Sources/Build/BuildPlan/BuildPlan.swift index 3b2830268cc..4e45792dae7 100644 --- a/Sources/Build/BuildPlan/BuildPlan.swift +++ b/Sources/Build/BuildPlan/BuildPlan.swift @@ -175,6 +175,7 @@ public class BuildPlan: SPMBuildCore.BuildPlan { /// as targets, but they are not directly included in the build graph. public let pluginDescriptions: [PluginBuildDescription] + /// The build targets. public var targets: AnySequence { AnySequence(self.targetMap.values) @@ -387,7 +388,7 @@ public class BuildPlan: SPMBuildCore.BuildPlan { try module.dependencies.compactMap { switch $0 { case .module(let moduleDependency, _): - if moduleDependency.type == .executable { + if moduleDependency.type == .executable || moduleDependency.type == .template { return graph.product(for: moduleDependency.name) } return nil @@ -1386,6 +1387,6 @@ extension ResolvedProduct { // We shouldn't create product descriptions for automatic libraries, plugins or products which consist solely of // binary targets, because they don't produce any output. fileprivate var shouldCreateProductDescription: Bool { - !self.isAutomaticLibrary && !self.isBinaryOnly && !self.isPlugin + !self.isAutomaticLibrary && !self.isBinaryOnly && !self.isPlugin //john-to-revisit to see if include templates } } From 5a815c7486c0b1d75c3e0d147adc3a1c5ddeed5a Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:10:41 -0400 Subject: [PATCH 131/225] fulfilling cases for addproduct, will need to revisit to make sure adding product of type template works --- Sources/Commands/PackageCommands/AddProduct.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Commands/PackageCommands/AddProduct.swift b/Sources/Commands/PackageCommands/AddProduct.swift index 288e691797b..faa4c43a0c2 100644 --- a/Sources/Commands/PackageCommands/AddProduct.swift +++ b/Sources/Commands/PackageCommands/AddProduct.swift @@ -32,6 +32,7 @@ extension SwiftPackageCommand { case staticLibrary = "static-library" case dynamicLibrary = "dynamic-library" case plugin + case template } package static let configuration = CommandConfiguration( @@ -85,6 +86,7 @@ extension SwiftPackageCommand { case .dynamicLibrary: .library(.dynamic) case .staticLibrary: .library(.static) case .plugin: .plugin + case .template: .template } let product = ProductDescription( From 36dfdfd57633ab730f8060b6edfec909a1d48452 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:11:18 -0400 Subject: [PATCH 132/225] fulfilling cases for addtarget, will need to revisit to make sure adding target of type template works --- Sources/Commands/PackageCommands/AddTarget.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/Commands/PackageCommands/AddTarget.swift b/Sources/Commands/PackageCommands/AddTarget.swift index 089f148675f..b7f92cfd1f8 100644 --- a/Sources/Commands/PackageCommands/AddTarget.swift +++ b/Sources/Commands/PackageCommands/AddTarget.swift @@ -39,6 +39,7 @@ extension SwiftPackageCommand { case executable case test case macro + case template } package static let configuration = CommandConfiguration( @@ -114,6 +115,8 @@ extension SwiftPackageCommand { case .executable: .executable case .test: .test case .macro: .macro + default: + throw StringError("unexpected target type: \(self.type)") } // Map dependencies From ac9421764a55142ee1c2e00e088b4003c994fddf Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:15:53 -0400 Subject: [PATCH 133/225] show templates in a local package --- .../PackageCommands/ShowTemplates.swift | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index e69de29bb2d..e72298b62ac 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -0,0 +1,90 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation +import PackageModel +import PackageGraph +import Workspace + +struct ShowTemplates: AsyncSwiftCommand { + static let configuration = CommandConfiguration( + abstract: "List the available executables from this package.") + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + @Option(help: "Set the output format.") + var format: ShowTemplatesMode = .flatlist + + func run(_ swiftCommandState: SwiftCommandState) async throws { + let packageGraph = try await swiftCommandState.loadPackageGraph() + let rootPackages = packageGraph.rootPackages.map { $0.identity } + + let templates = packageGraph.allModules.filter({ + $0.type == .template || $0.type == .snippet + }).map { module -> Template in + if !rootPackages.contains(module.packageIdentity) { + return Template(package: module.packageIdentity.description, name: module.name) + } else { + return Template(package: Optional.none, name: module.name) + } + } + + switch self.format { + case .flatlist: + for template in templates.sorted(by: {$0.name < $1.name }) { + if let package = template.package { + print("\(template.name) (\(package))") + } else { + print(template.name) + } + } + + case .json: + let encoder = JSONEncoder() + let data = try encoder.encode(templates) + if let output = String(data: data, encoding: .utf8) { + print(output) + } + } + } + + struct Template: Codable { + var package: String? + var name: String + } + + enum ShowTemplatesMode: String, RawRepresentable, CustomStringConvertible, ExpressibleByArgument, CaseIterable { + case flatlist, json + + public init?(rawValue: String) { + switch rawValue.lowercased() { + case "flatlist": + self = .flatlist + case "json": + self = .json + default: + return nil + } + } + + public var description: String { + switch self { + case .flatlist: return "flatlist" + case .json: return "json" + } + } + } +} From 9e4c4c9f9a37703beaad17010b563b998deea84f Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:16:37 -0400 Subject: [PATCH 134/225] fullfilling switch case for snippets --- Sources/Commands/Snippets/Cards/TopCard.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Commands/Snippets/Cards/TopCard.swift b/Sources/Commands/Snippets/Cards/TopCard.swift index 614a20b6080..00363fbdb92 100644 --- a/Sources/Commands/Snippets/Cards/TopCard.swift +++ b/Sources/Commands/Snippets/Cards/TopCard.swift @@ -182,6 +182,8 @@ fileprivate extension Module.Kind { return "snippets" case .macro: return "macros" + case .template: + return "templates" } } } From b5b9d11a3394de4072971e8a2db49035f468293d Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:30:33 -0400 Subject: [PATCH 135/225] registering the show-templates command and adding more to init --- Sources/Commands/PackageCommands/Init.swift | 238 ++++++++++++++++-- .../PackageCommands/SwiftPackageCommand.swift | 1 + 2 files changed, 214 insertions(+), 25 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index c5d1336a93d..6de836f5794 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -19,19 +19,27 @@ import CoreCommands import PackageModel import Workspace import SPMBuildCore +import TSCUtility + +import Foundation +import PackageGraph +import SPMBuildCore +import XCBuildSupport +import TSCBasic + extension SwiftPackageCommand { - struct Init: SwiftCommand { + struct Init: AsyncSwiftCommand { public static let configuration = CommandConfiguration( abstract: "Initialize a new package.", - subcommands: [ - PackageTemplates.self - ] ) @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions - + + @OptionGroup(visibility: .hidden) + var buildOptions: BuildCommandOptions + @Option( name: .customLong("type"), help: ArgumentHelp("Package type:", discussion: """ @@ -57,7 +65,47 @@ extension SwiftPackageCommand { // This command should support creating the supplied --package-path if it isn't created. var createPackagePath = true - func run(_ swiftCommandState: SwiftCommandState) throws { + @Option(name: .customLong("template"), help: "Name of a template to initialize the package.") + var template: String = "" + var useTemplates: Bool { !template.isEmpty } + + @Option(name: .customLong("template-type"), help: "Template type: registry, git, local.") + var templateType: InitTemplatePackage.TemplateType? + + @Option(name: .customLong("template-path"), help: "Path to the local template.", completion: .directory) + var templateDirectory: Basics.AbsolutePath? + + // Git-specific options + @Option(help: "The exact package version to depend on.") + var exact: Version? + + @Option(help: "The specific package revision to depend on.") + var revision: String? + + @Option(help: "The branch of the package to depend on.") + var branch: String? + + @Option(help: "The package version to depend on (up to the next major version).") + var from: Version? + + @Option(help: "The package version to depend on (up to the next minor version).") + var upToNextMinorFrom: Version? + + @Option(help: "Specify upper bound on the package version range (exclusive).") + var to: Version? + + //swift package init --template woof --template-type local --template-path path here + + //first check the path and see if the template woof is actually there + //if yes, build and get the templateInitializationOptions from it + // read templateInitializationOptions and parse permissions + type of package to initialize + // once read, initialize barebones package with what is needed, and add dependency to local template product + // swift build, then call --experimental-dump-help on the product + // prompt user + // run the executable with the command line stuff + + //first, + func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") } @@ -67,30 +115,170 @@ extension SwiftPackageCommand { // Testing is on by default, with XCTest only enabled explicitly. // For macros this is reversed, since we don't support testing // macros with Swift Testing yet. - var supportedTestingLibraries = Set() - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.xctest) - } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.swiftTesting) + if useTemplates { + guard let type = templateType else { + throw InternalError("Template path must be specified when using the local template type.") + } + + switch type { + case .local: + + guard let templatePath = templateDirectory else { + throw InternalError("Template path must be specified when using the local template type.") + } + + /// Get the package initialization type based on templateInitializationOptions and check for if the template called is valid. + let templateInitType = try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { workspace, root in + return try await checkConditions(swiftCommandState) + } + + var supportedTemplateTestingLibraries = Set() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (templateInitType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTemplateTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (templateInitType != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTemplateTestingLibraries.insert(.swiftTesting) + } + + let initTemplatePackage = try InitTemplatePackage( + name: packageName, + templateName: template, + initMode: type, + templatePath: templatePath, + fileSystem: swiftCommandState.fileSystem, + packageType: templateInitType, + supportedTestingLibraries: supportedTemplateTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, + + ) + try initTemplatePackage.setupTemplateManifest() + + let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { workspace, root in + + try await swiftCommandState.createBuildSystem( + explicitProduct: buildOptions.product, + traitConfiguration: .init(traitOptions: buildOptions.traits), + shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: swiftCommandState.toolsBuildParameters, + // command result output goes on stdout + // ie "swift build" should output to stdout + outputStream: TSCBasic.stdoutStream + ) + + } + + guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { + throw ExitCode.failure + } + + let _ = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { workspace, root in + do { + try await buildSystem.build(subset: subset) + } catch _ as Diagnostics { + throw ExitCode.failure + } + } + + + case .git: + // Implement or call your Git-based template handler + print("TODO: Handle Git template") + case .registry: + // Implement or call your Registry-based template handler + print("TODO: Handle Registry template") + } + } else { + + var supportedTestingLibraries = Set() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.swiftTesting) + } + + + let initPackage = try InitPackage( + name: packageName, + packageType: initMode, + supportedTestingLibraries: supportedTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, + fileSystem: swiftCommandState.fileSystem + ) + + initPackage.progressReporter = { message in + print(message) + } + + try initPackage.writePackageStructure() } + } + + // first save current activeWorkspace + //second switch activeWorkspace to the template Path + //third revert after conditions have been checked, (we will also get stuff needed for dpeende + private func checkConditions(_ swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType{ + + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() - let initPackage = try InitPackage( - name: packageName, - packageType: initMode, - supportedTestingLibraries: supportedTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, - fileSystem: swiftCommandState.fileSystem + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope ) - initPackage.progressReporter = { message in - print(message) + guard let rootManifest = rootManifests.values.first else { + throw InternalError("invalid manifests at \(root.packages)") + } + + let products = rootManifest.products + let targets = rootManifest.targets + + for product in products { + for targetName in product.targets { + if let target = targets.first(where: { _ in template == targetName }) { + if target.type == .template { + if let options = target.templateInitializationOptions { + + if case let .packageInit(templateType, _, _) = options { + return try .init(from: templateType) + } + } + } + } + } } - try initPackage.writePackageStructure() + throw InternalError("Could not find template \(template)") } } } -extension InitPackage.PackageType: ExpressibleByArgument {} +extension InitPackage.PackageType: ExpressibleByArgument { + init(from templateType: TargetDescription.TemplateType) throws { + switch templateType { + case .executable: + self = .executable + case .library: + self = .library + case .tool: + self = .tool + case .macro: + self = .macro + case .buildToolPlugin: + self = .buildToolPlugin + case .commandPlugin: + self = .commandPlugin + case .empty: + self = .empty + } + } + +} + +extension InitTemplatePackage.TemplateType: ExpressibleByArgument {} diff --git a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift index da253850404..6aed2971cc8 100644 --- a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift +++ b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift @@ -66,6 +66,7 @@ public struct SwiftPackageCommand: AsyncParsableCommand { ShowDependencies.self, ShowExecutables.self, + ShowTemplates.self, ToolsVersionCommand.self, ComputeChecksum.self, ArchiveSource.self, From e7467bc876e3cf198e919d1f78f2dd9c7ec7de59 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:30:57 -0400 Subject: [PATCH 136/225] removing testing library options, to revisit and discuss --- Sources/Commands/SwiftBuildCommand.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Commands/SwiftBuildCommand.swift b/Sources/Commands/SwiftBuildCommand.swift index 5c366090224..65a5288380d 100644 --- a/Sources/Commands/SwiftBuildCommand.swift +++ b/Sources/Commands/SwiftBuildCommand.swift @@ -103,13 +103,14 @@ struct BuildCommandOptions: ParsableArguments { @Option(help: "Build the specified product.") var product: String? + /* /// Testing library options. /// /// These options are no longer used but are needed by older versions of the /// Swift VSCode plugin. They will be removed in a future update. @OptionGroup(visibility: .private) var testLibraryOptions: TestLibraryOptions - + */ /// If should link the Swift stdlib statically. @Flag(name: .customLong("static-swift-stdlib"), inversion: .prefixedNo, help: "Link Swift stdlib statically.") public var shouldLinkStaticSwiftStdlib: Bool = false From 5377383d2fe49168a0976930088843c1b8344c9c Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:34:35 -0400 Subject: [PATCH 137/225] added context switcher for swiftcommandstate --- Sources/CoreCommands/SwiftCommandState.swift | 66 +++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/Sources/CoreCommands/SwiftCommandState.swift b/Sources/CoreCommands/SwiftCommandState.swift index 2e57782bb25..2b4ca2b66ae 100644 --- a/Sources/CoreCommands/SwiftCommandState.swift +++ b/Sources/CoreCommands/SwiftCommandState.swift @@ -229,7 +229,7 @@ public final class SwiftCommandState { } /// Scratch space (.build) directory. - public let scratchDirectory: AbsolutePath + public var scratchDirectory: AbsolutePath /// Path to the shared security directory public let sharedSecurityDirectory: AbsolutePath @@ -1313,3 +1313,67 @@ extension Basics.Diagnostic { } } +extension SwiftCommandState { + + public func withTemporaryWorkspace( + switchingTo packagePath: AbsolutePath, + createPackagePath: Bool = true, + perform: @escaping (Workspace, PackageGraphRootInput) async throws -> R + ) async throws -> R { + let originalWorkspace = self._workspace + let originalDelegate = self._workspaceDelegate + let originalWorkingDirectory = self.fileSystem.currentWorkingDirectory + let originalLock = self.workspaceLock + let originalLockState = self.workspaceLockState + + // Switch to temp directory + try Self.chdirIfNeeded(packageDirectory: packagePath, createPackagePath: createPackagePath) + + // Reset for new context + self._workspace = nil + self._workspaceDelegate = nil + self.workspaceLock = nil + self.workspaceLockState = .needsLocking + + defer { + if self.workspaceLockState == .locked { + self.releaseLockIfNeeded() + } + + // Restore lock state + self.workspaceLock = originalLock + self.workspaceLockState = originalLockState + + // Restore other context + if let cwd = originalWorkingDirectory { + try? Self.chdirIfNeeded(packageDirectory: cwd, createPackagePath: false) + do { + self.scratchDirectory = try BuildSystemUtilities.getEnvBuildPath(workingDir: cwd) + ?? (packageRoot ?? cwd).appending(component: ".build") + } catch { + self.scratchDirectory = (packageRoot ?? cwd).appending(component: ".build") + } + + } + + self._workspace = originalWorkspace + self._workspaceDelegate = originalDelegate + } + + // Set up new context + self.packageRoot = findPackageRoot(fileSystem: self.fileSystem) + + + if let cwd = self.fileSystem.currentWorkingDirectory { + self.scratchDirectory = try BuildSystemUtilities.getEnvBuildPath(workingDir: cwd) ?? (packageRoot ?? cwd).appending(".build") + + } + + + let tempWorkspace = try self.getActiveWorkspace() + let tempRoot = try self.getWorkspaceRoot() + + return try await perform(tempWorkspace, tempRoot) + } +} + From 6fd8d61c0bf4755c1d5fd28b5920d6ed2d28cae4 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 4 Jun 2025 16:42:57 -0400 Subject: [PATCH 138/225] make templates recognizable by package.swift --- .../PackageDescriptionSerialization.swift | 15 ++- ...geDescriptionSerializationConversion.swift | 23 +++- Sources/PackageDescription/Product.swift | 27 +++- Sources/PackageDescription/Target.swift | 115 ++++++++++++++---- .../Manifest/TargetDescription.swift | 35 +++--- Sources/PackageModel/Product.swift | 16 ++- 6 files changed, 170 insertions(+), 61 deletions(-) diff --git a/Sources/PackageDescription/PackageDescriptionSerialization.swift b/Sources/PackageDescription/PackageDescriptionSerialization.swift index fc18ebaef8b..43da07afe3b 100644 --- a/Sources/PackageDescription/PackageDescriptionSerialization.swift +++ b/Sources/PackageDescription/PackageDescriptionSerialization.swift @@ -212,19 +212,17 @@ enum Serialization { } enum TemplateInitializationOptions: Codable { - case packageInit(templateType: TemplateType, executable: TargetDependency, templatePermissions: [TemplatePermissions]?, description: String) + case packageInit(templateType: TemplateType, templatePermissions: [TemplatePermissions]?, description: String) } enum TemplateType: Codable { - /// A target that contains code for the Swift package's functionality. - case regular - /// A target that contains code for an executable's main module. + case library case executable - /// A target that contains tests for the Swift package's other targets. - case test - /// A target that adapts a library on the system to work with Swift - /// packages. + case tool + case buildToolPlugin + case commandPlugin case `macro` + case empty } enum TemplateNetworkPermissionScope: Codable { @@ -288,6 +286,7 @@ enum Serialization { case executable case library(type: LibraryType) case plugin + case template } let name: String diff --git a/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift b/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift index b8d9219c98a..f59e8d380ba 100644 --- a/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift +++ b/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift @@ -300,18 +300,21 @@ extension Serialization.TemplateInitializationOptions { init(_ usage: PackageDescription.Target.TemplateInitializationOptions) { switch usage { - case .packageInit(let templateType, let executable, let templatePermissions, let description): - self = .packageInit(templateType: .init(templateType), executable: .init(executable), templatePermissions: templatePermissions?.map { .init($0) }, description: description) + case .packageInit(let templateType, let templatePermissions, let description): + self = .packageInit(templateType: .init(templateType), templatePermissions: templatePermissions?.map { .init($0) }, description: description) } } } extension Serialization.TemplateType { init(_ type: PackageDescription.Target.TemplateType) { switch type { - case .regular: self = .regular case .executable: self = .executable case .macro: self = .macro - case .test: self = .test + case .library: self = .library + case .tool: self = .tool + case .buildToolPlugin: self = .buildToolPlugin + case .commandPlugin: self = .commandPlugin + case .empty: self = .empty } } } @@ -398,6 +401,8 @@ extension Serialization.Product { self.init(library) } else if let plugin = product as? PackageDescription.Product.Plugin { self.init(plugin) + } else if let template = product as? PackageDescription.Product.Template { + self.init(template) } else { fatalError("should not be reached") } @@ -430,6 +435,16 @@ extension Serialization.Product { self.settings = [] #endif } + + init(_ template: PackageDescription.Product.Template) { + self.name = template.name + self.targets = template.targets + self.productType = .template + #if ENABLE_APPLE_PRODUCT_TYPES + self.settings = [] + #endif + } + } extension Serialization.Trait { diff --git a/Sources/PackageDescription/Product.swift b/Sources/PackageDescription/Product.swift index e3b6a7352c4..1e21fce6396 100644 --- a/Sources/PackageDescription/Product.swift +++ b/Sources/PackageDescription/Product.swift @@ -120,6 +120,15 @@ public class Product { } } + public final class Template: Product, @unchecked Sendable { + public let targets: [String] + + init(name: String, targets: [String]) { + self.targets = [name] + super.init(name: name) + } + } + /// Creates a library product to allow clients that declare a dependency on /// this package to use the package's functionality. /// @@ -169,11 +178,12 @@ public class Product { return Executable(name: name, targets: targets, settings: settings) } - /// Defines a product that vends a package plugin target for use by clients of the package. - /// - /// It is not necessary to define a product for a plugin that + //john-to-revisit documentation + /// Defines a template that vends a template plugin target and a template executable target for use by clients of the package. + /// + /// It is not necessary to define a product for a template that /// is only used within the same package where you define it. All the targets - /// listed must be plugin targets in the same package as the product. Swift Package Manager + /// listed must be template targets in the same package as the product. Swift Package Manager /// will apply them to any client targets of the product in the order /// they are listed. /// - Parameters: @@ -187,6 +197,15 @@ public class Product { ) -> Product { return Plugin(name: name, targets: targets) } + + @available(_PackageDescription, introduced: 6.0) + public static func template( + name: String, + ) -> Product { + let templatePluginName = "\(name)Plugin" + let executableTemplateName = name + return Product.plugin(name: templatePluginName, targets: [templatePluginName]) + } } diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index 0ef4f126847..76fdbb62308 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -234,20 +234,18 @@ public final class Target { public var templateInitializationOptions: TemplateInitializationOptions? public enum TemplateType: String { - /// A target that contains code for the Swift package's functionality. - case regular - /// A target that contains code for an executable's main module. case executable - /// A target that contains tests for the Swift package's other targets. - case test - /// A target that adapts a library on the system to work with Swift - /// packages. case `macro` + case library + case tool + case buildToolPlugin + case commandPlugin + case empty } @available(_PackageDescription, introduced: 5.9) public enum TemplateInitializationOptions { - case packageInit(templateType: TemplateType, executable: Dependency, templatePermissions: [TemplatePermissions]? = nil, description: String) + case packageInit(templateType: TemplateType, templatePermissions: [TemplatePermissions]? = nil, description: String) } /// Construct a target. @@ -293,6 +291,7 @@ public final class Target { self.linkerSettings = linkerSettings self.checksum = checksum self.plugins = plugins + self.templateInitializationOptions = templateInitializationOptions switch type { case .regular, .executable, .test: @@ -616,7 +615,8 @@ public final class Target { cxxSettings: [CXXSetting]? = nil, swiftSettings: [SwiftSetting]? = nil, linkerSettings: [LinkerSetting]? = nil, - plugins: [PluginUsage]? = nil + plugins: [PluginUsage]? = nil, + templateInitializationOptions: TemplateInitializationOptions? = nil ) -> Target { return Target( name: name, @@ -632,7 +632,8 @@ public final class Target { cxxSettings: cxxSettings, swiftSettings: swiftSettings, linkerSettings: linkerSettings, - plugins: plugins + plugins: plugins, + templateInitializationOptions: templateInitializationOptions ) } @@ -1270,24 +1271,90 @@ public final class Target { pluginCapability: capability) } + //john-to-revisit documentation @available(_PackageDescription, introduced: 6.0) public static func template( name: String, - templateInitializationOptions: TemplateInitializationOptions, + dependencies: [Dependency] = [], + path: String? = nil, exclude: [String] = [], - executable: Dependency - ) -> Target { - return Target( - name: name, - dependencies: [], - path: nil, - exclude: exclude, - sources: nil, - publicHeadersPath: nil, - type: .template, - packageAccess: false, - templateInitializationOptions: templateInitializationOptions - ) + sources: [String]? = nil, + resources: [Resource]? = nil, + publicHeadersPath: String? = nil, + packageAccess: Bool = true, + cSettings: [CSetting]? = nil, + cxxSettings: [CXXSetting]? = nil, + swiftSettings: [SwiftSetting]? = nil, + linkerSettings: [LinkerSetting]? = nil, + plugins: [PluginUsage]? = nil, + templateInitializationOptions: TemplateInitializationOptions, + ) -> [Target] { + + let templatePluginName = "\(name)Plugin" + let templateExecutableName = name + + let (verb, description): (String, String) + switch templateInitializationOptions { + case .packageInit(_, _, let desc): + verb = "init-\(name.lowercased())" + description = desc + } + + let permissions: [PluginPermission] = { + switch templateInitializationOptions { + case .packageInit(_, let templatePermissions, _): + return templatePermissions?.compactMap { permission in + switch permission { + case .allowNetworkConnections(let scope, let reason): + // Map from TemplateNetworkPermissionScope to PluginNetworkPermissionScope + let pluginScope: PluginNetworkPermissionScope + switch scope { + case .none: + pluginScope = .none + case .local(let ports): + pluginScope = .local(ports: ports) + case .all(let ports): + pluginScope = .all(ports: ports) + case .docker: + pluginScope = .docker + case .unixDomainSocket: + pluginScope = .unixDomainSocket + } + return .allowNetworkConnections(scope: pluginScope, reason: reason) + } + } ?? [] + } + }() + + let templateTarget = Target( + name: templateExecutableName, + dependencies: dependencies, + path: path, + exclude: exclude, + sources: sources, + resources: resources, + publicHeadersPath: publicHeadersPath, + type: .template, + packageAccess: packageAccess, + cSettings: cSettings, + cxxSettings: cxxSettings, + swiftSettings: swiftSettings, + linkerSettings: linkerSettings, + plugins: plugins, + templateInitializationOptions: templateInitializationOptions + ) + + // Plugin target that depends on the template + let pluginTarget = plugin( + name: templatePluginName, + capability: .command( + intent: .custom(verb: verb, description: description), + permissions: permissions + ), + dependencies: [Target.Dependency.target(name: templateExecutableName, condition: nil)] + ) + + return [templateTarget, pluginTarget] } } diff --git a/Sources/PackageModel/Manifest/TargetDescription.swift b/Sources/PackageModel/Manifest/TargetDescription.swift index 6f5fa5068dc..04bbcfcc15e 100644 --- a/Sources/PackageModel/Manifest/TargetDescription.swift +++ b/Sources/PackageModel/Manifest/TargetDescription.swift @@ -198,19 +198,17 @@ public struct TargetDescription: Hashable, Encodable, Sendable { public let templateInitializationOptions: TemplateInitializationOptions? public enum TemplateInitializationOptions: Hashable, Sendable { - case packageInit(templateType: TemplateType, executable: Dependency, templatePermissions: [TemplatePermission]?, description: String) + case packageInit(templateType: TemplateType, templatePermissions: [TemplatePermission]?, description: String) } public enum TemplateType: String, Hashable, Codable, Sendable { - /// A target that contains code for the Swift package's functionality. - case regular - /// A target that contains code for an executable's main module. + case library case executable - /// A target that contains tests for the Swift package's other targets. - case test - /// A target that adapts a library on the system to work with Swift - /// packages. + case tool + case buildToolPlugin + case commandPlugin case `macro` + case empty } public enum TemplateNetworkPermissionScope: Hashable, Codable, Sendable { @@ -553,14 +551,13 @@ public struct TargetDescription: Hashable, Encodable, Sendable { targetType: targetType, propertyName: "checksum", value: checksum ?? "" - ) } - if templateInitializationOptions == nil { throw Error.disallowedPropertyInTarget( + ) } + if templateInitializationOptions == nil { throw Error.disallowedPropertyInTarget( //john-to-revisit targetName: name, targetType: targetType, propertyName: "templateInitializationOptions", - value: String(describing: templateInitializationOptions!) - ) - } + value: String(describing: templateInitializationOptions) + ) } } @@ -714,16 +711,15 @@ extension TargetDescription.TemplateInitializationOptions: Codable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { - case let .packageInit(a1, a2, a3, a4): + case let .packageInit(a1, a2, a3): var unkeyedContainer = container.nestedUnkeyedContainer(forKey: .packageInit) try unkeyedContainer.encode(a1) - try unkeyedContainer.encode(a2) - if let permissions = a3 { - try unkeyedContainer.encode(a3) + if let permissions = a2 { + try unkeyedContainer.encode(permissions) } else { try unkeyedContainer.encodeNil() } - try unkeyedContainer.encode(a4) + try unkeyedContainer.encode(a3) } } @@ -736,10 +732,9 @@ extension TargetDescription.TemplateInitializationOptions: Codable { case .packageInit: var unkeyedValues = try values.nestedUnkeyedContainer(forKey: key) let templateType = try unkeyedValues.decode(TargetDescription.TemplateType.self) - let executable = try unkeyedValues.decode(TargetDescription.Dependency.self) let templatePermissions = try unkeyedValues.decodeIfPresent([TargetDescription.TemplatePermission].self) let description = try unkeyedValues.decode(String.self) - self = .packageInit(templateType: templateType, executable: executable, templatePermissions: templatePermissions ?? nil, description: description) + self = .packageInit(templateType: templateType, templatePermissions: templatePermissions ?? nil, description: description) } } } diff --git a/Sources/PackageModel/Product.swift b/Sources/PackageModel/Product.swift index 4b5c79f696b..98033b7a97f 100644 --- a/Sources/PackageModel/Product.swift +++ b/Sources/PackageModel/Product.swift @@ -108,10 +108,18 @@ public enum ProductType: Equatable, Hashable, Sendable { /// A macro product. case `macro` + /// A template product + case template + public var isLibrary: Bool { guard case .library = self else { return false } return true } + + public var isTemplate: Bool { + guard case .template = self else {return false} + return true + } } @@ -203,6 +211,8 @@ extension ProductType: CustomStringConvertible { return "plugin" case .macro: return "macro" + case .template: + return "template" } } } @@ -222,7 +232,7 @@ extension ProductFilter: CustomStringConvertible { extension ProductType: Codable { private enum CodingKeys: String, CodingKey { - case library, executable, snippet, plugin, test, `macro` + case library, executable, snippet, plugin, test, `macro`, template } public func encode(to encoder: Encoder) throws { @@ -241,6 +251,8 @@ extension ProductType: Codable { try container.encodeNil(forKey: .test) case .macro: try container.encodeNil(forKey: .macro) + case .template: + try container.encodeNil(forKey: .template) } } @@ -264,6 +276,8 @@ extension ProductType: Codable { self = .plugin case .macro: self = .macro + case .template: + self = .template } } } From 95c01d7bd9791961ba1ff8a5b84e4b1321f0b0f9 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:28:53 -0400 Subject: [PATCH 139/225] dependency graph for swift targets and templates need to be revisited --- Sources/PackageGraph/ModulesGraph+Loading.swift | 2 +- Sources/PackageGraph/ModulesGraph.swift | 4 ++-- Sources/PackageGraph/Resolution/ResolvedProduct.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/PackageGraph/ModulesGraph+Loading.swift b/Sources/PackageGraph/ModulesGraph+Loading.swift index 2ea14951129..8df4144c067 100644 --- a/Sources/PackageGraph/ModulesGraph+Loading.swift +++ b/Sources/PackageGraph/ModulesGraph+Loading.swift @@ -272,7 +272,7 @@ private func checkAllDependenciesAreUsed( // We continue if the dependency contains executable products to make sure we don't // warn on a valid use-case for a lone dependency: swift run dependency executables. - guard !dependency.products.contains(where: { $0.type == .executable }) else { + guard !dependency.products.contains(where: { $0.type == .executable || $0.type == .template}) else { //john-to-revisit continue } // Skip this check if this dependency is a system module because system module packages diff --git a/Sources/PackageGraph/ModulesGraph.swift b/Sources/PackageGraph/ModulesGraph.swift index a5df0fb46e4..f632534874d 100644 --- a/Sources/PackageGraph/ModulesGraph.swift +++ b/Sources/PackageGraph/ModulesGraph.swift @@ -267,8 +267,8 @@ public struct ModulesGraph { }) return try Dictionary(throwingUniqueKeysWithValues: testModuleDeps) }() - - for module in rootModules where module.type == .executable { + + for module in rootModules where module.type == .executable || module.type == .template { //john-to-revisit // Find all dependencies of this module within its package. Note that we do not traverse plugin usages. let dependencies = try topologicalSortIdentifiable(module.dependencies, successors: { $0.dependencies.compactMap{ $0.module }.filter{ $0.type != .plugin }.map{ .module($0, conditions: []) } diff --git a/Sources/PackageGraph/Resolution/ResolvedProduct.swift b/Sources/PackageGraph/Resolution/ResolvedProduct.swift index 357cf515316..c0c86aa1c63 100644 --- a/Sources/PackageGraph/Resolution/ResolvedProduct.swift +++ b/Sources/PackageGraph/Resolution/ResolvedProduct.swift @@ -58,7 +58,7 @@ public struct ResolvedProduct { /// Note: This property is only valid for executable products. public var executableModule: ResolvedModule { get throws { - guard self.type == .executable || self.type == .snippet || self.type == .macro else { + guard self.type == .executable || self.type == .snippet || self.type == .macro || self.type == .template else { //john-to-revisit throw InternalError("`executableTarget` should only be called for executable targets") } guard let underlyingExecutableModule = modules.map(\.underlying).executables.first, From 47e61cb12a316236b0c60b1ea4abc38654084fb4 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:29:35 -0400 Subject: [PATCH 140/225] diagnostics added for template targets + products, will need to add tests for this too --- Sources/PackageLoading/Diagnostics.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/PackageLoading/Diagnostics.swift b/Sources/PackageLoading/Diagnostics.swift index eb3bbde4777..debc3030b52 100644 --- a/Sources/PackageLoading/Diagnostics.swift +++ b/Sources/PackageLoading/Diagnostics.swift @@ -38,7 +38,7 @@ extension Basics.Diagnostic { case .library(.automatic): typeString = "" case .executable, .snippet, .plugin, .test, .macro, - .library(.dynamic), .library(.static): + .library(.dynamic), .library(.static), .template: //john-to-revisit typeString = " (\(product.type))" } @@ -99,10 +99,21 @@ extension Basics.Diagnostic { .error("plugin product '\(product)' should have at least one plugin target") } + static func templateProductWithNoTargets(product: String) -> Self { + .error("template product '\(product)' should have at least one plugin target") + } + static func pluginProductWithNonPluginTargets(product: String, otherTargets: [String]) -> Self { .error("plugin product '\(product)' should have only plugin targets (it has \(otherTargets.map{ "'\($0)'" }.joined(separator: ", ")))") } + static func templateProductWithNonTemplateTargets(product: String, otherTargets: [String]) -> Self { + .error("template product `\(product)` should have only template targets (it has \(otherTargets.map{ "'\($0)'" }.joined(separator: ", ")))") + } + + static func templateProductWithMultipleTemplates(product: String) -> Self { + .error("template product `\(product)` should have only one template target") + } static var noLibraryTargetsForREPL: Self { .error("unable to synthesize a REPL product as there are no library targets in the package") } From 65b41d1c562e4527785564e880cc11db29f13a89 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:30:11 -0400 Subject: [PATCH 141/225] manifest loading and json parsing --- .../PackageLoading/ManifestJSONParser.swift | 20 ++++++++++++------- Sources/PackageLoading/ManifestLoader.swift | 1 - 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Sources/PackageLoading/ManifestJSONParser.swift b/Sources/PackageLoading/ManifestJSONParser.swift index bfa6352bc4c..9b489a70a82 100644 --- a/Sources/PackageLoading/ManifestJSONParser.swift +++ b/Sources/PackageLoading/ManifestJSONParser.swift @@ -499,7 +499,7 @@ extension ProductDescription { switch product.productType { case .executable: productType = .executable - case .plugin: + case .plugin, .template: productType = .plugin case .library(let type): productType = .library(.init(type)) @@ -638,8 +638,8 @@ extension TargetDescription.PluginUsage { extension TargetDescription.TemplateInitializationOptions { init (_ usage: Serialization.TemplateInitializationOptions, identityResolver: IdentityResolver) throws { switch usage { - case .packageInit(let templateType, let executable, let templatePermissions, let description): - self = .packageInit(templateType: .init(templateType), executable: try .init(executable, identityResolver: identityResolver), templatePermissions: templatePermissions?.map { .init($0) }, description: description) + case .packageInit(let templateType, let templatePermissions, let description): + self = .packageInit(templateType: .init(templateType), templatePermissions: templatePermissions?.map { .init($0) }, description: description) } } } @@ -647,14 +647,20 @@ extension TargetDescription.TemplateInitializationOptions { extension TargetDescription.TemplateType { init(_ type: Serialization.TemplateType) { switch type { - case .regular: - self = .regular + case .library: + self = .library case .executable: self = .executable - case .test: - self = .test + case .tool: + self = .tool + case .buildToolPlugin: + self = .buildToolPlugin + case .commandPlugin: + self = .commandPlugin case .macro: self = .macro + case .empty: + self = .empty } } } diff --git a/Sources/PackageLoading/ManifestLoader.swift b/Sources/PackageLoading/ManifestLoader.swift index aac61e0611f..00631fdf02c 100644 --- a/Sources/PackageLoading/ManifestLoader.swift +++ b/Sources/PackageLoading/ManifestLoader.swift @@ -947,7 +947,6 @@ public final class ManifestLoader: ManifestLoaderProtocol { // Read the JSON output that was emitted by libPackageDescription. let jsonOutput: String = try localFileSystem.readFileContents(jsonOutputFile) evaluationResult.manifestJSON = jsonOutput - print(jsonOutput) // withTemporaryDirectory handles cleanup automatically return evaluationResult } From f7248151fa09d1cc371211a41863a1d7cec660d4 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:30:39 -0400 Subject: [PATCH 142/225] templates directory check added, as template executables should not be in sources --- Sources/PackageLoading/PackageBuilder.swift | 78 +++++++++++++++++---- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/Sources/PackageLoading/PackageBuilder.swift b/Sources/PackageLoading/PackageBuilder.swift index 08535c82807..56f460f8f6c 100644 --- a/Sources/PackageLoading/PackageBuilder.swift +++ b/Sources/PackageLoading/PackageBuilder.swift @@ -113,7 +113,18 @@ extension ModuleError: CustomStringConvertible { let packages = packages.map(\.description).sorted().joined(separator: "', '") return "multiple packages ('\(packages)') declare targets with a conflicting name: '\(target)’; target names need to be unique across the package graph" case .moduleNotFound(let target, let type, let shouldSuggestRelaxedSourceDir): - let folderName = (type == .test) ? "Tests" : (type == .plugin) ? "Plugins" : "Sources" + var folderName = "" + switch type { + case .test: + folderName = "Tests" + case .plugin: + folderName = "Plugins" + case .template: + folderName = "Templates" + default: + folderName = "Sources" + } + var clauses = ["Source files for target \(target) should be located under '\(folderName)/\(target)'"] if shouldSuggestRelaxedSourceDir { clauses.append("'\(folderName)'") @@ -332,7 +343,8 @@ public final class PackageBuilder { public static let predefinedTestDirectories = ["Tests", "Sources", "Source", "src", "srcs"] /// Predefined plugin directories, in order of preference. public static let predefinedPluginDirectories = ["Plugins"] - + /// Predefinded template directories, in order of preference + public static let predefinedTemplateDirectories = ["Templates", "Template"] /// The identity for the package being constructed. private let identity: PackageIdentity @@ -573,7 +585,7 @@ public final class PackageBuilder { /// Finds the predefined directories for regular targets, test targets, and plugin targets. private func findPredefinedTargetDirectory() - -> (targetDir: String, testTargetDir: String, pluginTargetDir: String) + -> (targetDir: String, testTargetDir: String, pluginTargetDir: String, templateTargetDir: String) { let targetDir = PackageBuilder.predefinedSourceDirectories.first(where: { self.fileSystem.isDirectory(self.packagePath.appending(component: $0)) @@ -587,7 +599,11 @@ public final class PackageBuilder { self.fileSystem.isDirectory(self.packagePath.appending(component: $0)) }) ?? PackageBuilder.predefinedPluginDirectories[0] - return (targetDir, testTargetDir, pluginTargetDir) + let templateTargetDir = PackageBuilder.predefinedTemplateDirectories.first(where: { + self.fileSystem.isDirectory(self.packagePath.appending(component: $0)) + }) ?? PackageBuilder.predefinedTemplateDirectories[0] + + return (targetDir, testTargetDir, pluginTargetDir, templateTargetDir) } /// Construct targets according to PackageDescription 4 conventions. @@ -608,6 +624,10 @@ public final class PackageBuilder { path: packagePath.appending(component: predefinedDirs.pluginTargetDir) ) + let predefinedTemplateTargetDirectory = PredefinedTargetDirectory( + fs: fileSystem, + path: packagePath.appending(component: predefinedDirs.templateTargetDir) + ) /// Returns the path of the given target. func findPath(for target: TargetDescription) throws -> AbsolutePath { if target.type == .binary { @@ -637,14 +657,19 @@ public final class PackageBuilder { } // Check if target is present in the predefined directory. - let predefinedDir: PredefinedTargetDirectory = switch target.type { - case .test: - predefinedTestTargetDirectory - case .plugin: - predefinedPluginTargetDirectory - default: - predefinedTargetDirectory - } + let predefinedDir: PredefinedTargetDirectory = { + switch target.type { + case .test: + predefinedTestTargetDirectory + case .plugin: + predefinedPluginTargetDirectory + case .template: + predefinedTemplateTargetDirectory + default: + predefinedTargetDirectory + } + }() + let path = predefinedDir.path.appending(component: target.name) // Return the path if the predefined directory contains it. @@ -1051,6 +1076,8 @@ public final class PackageBuilder { moduleKind = .executable case .macro: moduleKind = .macro + case .template: //john-to-revisit + moduleKind = .template default: moduleKind = sources.computeModuleKind() if moduleKind == .executable && self.manifest.toolsVersion >= .v5_4 && self @@ -1687,6 +1714,10 @@ public final class PackageBuilder { guard self.validatePluginProduct(product, with: modules) else { continue } + case .template: //john-to-revisit + guard self.validateTemplateProduct(product, with: modules) else { + continue + } } try append(Product(package: self.identity, name: product.name, type: product.type, modules: modules)) @@ -1701,7 +1732,7 @@ public final class PackageBuilder { switch product.type { case .library, .plugin, .test, .macro: return [] - case .executable, .snippet: + case .executable, .snippet, .template: //john-to-revisit return product.targets } }) @@ -1847,6 +1878,27 @@ public final class PackageBuilder { return true } + private func validateTemplateProduct(_ product: ProductDescription, with targets: [Module]) -> Bool { + let nonTemplateTargets = targets.filter { $0.type != .template } + guard nonTemplateTargets.isEmpty else { + self.observabilityScope + .emit(.templateProductWithNonTemplateTargets(product: product.name, + otherTargets: nonTemplateTargets.map(\.name))) + return false + } + guard !targets.isEmpty else { + self.observabilityScope.emit(.templateProductWithNoTargets(product: product.name)) + return false + } + + guard targets.count == 1 else { + self.observabilityScope.emit(.templateProductWithMultipleTemplates(product: product.name)) + return false + } + + return true + } + /// Returns the first suggested predefined source directory for a given target type. public static func suggestedPredefinedSourceDirectory(type: TargetDescription.TargetKind) -> String { // These are static constants, safe to access by index; the first choice is preferred. From 5c040586a65290d860b4051537e5fc52e0003384 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:31:32 -0400 Subject: [PATCH 143/225] added templates as a type of module, will need to revisit to edit behavior as an executable --- .../ManifestSourceGeneration.swift | 20 +++++++++++-------- Sources/PackageModel/Module/Module.swift | 3 ++- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Sources/PackageModel/ManifestSourceGeneration.swift b/Sources/PackageModel/ManifestSourceGeneration.swift index d1818cb1fd3..409954bac64 100644 --- a/Sources/PackageModel/ManifestSourceGeneration.swift +++ b/Sources/PackageModel/ManifestSourceGeneration.swift @@ -321,6 +321,8 @@ fileprivate extension SourceCodeFragment { self.init(enum: "test", subnodes: params, multiline: true) case .macro: self.init(enum: "macro", subnodes: params, multiline: true) + case .template: + self.init(enum: "template", subnodes: params, multiline: true) } } } @@ -661,23 +663,25 @@ fileprivate extension SourceCodeFragment { init(from templateInitializationOptions: TargetDescription.TemplateInitializationOptions) { switch templateInitializationOptions { - case .packageInit(let templateType, let executable, let templatePermissions, let description): + case .packageInit(let templateType, let templatePermissions, let description): var params: [SourceCodeFragment] = [] switch templateType { - case .regular: + case .library: self.init(enum: "target", subnodes: params, multiline: true) case .executable: self.init(enum: "executableTarget", subnodes: params, multiline: true) - case .test: - self.init(enum: "testTarget", subnodes: params, multiline: true) + case .tool: + self.init(enum: "tool", subnodes: params, multiline: true) + case .buildToolPlugin: + self.init(enum: "buildToolPlugin", subnodes: params, multiline: true) + case .commandPlugin: + self.init(enum: "commandPlugin", subnodes: params, multiline: true) case .macro: self.init(enum: "macro", subnodes: params, multiline: true) + case .empty: + self.init(enum: "empty", subnodes: params, multiline: true) } - // Template type as an enum - - // Executable fragment - params.append(SourceCodeFragment(key: "executable", subnode: .init(from: executable))) // Permissions, if any if let permissions = templatePermissions { diff --git a/Sources/PackageModel/Module/Module.swift b/Sources/PackageModel/Module/Module.swift index 7001c244212..0b60a2178d8 100644 --- a/Sources/PackageModel/Module/Module.swift +++ b/Sources/PackageModel/Module/Module.swift @@ -29,6 +29,7 @@ public class Module { case plugin case snippet case `macro` + case template } /// A group a module belongs to that allows customizing access boundaries. A module is treated as @@ -310,7 +311,7 @@ public extension Sequence where Iterator.Element == Module { switch $0.type { case .binary: return ($0 as? BinaryModule)?.containsExecutable == true - case .executable, .snippet, .macro: + case .executable, .snippet, .macro, .template: //john-to-revisit return true default: return false From 29d3c0fd1ab4e92021c7337776457cc6e3c01f0f Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:31:57 -0400 Subject: [PATCH 144/225] added templates as a type of module, will need to revisit to edit behavior as an executable pt2 --- Sources/PackageModel/Module/SwiftModule.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/PackageModel/Module/SwiftModule.swift b/Sources/PackageModel/Module/SwiftModule.swift index 1fbb2a257d4..f382895c2a6 100644 --- a/Sources/PackageModel/Module/SwiftModule.swift +++ b/Sources/PackageModel/Module/SwiftModule.swift @@ -143,11 +143,11 @@ public final class SwiftModule: Module { } public var supportsTestableExecutablesFeature: Bool { - // Exclude macros from testable executables if they are built as dylibs. + // Exclude macros from testable executables if they are built as dylibs. john-to-revisit #if BUILD_MACROS_AS_DYLIBS - return type == .executable || type == .snippet + return type == .executable || type == .snippet || type == .template #else - return type == .executable || type == .macro || type == .snippet + return type == .executable || type == .macro || type == .snippet || type == .template #endif } } From d509aa6c9ce849ce48b0f3bb3615220fe7fce04c Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:32:34 -0400 Subject: [PATCH 145/225] changed tools version when generating a new project, will need to revert back to major, minor once demo completed --- Sources/PackageModel/ToolsVersion.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/PackageModel/ToolsVersion.swift b/Sources/PackageModel/ToolsVersion.swift index cded5bf9467..37e12c6703a 100644 --- a/Sources/PackageModel/ToolsVersion.swift +++ b/Sources/PackageModel/ToolsVersion.swift @@ -79,7 +79,7 @@ public struct ToolsVersion: Equatable, Hashable, Codable, Sendable { /// Returns the tools version with zeroed patch number. public var zeroedPatch: ToolsVersion { - return ToolsVersion(version: Version(major, minor, 0)) + return ToolsVersion(version: Version(6, 1, 0)) //john-to-revisit working on 6.1.0, just for using to test. revert to major, minor when finished } /// The underlying backing store. From cd8b0e2bba602b0b53bbec40230a262cba467996 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:33:35 -0400 Subject: [PATCH 146/225] target and product descriptions syntax --- .../ProductDescription+Syntax.swift | 63 +++++++++++ .../TargetDescription+Syntax.swift | 100 ++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 Sources/PackageModelSyntax/ProductDescription+Syntax.swift create mode 100644 Sources/PackageModelSyntax/TargetDescription+Syntax.swift diff --git a/Sources/PackageModelSyntax/ProductDescription+Syntax.swift b/Sources/PackageModelSyntax/ProductDescription+Syntax.swift new file mode 100644 index 00000000000..d12cef5e31b --- /dev/null +++ b/Sources/PackageModelSyntax/ProductDescription+Syntax.swift @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftSyntaxBuilder +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" + case .template: "template" + } + } + + 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/TargetDescription+Syntax.swift b/Sources/PackageModelSyntax/TargetDescription+Syntax.swift new file mode 100644 index 00000000000..9f5ff2299ae --- /dev/null +++ b/Sources/PackageModelSyntax/TargetDescription+Syntax.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftSyntaxBuilder +import SwiftParser + +extension TargetDescription: ManifestSyntaxRepresentable { + /// The function name in the package manifest. + private var functionName: String { + switch type { + case .binary: "binaryTarget" + case .executable: "executableTarget" + case .macro: "macro" + case .plugin: "plugin" + case .regular: "target" + case .system: "systemLibrary" + case .test: "testTarget" + case .template: "template" + } + } + + func asSyntax() -> ExprSyntax { + var arguments: [LabeledExprSyntax] = [] + arguments.append(label: "name", stringLiteral: name) + // FIXME: pluginCapability + + arguments.appendIfNonEmpty( + label: "dependencies", + arrayLiteral: dependencies + ) + + arguments.appendIf(label: "path", stringLiteral: path) + arguments.appendIf(label: "url", stringLiteral: url) + arguments.appendIfNonEmpty(label: "exclude", arrayLiteral: exclude) + arguments.appendIf(label: "sources", arrayLiteral: sources) + + // FIXME: resources + + arguments.appendIf( + label: "publicHeadersPath", + stringLiteral: publicHeadersPath + ) + + if !packageAccess { + arguments.append( + label: "packageAccess", + expression: "false" + ) + } + + // FIXME: cSettings + // FIXME: cxxSettings + // FIXME: swiftSettings + // FIXME: linkerSettings + // FIXME: plugins + + arguments.appendIf(label: "pkgConfig", stringLiteral: pkgConfig) + // FIXME: providers + + // Only for plugins + arguments.appendIf(label: "checksum", stringLiteral: checksum) + + let separateParen: String = arguments.count > 1 ? "\n" : "" + let argumentsSyntax = LabeledExprListSyntax(arguments) + return ".\(raw: functionName)(\(argumentsSyntax)\(raw: separateParen))" + } +} + +extension TargetDescription.Dependency: ManifestSyntaxRepresentable { + func asSyntax() -> ExprSyntax { + switch self { + case .byName(name: let name, condition: nil): + "\(literal: name)" + + case .target(name: let name, condition: nil): + ".target(name: \(literal: name))" + + case .product(name: let name, package: nil, moduleAliases: nil, condition: nil): + ".product(name: \(literal: name))" + + case .product(name: let name, package: let package, moduleAliases: nil, condition: nil): + ".product(name: \(literal: name), package: \(literal: package))" + + default: + fatalError() + } + } +} From 7d4f7d326c691294646274518461263fbaaf577d Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:34:28 -0400 Subject: [PATCH 147/225] build parameters for templates, will need to revisit --- Sources/SPMBuildCore/BuildParameters/BuildParameters.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift b/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift index 6bd5b8804c1..55984aa1532 100644 --- a/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift +++ b/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift @@ -307,9 +307,15 @@ public struct BuildParameters: Encodable { try RelativePath(validating: "\(name)\(self.suffix)\(self.triple.executableExtension)") } + package func templatePath(for name: String) throws -> Basics.RelativePath { + try RelativePath(validating: "\(name)\(self.suffix)\(self.triple.executableExtension)") //John-to-revisit + } + /// Returns the path to the binary of a product for the current build parameters, relative to the build directory. public func binaryRelativePath(for product: ResolvedProduct) throws -> Basics.RelativePath { switch product.type { + case .template: + return try templatePath(for: product.name) //john-to-revisit case .executable, .snippet: return try executablePath(for: product.name) case .library(.static): From 6051b8977a57e1891dfff79131fbb09dadaef594 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:34:51 -0400 Subject: [PATCH 148/225] plugins support for executing templates --- Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift | 2 +- Sources/SPMBuildCore/Plugins/PluginInvocation.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift b/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift index 83b3b90321d..783c67b7c27 100644 --- a/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift +++ b/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift @@ -335,7 +335,7 @@ fileprivate extension WireInput.Target.TargetInfo.SourceModuleKind { switch kind { case .library: self = .generic - case .executable: + case .executable, .template: //john-to-revisit self = .executable case .snippet: self = .snippet diff --git a/Sources/SPMBuildCore/Plugins/PluginInvocation.swift b/Sources/SPMBuildCore/Plugins/PluginInvocation.swift index d86ef9ecad7..db345bceb8c 100644 --- a/Sources/SPMBuildCore/Plugins/PluginInvocation.swift +++ b/Sources/SPMBuildCore/Plugins/PluginInvocation.swift @@ -696,7 +696,7 @@ fileprivate func collectAccessibleTools( } } // For an executable target we create a `builtTool`. - else if executableOrBinaryModule.type == .executable { + else if executableOrBinaryModule.type == .executable || executableOrBinaryModule.type == .template { //john-to-revisit return try [.builtTool(name: builtToolName, path: RelativePath(validating: executableOrBinaryModule.name))] } else { From ae81422b8c0de1f0a5dd93c619331106dd219cdf Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:35:33 -0400 Subject: [PATCH 149/225] buildsupport for templates, will need to revisit --- .../PackagePIFBuilder+Helpers.swift | 13 +++++++------ Sources/SwiftBuildSupport/PackagePIFBuilder.swift | 10 +++++++++- .../PackagePIFProjectBuilder+Modules.swift | 7 +++++++ .../PackagePIFProjectBuilder+Products.swift | 3 +++ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift b/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift index b409518837d..49e600fff1f 100644 --- a/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift +++ b/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift @@ -184,7 +184,7 @@ extension PackageModel.Module { switch self.type { case .executable, .snippet: true - case .library, .test, .macro, .systemModule, .plugin, .binary: + case .library, .test, .macro, .systemModule, .plugin, .binary, .template: //john-to-revisit false } } @@ -193,7 +193,7 @@ extension PackageModel.Module { switch self.type { case .binary: true - case .library, .executable, .snippet, .test, .plugin, .macro, .systemModule: + case .library, .executable, .snippet, .test, .plugin, .macro, .systemModule, .template: false } } @@ -203,7 +203,7 @@ extension PackageModel.Module { switch self.type { case .library, .executable, .snippet, .test, .macro: true - case .systemModule, .plugin, .binary: + case .systemModule, .plugin, .binary, .template: //john-to-revisit false } } @@ -218,6 +218,7 @@ extension PackageModel.ProductType { case .library: .library case .plugin: .plugin case .macro: .macro + case .template: .template } } } @@ -687,7 +688,7 @@ extension PackageGraph.ResolvedProduct { /// (e.g., executables have one executable module, test bundles have one test module, etc). var isMainModuleProduct: Bool { switch self.type { - case .executable, .snippet, .test: + case .executable, .snippet, .test, .template: //john-to-revisit true case .library, .macro, .plugin: false @@ -705,7 +706,7 @@ extension PackageGraph.ResolvedProduct { var isExecutable: Bool { switch self.type { - case .executable, .snippet: + case .executable, .snippet, .template: //john-to-revisit true case .library, .test, .plugin, .macro: false @@ -748,7 +749,7 @@ extension PackageGraph.ResolvedProduct { /// Shoud we link this product dependency? var isLinkable: Bool { switch self.type { - case .library, .executable, .snippet, .test, .macro: + case .library, .executable, .snippet, .test, .macro, .template: //john-to-revisit true case .plugin: false diff --git a/Sources/SwiftBuildSupport/PackagePIFBuilder.swift b/Sources/SwiftBuildSupport/PackagePIFBuilder.swift index 65b303f68b4..21ba0e129e7 100644 --- a/Sources/SwiftBuildSupport/PackagePIFBuilder.swift +++ b/Sources/SwiftBuildSupport/PackagePIFBuilder.swift @@ -459,6 +459,9 @@ public final class PackagePIFBuilder { case .macro: break // TODO: Double-check what's going on here as we skip snippet modules too (rdar://147705448) + case .template: + // john-to-revisit: makeTemplateproduct + try projectBuilder.makeMainModuleProduct(product) } } @@ -495,6 +498,11 @@ public final class PackagePIFBuilder { case .macro: try projectBuilder.makeMacroModule(module) + + case .template: + // Skip template modules — they represent tools, not code to compile. john-to-revisit + break + } } @@ -691,7 +699,7 @@ extension PackagePIFBuilder.LinkedPackageBinary { case .library, .binary, .macro: self.init(name: module.name, packageName: packageName, type: .target) - case .systemModule, .plugin: + case .systemModule, .plugin, .template: //john-to-revisit return nil } } diff --git a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift index 727562b27dd..c82243c413e 100644 --- a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift +++ b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift @@ -96,7 +96,12 @@ extension PackagePIFProjectBuilder { platformFilters: dependencyPlatformFilters ) log(.debug, indent: 1, "Added dependency on target '\(dependencyGUID)'") + case .template: //john-to-revisit + // Template targets are used as tooling, not build dependencies. + // Plugins will invoke them when needed. + break } + case .product(let productDependency, let packageConditions): // Do not add a dependency for binary-only executable products since they are not part of the build. @@ -678,6 +683,8 @@ extension PackagePIFProjectBuilder { indent: 1, "Added \(shouldLinkProduct ? "linked " : "")dependency on target '\(moduleDependency.pifTargetGUID)'" ) + case .template: //john-to-revisit + break } case .product(let productDependency, let packageConditions): diff --git a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift index 7292cddc7fe..970cc42f3fa 100644 --- a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift +++ b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift @@ -451,6 +451,9 @@ extension PackagePIFProjectBuilder { indent: 1, "Added \(shouldLinkProduct ? "linked " : "")dependency on target '\(dependencyGUID)'" ) + case .template: + //john-to-revisit + break } case .product(let productDependency, let packageConditions): From 2afc49feac434c841d2002b9981588942cccc154 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:35:56 -0400 Subject: [PATCH 150/225] template creation workhorse --- Sources/Workspace/InitTemplatePackage.swift | 158 +++++++++++++------- 1 file changed, 103 insertions(+), 55 deletions(-) diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 81506213928..f250164f31e 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -4,6 +4,7 @@ // // Created by John Bute on 2025-05-13. // + import Basics import PackageModel import SPMBuildCore @@ -13,11 +14,60 @@ import Basics import PackageModel import SPMBuildCore import TSCUtility +import System +import PackageModelSyntax +import TSCBasic +import SwiftParser + public final class InitTemplatePackage { var initMode: TemplateType + public var supportedTestingLibraries: Set + + + let templateName: String + /// The file system to use + let fileSystem: FileSystem + + /// Where to create the new package + let destinationPath: Basics.AbsolutePath + + /// Configuration from the used toolchain. + let installedSwiftPMConfiguration: InstalledSwiftPMConfiguration + + var packageName: String + + + var templatePath: Basics.AbsolutePath + + let packageType: InitPackage.PackageType + + public struct InitPackageOptions { + /// The type of package to create. + public var packageType: InitPackage.PackageType + + /// The set of supported testing libraries to include in the package. + public var supportedTestingLibraries: Set + + /// The list of platforms in the manifest. + /// + /// Note: This should only contain Apple platforms right now. + public var platforms: [SupportedPlatform] + + public init( + packageType: InitPackage.PackageType, + supportedTestingLibraries: Set, + platforms: [SupportedPlatform] = [] + ) { + self.packageType = packageType + self.supportedTestingLibraries = supportedTestingLibraries + self.platforms = platforms + } + } + + public enum TemplateType: String, CustomStringConvertible { case local = "local" @@ -29,76 +79,74 @@ public final class InitTemplatePackage { } } - var packageName: String? - - var templatePath: AbsolutePath - let fileSystem: FileSystem - public init(initMode: InitTemplatePackage.TemplateType, packageName: String? = nil, templatePath: AbsolutePath, fileSystem: FileSystem) { + + public init( + name: String, + templateName: String, + initMode: TemplateType, + templatePath: Basics.AbsolutePath, + fileSystem: FileSystem, + packageType: InitPackage.PackageType, + supportedTestingLibraries: Set, + destinationPath: Basics.AbsolutePath, + installedSwiftPMConfiguration: InstalledSwiftPMConfiguration, + ) { + self.packageName = name self.initMode = initMode - self.packageName = packageName self.templatePath = templatePath + self.packageType = packageType + self.supportedTestingLibraries = supportedTestingLibraries + self.destinationPath = destinationPath + self.installedSwiftPMConfiguration = installedSwiftPMConfiguration self.fileSystem = fileSystem + self.templateName = templateName } - - - private func checkTemplateExists(templatePath: AbsolutePath) throws { - //Checks if there is a package in directory, if it contains a .template command line-tool and if it contains a /template folder. - - //check if the path does exist - guard self.fileSystem.exists(templatePath) else { - throw TemplateError.invalidPath - } - // Check if Package.swift exists in the directory - let manifest = templatePath.appending(component: Manifest.filename) - guard self.fileSystem.exists(manifest) else { - throw TemplateError.invalidPath - } - //check if package.swift contains a .plugin - - //check if it contains a template folder - + public func setupTemplateManifest() throws { + // initialize empty swift package + let initializedPackage = try InitPackage(name: self.packageName, options: .init(packageType: self.packageType, supportedTestingLibraries: self.supportedTestingLibraries), destinationPath: self.destinationPath, installedSwiftPMConfiguration: self.installedSwiftPMConfiguration, fileSystem: self.fileSystem) + try initializedPackage.writePackageStructure() + try initializePackageFromTemplate() + + //try build + // try --experimental-help-dump + //prompt + //run the executable. } -/* - func initPackage(_ swiftCommandState: SwiftCommandState) throws { - //Logic here for initializing initial package (should find better way to organize this but for now) - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") - } + private func initializePackageFromTemplate() throws { + try addTemplateDepenency() + } - let packageName = self.packageName ?? cwd.basename + private func addTemplateDepenency() throws { - // Testing is on by default, with XCTest only enabled explicitly. - // For macros this is reversed, since we don't support testing - // macros with Swift Testing yet. - var supportedTestingLibraries = Set() - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.xctest) - } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.swiftTesting) + + let manifestPath = destinationPath.appending(component: Manifest.filename) + let manifestContents: ByteString + + do { + manifestContents = try fileSystem.readFileContents(manifestPath) + } catch { + throw StringError("Cannot fin package manifest in \(manifestPath)") } - let initPackage = try InitPackage( - name: packageName, - packageType: initMode, - supportedTestingLibraries: supportedTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, - fileSystem: swiftCommandState.fileSystem - ) - initPackage.progressReporter = { message in - print(message) + let manifestSyntax = manifestContents.withData { data in + data.withUnsafeBytes { buffer in + buffer.withMemoryRebound(to: UInt8.self) { buffer in + Parser.parse(source: buffer) + } + } } - try initPackage.writePackageStructure() + + let editResult = try AddPackageDependency.addPackageDependency( + .fileSystem(name: nil, path: self.templatePath.pathString), to: manifestSyntax) + + try editResult.applyEdits(to: fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: false) } - */ + } @@ -113,7 +161,7 @@ extension TemplateError: CustomStringConvertible { switch self { case .manifestAlreadyExists: return "a manifest file already exists in this directory" - case let .invalidPath: + case .invalidPath: return "Path does not exist, or is invalid." } } From adea001c5c5875ee8808e95d10c59b09cefd3f3f Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:36:23 -0400 Subject: [PATCH 151/225] build support for xcbuild --- Sources/XCBuildSupport/PIFBuilder.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/XCBuildSupport/PIFBuilder.swift b/Sources/XCBuildSupport/PIFBuilder.swift index 1b231f68592..41627c65442 100644 --- a/Sources/XCBuildSupport/PIFBuilder.swift +++ b/Sources/XCBuildSupport/PIFBuilder.swift @@ -386,7 +386,7 @@ final class PackagePIFProjectBuilder: PIFProjectBuilder { private func addTarget(for product: ResolvedProduct) throws { switch product.type { - case .executable, .snippet, .test: + case .executable, .snippet, .test, .template: //john-to-revisit try self.addMainModuleTarget(for: product) case .library: self.addLibraryTarget(for: product) @@ -414,6 +414,8 @@ final class PackagePIFProjectBuilder: PIFProjectBuilder { case .macro: // Macros are not supported when using XCBuild, similar to package plugins. return + case .template: //john-to-revisit + return } } @@ -1618,6 +1620,8 @@ extension ProductType { .plugin case .macro: .macro + case .template: + .template } } } From d7b0b52f18a3cc2dad197799cfcc82347b0f3229 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 09:37:18 -0400 Subject: [PATCH 152/225] reverting plugin test to normal --- Tests/FunctionalTests/PluginTests.swift | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Tests/FunctionalTests/PluginTests.swift b/Tests/FunctionalTests/PluginTests.swift index 264c132e5ff..0f4d31c67f5 100644 --- a/Tests/FunctionalTests/PluginTests.swift +++ b/Tests/FunctionalTests/PluginTests.swift @@ -535,16 +535,6 @@ final class PluginTests { .package(name: "HelperPackage", path: "VendoredDependencies/HelperPackage") ], targets: [ - .template( - name: "GenerateStuff", - - templateInitializationOptions: .packageInit( - templateType: .executable, - executable: .target(name: "MyLibrary"), - description: "A template that generates a starter executable package" - ), - executable: .target(name: "MyLibrary"), - ), .target( name: "MyLibrary", dependencies: [ From d151584444c8ec797108a4713249f49c88ddafc8 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 14:16:27 -0400 Subject: [PATCH 153/225] printing out --experimental-dump-help --- .../Miscellaneous/ShowTemplates/app/Package.swift | 7 +++---- .../{GenerateStuffPlugin => looPlugin}/plugin.swift | 4 +++- .../app/Templates/GenerateThings/main.swift | 8 -------- .../Templates/{GenerateStuff => loo}/Template.swift | 0 Sources/Build/BuildOperation.swift | 1 + Sources/Commands/PackageCommands/Init.swift | 5 +++++ Sources/Commands/PackageCommands/PluginCommand.swift | 6 ++++-- Sources/PackageDescription/Product.swift | 12 +++++++++--- Sources/PackageDescription/Target.swift | 5 ++--- 9 files changed, 27 insertions(+), 21 deletions(-) rename Fixtures/Miscellaneous/ShowTemplates/app/Plugins/{GenerateStuffPlugin => looPlugin}/plugin.swift (87%) delete mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift rename Fixtures/Miscellaneous/ShowTemplates/app/Templates/{GenerateStuff => loo}/Template.swift (100%) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift index bddabe3cf92..13ffaf45b03 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -3,9 +3,8 @@ import PackageDescription let package = Package( name: "Dealer", - products: [.template( - name: "GenerateStuff" - ),], + products: Product.template(name: "loo") + , dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), @@ -13,7 +12,7 @@ let package = Package( ], targets: Target.template( - name: "GenerateStuff", + name: "loo", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "SystemPackage", package: "swift-system") diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateStuffPlugin/plugin.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift similarity index 87% rename from Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateStuffPlugin/plugin.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift index 8318ddd1ef0..8e88b2ec74d 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateStuffPlugin/plugin.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift @@ -17,7 +17,9 @@ struct TemplatePlugin: CommandPlugin { context: PluginContext, arguments: [String] ) async throws { - let tool = try context.tool(named: "GenerateStuff") + + print(arguments) + let tool = try context.tool(named: "loo") let process = Process() process.executableURL = URL(filePath: tool.url.path()) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift deleted file mode 100644 index ba2e9c4f78d..00000000000 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateThings/main.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// main.swift -// app -// -// Created by John Bute on 2025-06-03. -// - -print("hello, world!") diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/Template.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/loo/Template.swift similarity index 100% rename from Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateStuff/Template.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Templates/loo/Template.swift diff --git a/Sources/Build/BuildOperation.swift b/Sources/Build/BuildOperation.swift index d901f14e72d..8de1b95d1f5 100644 --- a/Sources/Build/BuildOperation.swift +++ b/Sources/Build/BuildOperation.swift @@ -661,6 +661,7 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS let product = graph.product(for: productName) + print("computeLLBuildTargetName") guard let product else { observabilityScope.emit(error: "no product named '\(productName)'") throw Diagnostics.fatalError diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 6de836f5794..f3c55079045 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -191,6 +191,11 @@ extension SwiftPackageCommand { // Implement or call your Registry-based template handler print("TODO: Handle Registry template") } + + + let parsedOptions = try PluginCommand.PluginOptions.parse(["--allow-writing-to-package-directory"]) + try await PluginCommand.run(command: template, options: parsedOptions, arguments: ["--","--experimental-dump-help"], swiftCommandState: swiftCommandState) + } else { var supportedTestingLibraries = Set() diff --git a/Sources/Commands/PackageCommands/PluginCommand.swift b/Sources/Commands/PackageCommands/PluginCommand.swift index 8bc238af22a..8129b975c1b 100644 --- a/Sources/Commands/PackageCommands/PluginCommand.swift +++ b/Sources/Commands/PackageCommands/PluginCommand.swift @@ -37,7 +37,7 @@ struct PluginCommand: AsyncSwiftCommand { ) var listCommands: Bool = false - struct PluginOptions: ParsableArguments { + public struct PluginOptions: ParsableArguments { @Flag( name: .customLong("allow-writing-to-package-directory"), help: "Allow the plugin to write to the package directory." @@ -376,7 +376,7 @@ struct PluginCommand: AsyncSwiftCommand { let allowNetworkConnectionsCopy = allowNetworkConnections let buildEnvironment = buildParameters.buildEnvironment - let _ = try await pluginTarget.invoke( + let pluginOutput = try await pluginTarget.invoke( action: .performCommand(package: package, arguments: arguments), buildEnvironment: buildEnvironment, scriptRunner: pluginScriptRunner, @@ -396,6 +396,8 @@ struct PluginCommand: AsyncSwiftCommand { delegate: pluginDelegate ) + print("plugin Output:", pluginOutput) + // TODO: We should also emit a final line of output regarding the result. } diff --git a/Sources/PackageDescription/Product.swift b/Sources/PackageDescription/Product.swift index 1e21fce6396..d17a83cd487 100644 --- a/Sources/PackageDescription/Product.swift +++ b/Sources/PackageDescription/Product.swift @@ -201,10 +201,16 @@ public class Product { @available(_PackageDescription, introduced: 6.0) public static func template( name: String, - ) -> Product { + ) -> [Product] { let templatePluginName = "\(name)Plugin" - let executableTemplateName = name - return Product.plugin(name: templatePluginName, targets: [templatePluginName]) + return [Product.plugin(name: templatePluginName, targets: [templatePluginName]), Product.template(name: name)] + + } + + private static func template( + name: String + ) -> Product { + return Executable(name: name, targets: [name], settings: []) } } diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index 76fdbb62308..82d88fff0c1 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -1271,7 +1271,6 @@ public final class Target { pluginCapability: capability) } - //john-to-revisit documentation @available(_PackageDescription, introduced: 6.0) public static func template( name: String, @@ -1291,12 +1290,12 @@ public final class Target { ) -> [Target] { let templatePluginName = "\(name)Plugin" - let templateExecutableName = name + let templateExecutableName = "\(name)" let (verb, description): (String, String) switch templateInitializationOptions { case .packageInit(_, _, let desc): - verb = "init-\(name.lowercased())" + verb = templateExecutableName description = desc } From 0434d0aefd4d8253af31f7456a7543ecd966c127 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 5 Jun 2025 15:52:36 -0400 Subject: [PATCH 154/225] capturing --experimental-dump-help and parsing it --- .../app/Plugins/looPlugin/plugin.swift | 2 - Sources/Build/BuildOperation.swift | 1 - Sources/Commands/PackageCommands/Init.swift | 71 ++++++++++++++++++- .../PackageCommands/PluginCommand.swift | 2 - 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift index 8e88b2ec74d..eff35a92b01 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift @@ -17,8 +17,6 @@ struct TemplatePlugin: CommandPlugin { context: PluginContext, arguments: [String] ) async throws { - - print(arguments) let tool = try context.tool(named: "loo") let process = Process() diff --git a/Sources/Build/BuildOperation.swift b/Sources/Build/BuildOperation.swift index 8de1b95d1f5..d901f14e72d 100644 --- a/Sources/Build/BuildOperation.swift +++ b/Sources/Build/BuildOperation.swift @@ -661,7 +661,6 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS let product = graph.product(for: productName) - print("computeLLBuildTargetName") guard let product else { observabilityScope.emit(error: "no product named '\(productName)'") throw Diagnostics.fatalError diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index f3c55079045..4488d30da57 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -27,6 +27,7 @@ import SPMBuildCore import XCBuildSupport import TSCBasic +import ArgumentParserToolInfo extension SwiftPackageCommand { struct Init: AsyncSwiftCommand { @@ -192,10 +193,50 @@ extension SwiftPackageCommand { print("TODO: Handle Registry template") } - + /* let parsedOptions = try PluginCommand.PluginOptions.parse(["--allow-writing-to-package-directory"]) + + try await PluginCommand.run(command: template, options: parsedOptions, arguments: ["--","--experimental-dump-help"], swiftCommandState: swiftCommandState) + */ + + //will need to revisit this + let arguments = [ + "/Users/johnbute/Desktop/swift-pm-template/.build/arm64-apple-macosx/debug/swift-package", "plugin", template, "--allow-network-connections","local:1200", + "--", "--experimental-dump-help" + ] + let process = AsyncProcess(arguments: arguments) + + try process.launch() + + let processResult = try await process.waitUntilExit() + + + guard processResult.exitStatus == .terminated(code: 0) else { + throw try StringError(processResult.utf8stderrOutput()) + } + + + switch processResult.output { + case .success(let outputBytes): + let outputString = String(decoding: outputBytes, as: UTF8.self) + + + guard let data = outputString.data(using: .utf8) else { + fatalError("Could not convert output string to Data") + } + + do { + let schema = try JSONDecoder().decode(ToolInfoV0.self, from: data) + } + + case .failure(let error): + print("Failed to get output:", error) + } + + + } else { var supportedTestingLibraries = Set() @@ -226,6 +267,34 @@ extension SwiftPackageCommand { } } + + private func captureStdout(_ block: () async throws -> Void) async throws -> String { + let originalStdout = dup(fileno(stdout)) + + let pipe = Pipe() + let readHandle = pipe.fileHandleForReading + let writeHandle = pipe.fileHandleForWriting + + dup2(writeHandle.fileDescriptor, fileno(stdout)) + + + var output = "" + let outputQueue = DispatchQueue(label: "outputQueue") + let group = DispatchGroup() + group.enter() + + outputQueue.async { + let data = readHandle.readDataToEndOfFile() + output = String(data: data, encoding: .utf8) ?? "" + } + + + fflush(stdout) + writeHandle.closeFile() + + dup2(originalStdout, fileno(stdout)) + return output + } // first save current activeWorkspace //second switch activeWorkspace to the template Path //third revert after conditions have been checked, (we will also get stuff needed for dpeende diff --git a/Sources/Commands/PackageCommands/PluginCommand.swift b/Sources/Commands/PackageCommands/PluginCommand.swift index 8129b975c1b..68ef07efd39 100644 --- a/Sources/Commands/PackageCommands/PluginCommand.swift +++ b/Sources/Commands/PackageCommands/PluginCommand.swift @@ -396,8 +396,6 @@ struct PluginCommand: AsyncSwiftCommand { delegate: pluginDelegate ) - print("plugin Output:", pluginOutput) - // TODO: We should also emit a final line of output regarding the result. } From 13b12a506bc720a31ae4dbad63bf0210dc8fc9f6 Mon Sep 17 00:00:00 2001 From: John Bute Date: Sat, 7 Jun 2025 11:55:41 -0400 Subject: [PATCH 155/225] fixtures update --- Fixtures/Miscellaneous/ShowTemplates/app/Package.swift | 4 ++-- .../app/Plugins/{looPlugin => kooPlugin}/plugin.swift | 2 +- .../ShowTemplates/app/Templates/{loo => koo}/Template.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) rename Fixtures/Miscellaneous/ShowTemplates/app/Plugins/{looPlugin => kooPlugin}/plugin.swift (91%) rename Fixtures/Miscellaneous/ShowTemplates/app/Templates/{loo => koo}/Template.swift (95%) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift index 13ffaf45b03..b6704e45506 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "Dealer", - products: Product.template(name: "loo") + products: Product.template(name: "koo") , dependencies: [ @@ -12,7 +12,7 @@ let package = Package( ], targets: Target.template( - name: "loo", + name: "koo", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "SystemPackage", package: "swift-system") diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/kooPlugin/plugin.swift similarity index 91% rename from Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Plugins/kooPlugin/plugin.swift index eff35a92b01..0c66b436fc8 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/looPlugin/plugin.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/kooPlugin/plugin.swift @@ -17,7 +17,7 @@ struct TemplatePlugin: CommandPlugin { context: PluginContext, arguments: [String] ) async throws { - let tool = try context.tool(named: "loo") + let tool = try context.tool(named: "koo") let process = Process() process.executableURL = URL(filePath: tool.url.path()) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/loo/Template.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/koo/Template.swift similarity index 95% rename from Fixtures/Miscellaneous/ShowTemplates/app/Templates/loo/Template.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Templates/koo/Template.swift index 24d9af341c4..e310f9a1e3d 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/loo/Template.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/koo/Template.swift @@ -31,7 +31,7 @@ struct HelloTemplateTool: ParsableCommand { let rootDir = FilePath(fs.currentDirectoryPath) - let mainFile = rootDir / "Soures" / name / "main.swift" + let mainFile = rootDir / "Generated" / name / "main.swift" try fs.createDirectory(atPath: mainFile.removingLastComponent().string, withIntermediateDirectories: true) From e8bdd7e2da0572ad97d210cbaa1ff522b128a6be Mon Sep 17 00:00:00 2001 From: John Bute Date: Sat, 7 Jun 2025 11:56:37 -0400 Subject: [PATCH 156/225] Updating package.swift to include Argumnetparser dependency for workspace --- Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Package.swift b/Package.swift index d78f790b19a..c1f34839214 100644 --- a/Package.swift +++ b/Package.swift @@ -553,6 +553,7 @@ let package = Package( "SourceControl", "SPMBuildCore", .product(name: "OrderedCollections", package: "swift-collections"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), "PackageModelSyntax", ] + swiftSyntaxDependencies(["SwiftIDEUtils", "SwiftParser"]), exclude: ["CMakeLists.txt"], From cba59e7f62f2ebcfd6a62128475a9a426a445f7a Mon Sep 17 00:00:00 2001 From: John Bute Date: Sat, 7 Jun 2025 12:01:34 -0400 Subject: [PATCH 157/225] argument parsing dump-help --- Sources/Commands/PackageCommands/Init.swift | 113 +++++++++----------- Sources/Workspace/InitTemplatePackage.swift | 108 ++++++++++++++++++- 2 files changed, 156 insertions(+), 65 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 4488d30da57..31a5490c9f1 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -180,63 +180,76 @@ extension SwiftPackageCommand { do { try await buildSystem.build(subset: subset) } catch _ as Diagnostics { - throw ExitCode.failure + throw ExitCode.failure + } } - } + /* + let parsedOptions = try PluginCommand.PluginOptions.parse(["--allow-writing-to-package-directory"]) - case .git: - // Implement or call your Git-based template handler - print("TODO: Handle Git template") - case .registry: - // Implement or call your Registry-based template handler - print("TODO: Handle Registry template") - } - /* - let parsedOptions = try PluginCommand.PluginOptions.parse(["--allow-writing-to-package-directory"]) + try await PluginCommand.run(command: template, options: parsedOptions, arguments: ["--","--experimental-dump-help"], swiftCommandState: swiftCommandState) + + */ + //will need to revisit this + let arguments = [ + "/Users/johnbute/Desktop/swift-pm-template/.build/arm64-apple-macosx/debug/swift-package", "plugin", template, "--allow-network-connections","local:1200", + "--", "--experimental-dump-help" + ] + let process = AsyncProcess(arguments: arguments) - try await PluginCommand.run(command: template, options: parsedOptions, arguments: ["--","--experimental-dump-help"], swiftCommandState: swiftCommandState) + try process.launch() - */ + let processResult = try await process.waitUntilExit() - //will need to revisit this - let arguments = [ - "/Users/johnbute/Desktop/swift-pm-template/.build/arm64-apple-macosx/debug/swift-package", "plugin", template, "--allow-network-connections","local:1200", - "--", "--experimental-dump-help" - ] - let process = AsyncProcess(arguments: arguments) - try process.launch() + guard processResult.exitStatus == .terminated(code: 0) else { + throw try StringError(processResult.utf8stderrOutput()) + } - let processResult = try await process.waitUntilExit() + switch processResult.output { + case .success(let outputBytes): + let outputString = String(decoding: outputBytes, as: UTF8.self) - guard processResult.exitStatus == .terminated(code: 0) else { - throw try StringError(processResult.utf8stderrOutput()) - } + guard let data = outputString.data(using: .utf8) else { + fatalError("Could not convert output string to Data") + } + + do { + let schema = try JSONDecoder().decode(ToolInfoV0.self, from: data) + let response = try initTemplatePackage.promptUser(tool: schema) - switch processResult.output { - case .success(let outputBytes): - let outputString = String(decoding: outputBytes, as: UTF8.self) + let parsedOptions = try PluginCommand.PluginOptions.parse(["--allow-writing-to-package-directory"]) - guard let data = outputString.data(using: .utf8) else { - fatalError("Could not convert output string to Data") - } - - do { - let schema = try JSONDecoder().decode(ToolInfoV0.self, from: data) - } - case .failure(let error): - print("Failed to get output:", error) + try await PluginCommand.run(command: template, options: parsedOptions, arguments: response, swiftCommandState: swiftCommandState) + + } catch { + print(error) + } + + case .failure(let error): + print("Failed to get output:", error) + } + + + + + case .git: + // Implement or call your Git-based template handler + print("TODO: Handle Git template") + case .registry: + // Implement or call your Registry-based template handler + print("TODO: Handle Registry template") } + } else { var supportedTestingLibraries = Set() @@ -267,34 +280,6 @@ extension SwiftPackageCommand { } } - - private func captureStdout(_ block: () async throws -> Void) async throws -> String { - let originalStdout = dup(fileno(stdout)) - - let pipe = Pipe() - let readHandle = pipe.fileHandleForReading - let writeHandle = pipe.fileHandleForWriting - - dup2(writeHandle.fileDescriptor, fileno(stdout)) - - - var output = "" - let outputQueue = DispatchQueue(label: "outputQueue") - let group = DispatchGroup() - group.enter() - - outputQueue.async { - let data = readHandle.readDataToEndOfFile() - output = String(data: data, encoding: .utf8) ?? "" - } - - - fflush(stdout) - writeHandle.closeFile() - - dup2(originalStdout, fileno(stdout)) - return output - } // first save current activeWorkspace //second switch activeWorkspace to the template Path //third revert after conditions have been checked, (we will also get stuff needed for dpeende diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index f250164f31e..8d83ec84ff8 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -18,7 +18,7 @@ import System import PackageModelSyntax import TSCBasic import SwiftParser - +import ArgumentParserToolInfo public final class InitTemplatePackage { @@ -147,12 +147,114 @@ public final class InitTemplatePackage { try editResult.applyEdits(to: fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: false) } + + public func promptUser(tool: ToolInfoV0) throws -> [String] { + let arguments = try convertArguments(from: tool.command) + + let responses = UserPrompter.prompt(for: arguments) + + let commandLine = buildCommandLine(from: responses) + + return commandLine + } + + private func convertArguments(from command: CommandInfoV0) throws -> [ArgumentInfoV0] { + guard let rawArgs = command.arguments else { + throw TemplateError.noArguments + } + return rawArgs + } + + + private struct UserPrompter { + + static func prompt(for arguments: [ArgumentInfoV0]) -> [ArgumentResponse] { + return arguments + .filter { $0.valueName != "help" } + .map { arg in + let defaultText = arg.defaultValue.map { " (default: \($0))" } ?? "" + let promptMessage = "\(arg.abstract ?? "")\(defaultText):" + + var values: [String] = [] + + switch arg.kind { + case .flag: + let confirmed = promptForConfirmation(prompt: promptMessage, + defaultBehavior: arg.defaultValue?.lowercased() == "true") + values = [confirmed ? "true" : "false"] + + case .option, .positional: + print(promptMessage) + + if arg.isRepeating { + while let input = readLine(), !input.isEmpty { + values.append(input) + } + if values.isEmpty, let def = arg.defaultValue { + values = [def] + } + } else { + let input = readLine() + if let input = input, !input.isEmpty { + values = [input] + } else if let def = arg.defaultValue { + values = [def] + } else if arg.isOptional == false { + fatalError("Required argument '\(arg.valueName)' not provided.") + } + } + } + + return ArgumentResponse(argument: arg, values: values) + } + } + } + func buildCommandLine(from responses: [ArgumentResponse]) -> [String] { + return responses.flatMap(\.commandLineFragments) + } + + + + private static func promptForConfirmation(prompt: String, defaultBehavior: Bool?) -> Bool { + let suffix = defaultBehavior == true ? " [Y/n]" : defaultBehavior == false ? " [y/N]" : " [y/n]" + print(prompt + suffix, terminator: " ") + guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + return defaultBehavior ?? false + } + + switch input { + case "y", "yes": return true + case "n", "no": return false + default: return defaultBehavior ?? false + } + } + + struct ArgumentResponse { + let argument: ArgumentInfoV0 + let values: [String] + + var commandLineFragments: [String] { + guard let name = argument.valueName else { + return values + } + + switch argument.kind { + case .flag: + return values.first == "true" ? ["--\(name)"] : [] + case .option: + return values.flatMap { ["--\(name)", $0] } + case .positional: + return values + } + } + } } private enum TemplateError: Swift.Error { case invalidPath case manifestAlreadyExists + case noArguments } @@ -163,6 +265,10 @@ extension TemplateError: CustomStringConvertible { return "a manifest file already exists in this directory" case .invalidPath: return "Path does not exist, or is invalid." + case .noArguments: + return "Template has no arguments" } } } + + From 016460501073377ffd957c4b2670d5bb3bbb8a03 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Thu, 5 Jun 2025 16:25:06 -0400 Subject: [PATCH 158/225] Move new package description API to the array types and update version to 999.0.0 --- .../ShowTemplates/app/Package.swift | 12 ++++-------- Sources/PackageDescription/Product.swift | 17 +++++++++-------- Sources/PackageDescription/Target.swift | 12 +++++++----- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift index b6704e45506..060e3a35029 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -1,18 +1,16 @@ -// swift-tools-version:6.1 +// swift-tools-version:999.0.0 import PackageDescription let package = Package( name: "Dealer", - products: Product.template(name: "koo") - , - + products: .template(name: "loo"), dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2") ], - targets: Target.template( - name: "koo", + targets: .template( + name: "loo", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "SystemPackage", package: "swift-system") @@ -25,8 +23,6 @@ let package = Package( ], description: "A template that generates a starter executable package" ) - ) - ) diff --git a/Sources/PackageDescription/Product.swift b/Sources/PackageDescription/Product.swift index d17a83cd487..1df7491021f 100644 --- a/Sources/PackageDescription/Product.swift +++ b/Sources/PackageDescription/Product.swift @@ -198,19 +198,20 @@ public class Product { return Plugin(name: name, targets: targets) } - @available(_PackageDescription, introduced: 6.0) + fileprivate static func template( + name: String + ) -> Product { + return Executable(name: name, targets: [name], settings: []) + } +} + +public extension [Product] { + @available(_PackageDescription, introduced: 999.0.0) public static func template( name: String, ) -> [Product] { let templatePluginName = "\(name)Plugin" return [Product.plugin(name: templatePluginName, targets: [templatePluginName]), Product.template(name: name)] - - } - - private static func template( - name: String - ) -> Product { - return Executable(name: name, targets: [name], settings: []) } } diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index 82d88fff0c1..749a4d00e89 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -1270,11 +1270,13 @@ public final class Target { packageAccess: packageAccess, pluginCapability: capability) } +} - @available(_PackageDescription, introduced: 6.0) +public extension [Target] { + @available(_PackageDescription, introduced: 999.0.0) public static func template( name: String, - dependencies: [Dependency] = [], + dependencies: [Target.Dependency] = [], path: String? = nil, exclude: [String] = [], sources: [String]? = nil, @@ -1285,8 +1287,8 @@ public final class Target { cxxSettings: [CXXSetting]? = nil, swiftSettings: [SwiftSetting]? = nil, linkerSettings: [LinkerSetting]? = nil, - plugins: [PluginUsage]? = nil, - templateInitializationOptions: TemplateInitializationOptions, + plugins: [Target.PluginUsage]? = nil, + templateInitializationOptions: Target.TemplateInitializationOptions, ) -> [Target] { let templatePluginName = "\(name)Plugin" @@ -1344,7 +1346,7 @@ public final class Target { ) // Plugin target that depends on the template - let pluginTarget = plugin( + let pluginTarget = Target.plugin( name: templatePluginName, capability: .command( intent: .custom(verb: verb, description: description), From 87b5ee2284e33eacdeea8e80177e05d3b2a34beb Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Fri, 6 Jun 2025 15:45:04 -0400 Subject: [PATCH 159/225] Use in-process API to run experimental dump help and decode JSON --- Sources/Commands/PackageCommands/Init.swift | 209 +++++++++++++++++- .../Commands/Utilities/PluginDelegate.swift | 15 +- 2 files changed, 217 insertions(+), 7 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 31a5490c9f1..afbef6084ce 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -247,8 +247,22 @@ extension SwiftPackageCommand { print("TODO: Handle Registry template") } - - + let packageGraph = try await swiftCommandState.loadPackageGraph() + let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) + + let output = try await Self.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages[packageGraph.rootPackages.startIndex], + packageGraph: packageGraph, + //allowNetworkConnections: [.local(ports: [1200])], + arguments: ["--experimental-dump-help"], + swiftCommandState: swiftCommandState + ) + + do { + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + print("OUTPUT: \(toolInfo)") + } } else { @@ -280,6 +294,197 @@ extension SwiftPackageCommand { } } + + static func run( + plugin: ResolvedModule, + package: ResolvedPackage, + packageGraph: ModulesGraph, + allowNetworkConnections: [SandboxNetworkPermission] = [], + arguments: [String], + swiftCommandState: SwiftCommandState + ) async throws -> Data { + let pluginTarget = plugin.underlying as! PluginModule + + // The `plugins` directory is inside the workspace's main data directory, and contains all temporary files related to this plugin in the workspace. + let pluginsDir = try swiftCommandState.getActiveWorkspace().location.pluginWorkingDirectory + .appending(component: plugin.name) + + // The `cache` directory is in the plugin’s directory and is where the plugin script runner caches compiled plugin binaries and any other derived information for this plugin. + let pluginScriptRunner = try swiftCommandState.getPluginScriptRunner( + customPluginsDir: pluginsDir + ) + + // The `outputs` directory contains subdirectories for each combination of package and command plugin. Each usage of a plugin has an output directory that is writable by the plugin, where it can write additional files, and to which it can configure tools to write their outputs, etc. + let outputDir = pluginsDir.appending("outputs") + + // Determine the set of directories under which plugins are allowed to write. We always include the output directory. + var writableDirectories = [outputDir] + + // FIXME: decide whether this permission needs to be explicitly requested by the plugin target, or not + print("WRITABLE DIRECTORY: \(package.path)") + writableDirectories.append(package.path) + + var allowNetworkConnections = allowNetworkConnections + + // If the plugin requires permissions, we ask the user for approval. + if case .command(_, let permissions) = pluginTarget.capability { + try permissions.forEach { + let permissionString: String + let reasonString: String + let remedyOption: String + + switch $0 { + case .writeToPackageDirectory(let reason): + //guard !options.allowWritingToPackageDirectory else { return } // permission already granted + permissionString = "write to the package directory" + reasonString = reason + remedyOption = "--allow-writing-to-package-directory" + case .allowNetworkConnections(let scope, let reason): + guard scope != .none else { return } // no need to prompt + //guard options.allowNetworkConnections != .init(scope) else { return } // permission already granted + + switch scope { + case .all, .local: + let portsString = scope.ports + .isEmpty ? "on all ports" : + "on ports: \(scope.ports.map { "\($0)" }.joined(separator: ", "))" + permissionString = "allow \(scope.label) network connections \(portsString)" + case .docker, .unixDomainSocket: + permissionString = "allow \(scope.label) connections" + case .none: + permissionString = "" // should not be reached + } + + reasonString = reason + // FIXME compute the correct reason for the type of network connection + remedyOption = + "--allow-network-connections 'Network connection is needed'" + } + + let problem = "Plugin ‘\(plugin.name)’ wants permission to \(permissionString)." + let reason = "Stated reason: “\(reasonString)”." + if swiftCommandState.outputStream.isTTY { + // We can ask the user directly, so we do so. + let query = "Allow this plugin to \(permissionString)?" + swiftCommandState.outputStream.write("\(problem)\n\(reason)\n\(query) (yes/no) ".utf8) + swiftCommandState.outputStream.flush() + let answer = readLine(strippingNewline: true) + // Throw an error if we didn't get permission. + if answer?.lowercased() != "yes" { + throw StringError("Plugin was denied permission to \(permissionString).") + } + } else { + // We can't ask the user, so emit an error suggesting passing the flag. + let remedy = "Use `\(remedyOption)` to allow this." + throw StringError([problem, reason, remedy].joined(separator: "\n")) + } + + switch $0 { + case .writeToPackageDirectory: + // Otherwise append the directory to the list of allowed ones. + writableDirectories.append(package.path) + case .allowNetworkConnections(let scope, _): + allowNetworkConnections.append(.init(scope)) + } + } + } + + // Make sure that the package path is read-only unless it's covered by any of the explicitly writable directories. + let readOnlyDirectories = writableDirectories + .contains { package.path.isDescendantOfOrEqual(to: $0) } ? [] : [package.path] + + // Use the directory containing the compiler as an additional search directory, and add the $PATH. + let toolSearchDirs = [try swiftCommandState.getTargetToolchain().swiftCompilerPath.parentDirectory] + + getEnvSearchPaths(pathString: Environment.current[.path], currentWorkingDirectory: .none) + + let buildParameters = try swiftCommandState.toolsBuildParameters + // Build or bring up-to-date any executable host-side tools on which this plugin depends. Add them and any binary dependencies to the tool-names-to-path map. + let buildSystem = try await swiftCommandState.createBuildSystem( + explicitBuildSystem: .native, + traitConfiguration: .init(), + cacheBuildManifest: false, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: buildParameters, + packageGraphLoader: { packageGraph } + ) + + let accessibleTools = try await plugin.preparePluginTools( + fileSystem: swiftCommandState.fileSystem, + environment: buildParameters.buildEnvironment, + for: try pluginScriptRunner.hostTriple + ) { name, _ in + // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies are not supported within a package, so if the tool happens to be from the same package, we instead find the executable that corresponds to the product. There is always one, because of autogeneration of implicit executables with the same name as the target if there isn't an explicit one. + try await buildSystem.build(subset: .product(name, for: .host)) + if let builtTool = try buildSystem.buildPlan.buildProducts.first(where: { + $0.product.name == name && $0.buildParameters.destination == .host + }) { + return try builtTool.binaryPath + } else { + return nil + } + } + + // Set up a delegate to handle callbacks from the command plugin. + let pluginDelegate = PluginDelegate(swiftCommandState: swiftCommandState, plugin: pluginTarget, echoOutput: false) + let delegateQueue = DispatchQueue(label: "plugin-invocation") + + // Run the command plugin. + + // TODO: use region based isolation when swift 6 is available + let writableDirectoriesCopy = writableDirectories + let allowNetworkConnectionsCopy = allowNetworkConnections + + let buildEnvironment = buildParameters.buildEnvironment + try await pluginTarget.invoke( + action: .performCommand(package: package, arguments: arguments), + buildEnvironment: buildEnvironment, + scriptRunner: pluginScriptRunner, + workingDirectory: swiftCommandState.originalWorkingDirectory, + outputDirectory: outputDir, + toolSearchDirectories: toolSearchDirs, + accessibleTools: accessibleTools, + writableDirectories: writableDirectoriesCopy, + readOnlyDirectories: readOnlyDirectories, + allowNetworkConnections: allowNetworkConnectionsCopy, + pkgConfigDirectories: swiftCommandState.options.locations.pkgConfigDirectories, + sdkRootPath: buildParameters.toolchain.sdkRootPath, + fileSystem: swiftCommandState.fileSystem, + modulesGraph: packageGraph, + observabilityScope: swiftCommandState.observabilityScope, + callbackQueue: delegateQueue, + delegate: pluginDelegate + ) + + return pluginDelegate.lineBufferedOutput + } + + private func captureStdout(_ block: () async throws -> Void) async throws -> String { + let originalStdout = dup(fileno(stdout)) + + let pipe = Pipe() + let readHandle = pipe.fileHandleForReading + let writeHandle = pipe.fileHandleForWriting + + dup2(writeHandle.fileDescriptor, fileno(stdout)) + + + var output = "" + let outputQueue = DispatchQueue(label: "outputQueue") + let group = DispatchGroup() + group.enter() + + outputQueue.async { + let data = readHandle.readDataToEndOfFile() + output = String(data: data, encoding: .utf8) ?? "" + } + + + fflush(stdout) + writeHandle.closeFile() + + dup2(originalStdout, fileno(stdout)) + return output + } // first save current activeWorkspace //second switch activeWorkspace to the template Path //third revert after conditions have been checked, (we will also get stuff needed for dpeende diff --git a/Sources/Commands/Utilities/PluginDelegate.swift b/Sources/Commands/Utilities/PluginDelegate.swift index 715da6aad51..24117d49698 100644 --- a/Sources/Commands/Utilities/PluginDelegate.swift +++ b/Sources/Commands/Utilities/PluginDelegate.swift @@ -28,12 +28,14 @@ final class PluginDelegate: PluginInvocationDelegate { let buildSystem: BuildSystemProvider.Kind let plugin: PluginModule var lineBufferedOutput: Data + let echoOutput: Bool - init(swiftCommandState: SwiftCommandState, buildSystem: BuildSystemProvider.Kind, plugin: PluginModule) { + init(swiftCommandState: SwiftCommandState, buildSystem: BuildSystemProvider.Kind, plugin: PluginModule, echoOutput: Bool = true) { self.swiftCommandState = swiftCommandState self.buildSystem = buildSystem self.plugin = plugin self.lineBufferedOutput = Data() + self.echoOutput = echoOutput } func pluginCompilationStarted(commandLine: [String], environment: [String: String]) { @@ -47,10 +49,13 @@ final class PluginDelegate: PluginInvocationDelegate { func pluginEmittedOutput(_ data: Data) { lineBufferedOutput += data - while let newlineIdx = lineBufferedOutput.firstIndex(of: UInt8(ascii: "\n")) { - let lineData = lineBufferedOutput.prefix(upTo: newlineIdx) - print(String(decoding: lineData, as: UTF8.self)) - lineBufferedOutput = lineBufferedOutput.suffix(from: newlineIdx.advanced(by: 1)) + + if echoOutput { + while let newlineIdx = lineBufferedOutput.firstIndex(of: UInt8(ascii: "\n")) { + let lineData = lineBufferedOutput.prefix(upTo: newlineIdx) + print(String(decoding: lineData, as: UTF8.self)) + lineBufferedOutput = lineBufferedOutput.suffix(from: newlineIdx.advanced(by: 1)) + } } } From 90577899b0d2951a5dbd0e2c4c9f978511acde27 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Fri, 6 Jun 2025 12:32:10 -0400 Subject: [PATCH 160/225] Fix show-executables subcommand so that it does not show templates Fix show-templates subcommand so that it does not show snippets Make the swift-package binary development environment independent --- Sources/Commands/PackageCommands/ShowExecutables.swift | 2 ++ Sources/Commands/PackageCommands/ShowTemplates.swift | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Commands/PackageCommands/ShowExecutables.swift b/Sources/Commands/PackageCommands/ShowExecutables.swift index c1e50248b19..3195c2780cb 100644 --- a/Sources/Commands/PackageCommands/ShowExecutables.swift +++ b/Sources/Commands/PackageCommands/ShowExecutables.swift @@ -34,6 +34,8 @@ struct ShowExecutables: AsyncSwiftCommand { let executables = packageGraph.allProducts.filter({ $0.type == .executable || $0.type == .snippet + }).filter({ + $0.modules.allSatisfy( {$0.type != .template}) }).map { product -> Executable in if !rootPackages.contains(product.packageIdentity) { return Executable(package: product.packageIdentity.description, name: product.name) diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index e72298b62ac..023744c4478 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -33,7 +33,7 @@ struct ShowTemplates: AsyncSwiftCommand { let rootPackages = packageGraph.rootPackages.map { $0.identity } let templates = packageGraph.allModules.filter({ - $0.type == .template || $0.type == .snippet + $0.type == .template }).map { module -> Template in if !rootPackages.contains(module.packageIdentity) { return Template(package: module.packageIdentity.description, name: module.name) From 971b8ab19b0c3181b4105fe676f31ee213b1f14e Mon Sep 17 00:00:00 2001 From: John Bute Date: Mon, 9 Jun 2025 10:28:01 -0400 Subject: [PATCH 161/225] running the executable --- .../ShowTemplates/app/Package.swift | 6 +- .../{kooPlugin => dooPlugin}/plugin.swift | 2 +- .../app/Templates/{koo => doo}/Template.swift | 2 + Sources/Commands/PackageCommands/Init.swift | 220 ++++++++---------- Sources/PackageDescription/Product.swift | 2 +- 5 files changed, 100 insertions(+), 132 deletions(-) rename Fixtures/Miscellaneous/ShowTemplates/app/Plugins/{kooPlugin => dooPlugin}/plugin.swift (91%) rename Fixtures/Miscellaneous/ShowTemplates/app/Templates/{koo => doo}/Template.swift (97%) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift index 060e3a35029..7680cba36ec 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -3,14 +3,14 @@ import PackageDescription let package = Package( name: "Dealer", - products: .template(name: "loo"), + products: Product.template(name: "doo"), dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2") ], - targets: .template( - name: "loo", + targets: Target.template( + name: "doo", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "SystemPackage", package: "swift-system") diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/kooPlugin/plugin.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/dooPlugin/plugin.swift similarity index 91% rename from Fixtures/Miscellaneous/ShowTemplates/app/Plugins/kooPlugin/plugin.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Plugins/dooPlugin/plugin.swift index 0c66b436fc8..00950ba3014 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/kooPlugin/plugin.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/dooPlugin/plugin.swift @@ -17,7 +17,7 @@ struct TemplatePlugin: CommandPlugin { context: PluginContext, arguments: [String] ) async throws { - let tool = try context.tool(named: "koo") + let tool = try context.tool(named: "doo") let process = Process() process.executableURL = URL(filePath: tool.url.path()) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/koo/Template.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/doo/Template.swift similarity index 97% rename from Fixtures/Miscellaneous/ShowTemplates/app/Templates/koo/Template.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Templates/doo/Template.swift index e310f9a1e3d..f5dec1c76bc 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/koo/Template.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/doo/Template.swift @@ -27,6 +27,8 @@ struct HelloTemplateTool: ParsableCommand { //entrypoint of the template executable, that generates just a main.swift and a readme.md func run() throws { + + print("we got here") let fs = FileManager.default let rootDir = FilePath(fs.currentDirectoryPath) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index afbef6084ce..e86df2e1776 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -105,6 +105,30 @@ extension SwiftPackageCommand { // prompt user // run the executable with the command line stuff + /// Returns the resolved template path for a given template source. + func resolveTemplatePath() async throws -> Basics.AbsolutePath { + switch templateType { + case .local: + guard let path = templateDirectory else { + throw InternalError("Template path must be specified for local templates.") + } + return path + + case .git: + // TODO: Cache logic and smarter hashing + throw StringError("git-based templates not yet implemented") + + case .registry: + // TODO: Lookup and download from registry + throw StringError("Registry-based templates not yet implemented") + + case .none: + throw InternalError("Missing --template-type for --template") + } + } + + + //first, func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { @@ -117,151 +141,87 @@ extension SwiftPackageCommand { // For macros this is reversed, since we don't support testing // macros with Swift Testing yet. if useTemplates { - guard let type = templateType else { - throw InternalError("Template path must be specified when using the local template type.") - } - - switch type { - case .local: - - guard let templatePath = templateDirectory else { - throw InternalError("Template path must be specified when using the local template type.") - } + let resolvedTemplatePath = try await resolveTemplatePath() - /// Get the package initialization type based on templateInitializationOptions and check for if the template called is valid. - let templateInitType = try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { workspace, root in - return try await checkConditions(swiftCommandState) - } + let templateInitType = try await swiftCommandState.withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { workspace, root in + return try await checkConditions(swiftCommandState) + } - var supportedTemplateTestingLibraries = Set() - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (templateInitType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { - supportedTemplateTestingLibraries.insert(.xctest) - } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (templateInitType != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { - supportedTemplateTestingLibraries.insert(.swiftTesting) - } + var supportedTemplateTestingLibraries = Set() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (templateInitType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTemplateTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (templateInitType != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTemplateTestingLibraries.insert(.swiftTesting) + } - let initTemplatePackage = try InitTemplatePackage( - name: packageName, - templateName: template, - initMode: type, - templatePath: templatePath, - fileSystem: swiftCommandState.fileSystem, - packageType: templateInitType, - supportedTestingLibraries: supportedTemplateTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, + let initTemplatePackage = try InitTemplatePackage( + name: packageName, + templateName: template, + initMode: templateType ?? .local, + templatePath: resolvedTemplatePath, + fileSystem: swiftCommandState.fileSystem, + packageType: templateInitType, + supportedTestingLibraries: supportedTemplateTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration + ) + try initTemplatePackage.setupTemplateManifest() + + // Build system setup + let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + try await swiftCommandState.createBuildSystem( + explicitProduct: buildOptions.product, + traitConfiguration: .init(traitOptions: buildOptions.traits), + shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: swiftCommandState.toolsBuildParameters, + outputStream: TSCBasic.stdoutStream ) - try initTemplatePackage.setupTemplateManifest() - - let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { workspace, root in - - try await swiftCommandState.createBuildSystem( - explicitProduct: buildOptions.product, - traitConfiguration: .init(traitOptions: buildOptions.traits), - shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: swiftCommandState.toolsBuildParameters, - // command result output goes on stdout - // ie "swift build" should output to stdout - outputStream: TSCBasic.stdoutStream - ) + } - } + guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { + throw ExitCode.failure + } - guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { + try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + do { + try await buildSystem.build(subset: subset) + } catch _ as Diagnostics { throw ExitCode.failure } - - let _ = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { workspace, root in - do { - try await buildSystem.build(subset: subset) - } catch _ as Diagnostics { - throw ExitCode.failure - } - } - - /* - let parsedOptions = try PluginCommand.PluginOptions.parse(["--allow-writing-to-package-directory"]) - - - try await PluginCommand.run(command: template, options: parsedOptions, arguments: ["--","--experimental-dump-help"], swiftCommandState: swiftCommandState) - - */ - - //will need to revisit this - let arguments = [ - "/Users/johnbute/Desktop/swift-pm-template/.build/arm64-apple-macosx/debug/swift-package", "plugin", template, "--allow-network-connections","local:1200", - "--", "--experimental-dump-help" - ] - let process = AsyncProcess(arguments: arguments) - - try process.launch() - - let processResult = try await process.waitUntilExit() - - - guard processResult.exitStatus == .terminated(code: 0) else { - throw try StringError(processResult.utf8stderrOutput()) - } - - - switch processResult.output { - case .success(let outputBytes): - let outputString = String(decoding: outputBytes, as: UTF8.self) - - - guard let data = outputString.data(using: .utf8) else { - fatalError("Could not convert output string to Data") - } - - do { - let schema = try JSONDecoder().decode(ToolInfoV0.self, from: data) - let response = try initTemplatePackage.promptUser(tool: schema) - - - let parsedOptions = try PluginCommand.PluginOptions.parse(["--allow-writing-to-package-directory"]) - - - try await PluginCommand.run(command: template, options: parsedOptions, arguments: response, swiftCommandState: swiftCommandState) - - } catch { - print(error) - } - - case .failure(let error): - print("Failed to get output:", error) - } - - - - - case .git: - // Implement or call your Git-based template handler - print("TODO: Handle Git template") - case .registry: - // Implement or call your Registry-based template handler - print("TODO: Handle Registry template") } let packageGraph = try await swiftCommandState.loadPackageGraph() let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) + + let output = try await Self.run( plugin: matchingPlugins[0], - package: packageGraph.rootPackages[packageGraph.rootPackages.startIndex], + package: packageGraph.rootPackages.first!, packageGraph: packageGraph, - //allowNetworkConnections: [.local(ports: [1200])], - arguments: ["--experimental-dump-help"], + arguments: ["--", "--experimental-dump-help"], swiftCommandState: swiftCommandState ) - + + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + let response = try initTemplatePackage.promptUser(tool: toolInfo) do { - let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - print("OUTPUT: \(toolInfo)") + + let _ = try await Self.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: response, + swiftCommandState: swiftCommandState, + shouldPrint: true + ) + + } } else { @@ -321,7 +281,6 @@ extension SwiftPackageCommand { var writableDirectories = [outputDir] // FIXME: decide whether this permission needs to be explicitly requested by the plugin target, or not - print("WRITABLE DIRECTORY: \(package.path)") writableDirectories.append(package.path) var allowNetworkConnections = allowNetworkConnections @@ -434,12 +393,19 @@ extension SwiftPackageCommand { let writableDirectoriesCopy = writableDirectories let allowNetworkConnectionsCopy = allowNetworkConnections + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") + } + + guard let workingDirectory = swiftCommandState.options.locations.packageDirectory ?? swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find current working directory") + } let buildEnvironment = buildParameters.buildEnvironment try await pluginTarget.invoke( action: .performCommand(package: package, arguments: arguments), buildEnvironment: buildEnvironment, scriptRunner: pluginScriptRunner, - workingDirectory: swiftCommandState.originalWorkingDirectory, + workingDirectory: workingDirectory, outputDirectory: outputDir, toolSearchDirectories: toolSearchDirs, accessibleTools: accessibleTools, diff --git a/Sources/PackageDescription/Product.swift b/Sources/PackageDescription/Product.swift index 1df7491021f..05df231980e 100644 --- a/Sources/PackageDescription/Product.swift +++ b/Sources/PackageDescription/Product.swift @@ -207,7 +207,7 @@ public class Product { public extension [Product] { @available(_PackageDescription, introduced: 999.0.0) - public static func template( + static func template( name: String, ) -> [Product] { let templatePluginName = "\(name)Plugin" From dac93fabc096be2ff5860c290d75a977fbf14c3a Mon Sep 17 00:00:00 2001 From: John Bute Date: Mon, 9 Jun 2025 13:07:15 -0400 Subject: [PATCH 162/225] fixed syntax issues + added functionality for arguments with enums --- Sources/Commands/PackageCommands/Init.swift | 3 +-- Sources/Workspace/InitTemplatePackage.swift | 13 +++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index e86df2e1776..e662d302138 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -217,8 +217,7 @@ extension SwiftPackageCommand { package: packageGraph.rootPackages.first!, packageGraph: packageGraph, arguments: response, - swiftCommandState: swiftCommandState, - shouldPrint: true + swiftCommandState: swiftCommandState ) diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 8d83ec84ff8..2a8a4a50ba1 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -170,10 +170,11 @@ public final class InitTemplatePackage { static func prompt(for arguments: [ArgumentInfoV0]) -> [ArgumentResponse] { return arguments - .filter { $0.valueName != "help" } + .filter { $0.valueName != "help" && $0.shouldDisplay != false } .map { arg in let defaultText = arg.defaultValue.map { " (default: \($0))" } ?? "" - let promptMessage = "\(arg.abstract ?? "")\(defaultText):" + let allValuesText = (arg.allValues?.isEmpty == false) ? " [\(arg.allValues!.joined(separator: ", "))]" : "" + let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(defaultText):" var values: [String] = [] @@ -188,6 +189,10 @@ public final class InitTemplatePackage { if arg.isRepeating { while let input = readLine(), !input.isEmpty { + if let allowed = arg.allValues, !allowed.contains(input) { + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + continue + } values.append(input) } if values.isEmpty, let def = arg.defaultValue { @@ -196,6 +201,10 @@ public final class InitTemplatePackage { } else { let input = readLine() if let input = input, !input.isEmpty { + if let allowed = arg.allValues, !allowed.contains(input) { + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + exit(1) + } values = [input] } else if let def = arg.defaultValue { values = [def] From e9bcb68dea60ab3ce426aa663bae1d827664de68 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 10 Jun 2025 11:29:56 -0400 Subject: [PATCH 163/225] formatting + starting to add git capabilities --- Sources/Commands/PackageCommands/Init.swift | 411 ++++++++++++++------ 1 file changed, 282 insertions(+), 129 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index e662d302138..463456611de 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -26,6 +26,7 @@ import PackageGraph import SPMBuildCore import XCBuildSupport import TSCBasic +import SourceControl import ArgumentParserToolInfo @@ -76,6 +77,9 @@ extension SwiftPackageCommand { @Option(name: .customLong("template-path"), help: "Path to the local template.", completion: .directory) var templateDirectory: Basics.AbsolutePath? + @Option(name: .customLong("template-url"), help: "The git URL of the template.") + var templateURL: String? + // Git-specific options @Option(help: "The exact package version to depend on.") var exact: Version? @@ -115,8 +119,62 @@ extension SwiftPackageCommand { return path case .git: - // TODO: Cache logic and smarter hashing - throw StringError("git-based templates not yet implemented") + + var requirements : [PackageDependency.SourceControl.Requirement] = [] + + if let exact { + requirements.append(.exact(exact)) + } + + if let branch { + requirements.append(.branch(branch)) + } + + if let revision { + requirements.append(.revision(revision)) + } + + if let from { + requirements.append(.range(.upToNextMajor(from: from))) + } + + if let upToNextMinorFrom { + requirements.append(.range(.upToNextMinor(from: upToNextMinorFrom))) + } + + if requirements.count > 1 { + throw StringError( + "must specify at most one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" + ) + } + + guard let firstRequirement = requirements.first else { + throw StringError( + "must specify one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" + ) + } + + let requirement: PackageDependency.SourceControl.Requirement + if case .range(let range) = firstRequirement { + if let to { + requirement = .range(range.lowerBound ..< to) + } else { + requirement = .range(range) + } + } else { + requirement = firstRequirement + + if self.to != nil { + throw StringError("--to can only be specified with --from or --up-to-next-minor-from") + } + } + + if let templateURL = templateURL{ + print(try await getPackageFromGit(destination: templateURL, requirement: requirement)) + throw StringError("did not specify template URL") + } else { + throw StringError("did not specify template URL") + } case .registry: // TODO: Lookup and download from registry @@ -199,7 +257,7 @@ extension SwiftPackageCommand { let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) - + let output = try await Self.run( plugin: matchingPlugins[0], package: packageGraph.rootPackages.first!, @@ -253,133 +311,228 @@ extension SwiftPackageCommand { } } + func getPackageFromGit(destination: String, requirement: PackageDependency.SourceControl.Requirement) async throws -> Basics.AbsolutePath { + let repositoryProvider = GitRepositoryProvider() - static func run( - plugin: ResolvedModule, - package: ResolvedPackage, - packageGraph: ModulesGraph, - allowNetworkConnections: [SandboxNetworkPermission] = [], - arguments: [String], - swiftCommandState: SwiftCommandState - ) async throws -> Data { - let pluginTarget = plugin.underlying as! PluginModule + let fetchStandalonePackageByURL = {() async throws -> Basics.AbsolutePath in + let url = SourceControlURL(destination) + return try withTemporaryDirectory(removeTreeOnDeinit: true) { (tempDir: Basics.AbsolutePath) in + let tempPath = tempDir.appending(component: url.lastPathComponent) + let repositorySpecifier = RepositorySpecifier(url: url) - // The `plugins` directory is inside the workspace's main data directory, and contains all temporary files related to this plugin in the workspace. - let pluginsDir = try swiftCommandState.getActiveWorkspace().location.pluginWorkingDirectory - .appending(component: plugin.name) + try repositoryProvider.fetch( + repository: repositorySpecifier, + to: tempPath, + progressHandler: nil + ) - // The `cache` directory is in the plugin’s directory and is where the plugin script runner caches compiled plugin binaries and any other derived information for this plugin. - let pluginScriptRunner = try swiftCommandState.getPluginScriptRunner( - customPluginsDir: pluginsDir - ) + guard try repositoryProvider.isValidDirectory(tempPath), + let repository = repositoryProvider.open( + repository: repositorySpecifier, + at: tempPath + ) as? GitRepository else { + throw InternalError("Invalid directory at \(tempPath)") + } - // The `outputs` directory contains subdirectories for each combination of package and command plugin. Each usage of a plugin has an output directory that is writable by the plugin, where it can write additional files, and to which it can configure tools to write their outputs, etc. - let outputDir = pluginsDir.appending("outputs") - - // Determine the set of directories under which plugins are allowed to write. We always include the output directory. - var writableDirectories = [outputDir] - - // FIXME: decide whether this permission needs to be explicitly requested by the plugin target, or not - writableDirectories.append(package.path) - - var allowNetworkConnections = allowNetworkConnections - - // If the plugin requires permissions, we ask the user for approval. - if case .command(_, let permissions) = pluginTarget.capability { - try permissions.forEach { - let permissionString: String - let reasonString: String - let remedyOption: String - - switch $0 { - case .writeToPackageDirectory(let reason): - //guard !options.allowWritingToPackageDirectory else { return } // permission already granted - permissionString = "write to the package directory" - reasonString = reason - remedyOption = "--allow-writing-to-package-directory" - case .allowNetworkConnections(let scope, let reason): - guard scope != .none else { return } // no need to prompt - //guard options.allowNetworkConnections != .init(scope) else { return } // permission already granted - - switch scope { - case .all, .local: - let portsString = scope.ports - .isEmpty ? "on all ports" : - "on ports: \(scope.ports.map { "\($0)" }.joined(separator: ", "))" - permissionString = "allow \(scope.label) network connections \(portsString)" - case .docker, .unixDomainSocket: - permissionString = "allow \(scope.label) connections" - case .none: - permissionString = "" // should not be reached + // If requirement is a range, find the latest tag that matches + switch requirement { + case .range(let versionRange): + let tags = try repository.getTags() + let versions = tags.compactMap { Version($0) } + // Filter versions within range + let filteredVersions = versions.filter { versionRange.contains($0) } + guard let latestVersion = filteredVersions.max() else { + throw InternalError("No tags found within the specified version range \(versionRange)") + } + // Checkout the latest version tag + try repository.checkout(tag: latestVersion.description) + + case .exact(let exactVersion): + try repository.checkout(tag: exactVersion.description) + + case .branch(let branchName): + + throw InternalError("No branch option available for fetching a single commit") + case .revision(let revision): + try repository.checkout(revision: .init(identifier: revision)) } - reasonString = reason - // FIXME compute the correct reason for the type of network connection - remedyOption = - "--allow-network-connections 'Network connection is needed'" + + // Return the absolute path of the fetched git repository + return tempPath } + } + + return try await fetchStandalonePackageByURL() + } + + static func run( + plugin: ResolvedModule, + package: ResolvedPackage, + packageGraph: ModulesGraph, + allowNetworkConnections: [SandboxNetworkPermission] = [], + arguments: [String], + swiftCommandState: SwiftCommandState + ) async throws -> Data { + let pluginTarget = plugin.underlying as! PluginModule + + // The `plugins` directory is inside the workspace's main data directory, and contains all temporary files related to this plugin in the workspace. + let pluginsDir = try swiftCommandState.getActiveWorkspace().location.pluginWorkingDirectory + .appending(component: plugin.name) + + // The `cache` directory is in the plugin’s directory and is where the plugin script runner caches compiled plugin binaries and any other derived information for this plugin. + let pluginScriptRunner = try swiftCommandState.getPluginScriptRunner( + customPluginsDir: pluginsDir + ) - let problem = "Plugin ‘\(plugin.name)’ wants permission to \(permissionString)." - let reason = "Stated reason: “\(reasonString)”." - if swiftCommandState.outputStream.isTTY { - // We can ask the user directly, so we do so. - let query = "Allow this plugin to \(permissionString)?" - swiftCommandState.outputStream.write("\(problem)\n\(reason)\n\(query) (yes/no) ".utf8) - swiftCommandState.outputStream.flush() - let answer = readLine(strippingNewline: true) - // Throw an error if we didn't get permission. - if answer?.lowercased() != "yes" { - throw StringError("Plugin was denied permission to \(permissionString).") + // The `outputs` directory contains subdirectories for each combination of package and command plugin. Each usage of a plugin has an output directory that is writable by the plugin, where it can write additional files, and to which it can configure tools to write their outputs, etc. + let outputDir = pluginsDir.appending("outputs") + + // Determine the set of directories under which plugins are allowed to write. We always include the output directory. + var writableDirectories = [outputDir] + + // FIXME: decide whether this permission needs to be explicitly requested by the plugin target, or not + writableDirectories.append(package.path) + + var allowNetworkConnections = allowNetworkConnections + + // If the plugin requires permissions, we ask the user for approval. + if case .command(_, let permissions) = pluginTarget.capability { + try permissions.forEach { + let permissionString: String + let reasonString: String + let remedyOption: String + + switch $0 { + case .writeToPackageDirectory(let reason): + //guard !options.allowWritingToPackageDirectory else { return } // permission already granted + permissionString = "write to the package directory" + reasonString = reason + remedyOption = "--allow-writing-to-package-directory" + case .allowNetworkConnections(let scope, let reason): + guard scope != .none else { return } // no need to prompt + //guard options.allowNetworkConnections != .init(scope) else { return } // permission already granted + + switch scope { + case .all, .local: + let portsString = scope.ports + .isEmpty ? "on all ports" : + "on ports: \(scope.ports.map { "\($0)" }.joined(separator: ", "))" + permissionString = "allow \(scope.label) network connections \(portsString)" + case .docker, .unixDomainSocket: + permissionString = "allow \(scope.label) connections" + case .none: + permissionString = "" // should not be reached + } + + reasonString = reason + // FIXME compute the correct reason for the type of network connection + remedyOption = + "--allow-network-connections 'Network connection is needed'" + } + + let problem = "Plugin ‘\(plugin.name)’ wants permission to \(permissionString)." + let reason = "Stated reason: “\(reasonString)”." + if swiftCommandState.outputStream.isTTY { + // We can ask the user directly, so we do so. + let query = "Allow this plugin to \(permissionString)?" + swiftCommandState.outputStream.write("\(problem)\n\(reason)\n\(query) (yes/no) ".utf8) + swiftCommandState.outputStream.flush() + let answer = readLine(strippingNewline: true) + // Throw an error if we didn't get permission. + if answer?.lowercased() != "yes" { + throw StringError("Plugin was denied permission to \(permissionString).") + } + } else { + // We can't ask the user, so emit an error suggesting passing the flag. + let remedy = "Use `\(remedyOption)` to allow this." + throw StringError([problem, reason, remedy].joined(separator: "\n")) } - } else { - // We can't ask the user, so emit an error suggesting passing the flag. - let remedy = "Use `\(remedyOption)` to allow this." - throw StringError([problem, reason, remedy].joined(separator: "\n")) - } - switch $0 { - case .writeToPackageDirectory: - // Otherwise append the directory to the list of allowed ones. - writableDirectories.append(package.path) - case .allowNetworkConnections(let scope, _): - allowNetworkConnections.append(.init(scope)) + switch $0 { + case .writeToPackageDirectory: + // Otherwise append the directory to the list of allowed ones. + writableDirectories.append(package.path) + case .allowNetworkConnections(let scope, _): + allowNetworkConnections.append(.init(scope)) + } } } - } - // Make sure that the package path is read-only unless it's covered by any of the explicitly writable directories. - let readOnlyDirectories = writableDirectories - .contains { package.path.isDescendantOfOrEqual(to: $0) } ? [] : [package.path] + // Make sure that the package path is read-only unless it's covered by any of the explicitly writable directories. + let readOnlyDirectories = writableDirectories + .contains { package.path.isDescendantOfOrEqual(to: $0) } ? [] : [package.path] - // Use the directory containing the compiler as an additional search directory, and add the $PATH. - let toolSearchDirs = [try swiftCommandState.getTargetToolchain().swiftCompilerPath.parentDirectory] + // Use the directory containing the compiler as an additional search directory, and add the $PATH. + let toolSearchDirs = [try swiftCommandState.getTargetToolchain().swiftCompilerPath.parentDirectory] + getEnvSearchPaths(pathString: Environment.current[.path], currentWorkingDirectory: .none) - let buildParameters = try swiftCommandState.toolsBuildParameters - // Build or bring up-to-date any executable host-side tools on which this plugin depends. Add them and any binary dependencies to the tool-names-to-path map. - let buildSystem = try await swiftCommandState.createBuildSystem( - explicitBuildSystem: .native, - traitConfiguration: .init(), - cacheBuildManifest: false, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: buildParameters, - packageGraphLoader: { packageGraph } - ) + let buildParameters = try swiftCommandState.toolsBuildParameters + // Build or bring up-to-date any executable host-side tools on which this plugin depends. Add them and any binary dependencies to the tool-names-to-path map. + let buildSystem = try await swiftCommandState.createBuildSystem( + explicitBuildSystem: .native, + traitConfiguration: .init(), + cacheBuildManifest: false, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: buildParameters, + packageGraphLoader: { packageGraph } + ) - let accessibleTools = try await plugin.preparePluginTools( - fileSystem: swiftCommandState.fileSystem, - environment: buildParameters.buildEnvironment, - for: try pluginScriptRunner.hostTriple - ) { name, _ in - // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies are not supported within a package, so if the tool happens to be from the same package, we instead find the executable that corresponds to the product. There is always one, because of autogeneration of implicit executables with the same name as the target if there isn't an explicit one. - try await buildSystem.build(subset: .product(name, for: .host)) - if let builtTool = try buildSystem.buildPlan.buildProducts.first(where: { - $0.product.name == name && $0.buildParameters.destination == .host - }) { - return try builtTool.binaryPath - } else { - return nil + let accessibleTools = try await plugin.preparePluginTools( + fileSystem: swiftCommandState.fileSystem, + environment: buildParameters.buildEnvironment, + for: try pluginScriptRunner.hostTriple + ) { name, _ in + // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies are not supported within a package, so if the tool happens to be from the same package, we instead find the executable that corresponds to the product. There is always one, because of autogeneration of implicit executables with the same name as the target if there isn't an explicit one. + try await buildSystem.build(subset: .product(name, for: .host)) + if let builtTool = try buildSystem.buildPlan.buildProducts.first(where: { + $0.product.name == name && $0.buildParameters.destination == .host + }) { + return try builtTool.binaryPath + } else { + return nil + } + } + + // Set up a delegate to handle callbacks from the command plugin. + let pluginDelegate = PluginDelegate(swiftCommandState: swiftCommandState, plugin: pluginTarget, echoOutput: false) + let delegateQueue = DispatchQueue(label: "plugin-invocation") + + // Run the command plugin. + + // TODO: use region based isolation when swift 6 is available + let writableDirectoriesCopy = writableDirectories + let allowNetworkConnectionsCopy = allowNetworkConnections + + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") } + + guard let workingDirectory = swiftCommandState.options.locations.packageDirectory ?? swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find current working directory") + } + let buildEnvironment = buildParameters.buildEnvironment + try await pluginTarget.invoke( + action: .performCommand(package: package, arguments: arguments), + buildEnvironment: buildEnvironment, + scriptRunner: pluginScriptRunner, + workingDirectory: workingDirectory, + outputDirectory: outputDir, + toolSearchDirectories: toolSearchDirs, + accessibleTools: accessibleTools, + writableDirectories: writableDirectoriesCopy, + readOnlyDirectories: readOnlyDirectories, + allowNetworkConnections: allowNetworkConnectionsCopy, + pkgConfigDirectories: swiftCommandState.options.locations.pkgConfigDirectories, + sdkRootPath: buildParameters.toolchain.sdkRootPath, + fileSystem: swiftCommandState.fileSystem, + modulesGraph: packageGraph, + observabilityScope: swiftCommandState.observabilityScope, + callbackQueue: delegateQueue, + delegate: pluginDelegate + ) + + return pluginDelegate.lineBufferedOutput } // Set up a delegate to handle callbacks from the command plugin. @@ -489,23 +642,23 @@ extension SwiftPackageCommand { } extension InitPackage.PackageType: ExpressibleByArgument { - init(from templateType: TargetDescription.TemplateType) throws { - switch templateType { - case .executable: - self = .executable - case .library: - self = .library - case .tool: - self = .tool - case .macro: - self = .macro - case .buildToolPlugin: - self = .buildToolPlugin - case .commandPlugin: - self = .commandPlugin - case .empty: - self = .empty - } + init(from templateType: TargetDescription.TemplateType) throws { + switch templateType { + case .executable: + self = .executable + case .library: + self = .library + case .tool: + self = .tool + case .macro: + self = .macro + case .buildToolPlugin: + self = .buildToolPlugin + case .commandPlugin: + self = .commandPlugin + case .empty: + self = .empty + } } } From 3ffbb688fe29f8b5b0004b11a3645256c80c227b Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 11 Jun 2025 09:04:28 -0400 Subject: [PATCH 164/225] git support + dependency mapping based on type --- Sources/Commands/PackageCommands/Init.swift | 212 +++++++++++--------- Sources/SourceControl/GitRepository.swift | 51 +++++ Sources/Workspace/InitTemplatePackage.swift | 10 +- 3 files changed, 177 insertions(+), 96 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 463456611de..04f5046572d 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -120,58 +120,9 @@ extension SwiftPackageCommand { case .git: - var requirements : [PackageDependency.SourceControl.Requirement] = [] - - if let exact { - requirements.append(.exact(exact)) - } - - if let branch { - requirements.append(.branch(branch)) - } - - if let revision { - requirements.append(.revision(revision)) - } - - if let from { - requirements.append(.range(.upToNextMajor(from: from))) - } - - if let upToNextMinorFrom { - requirements.append(.range(.upToNextMinor(from: upToNextMinorFrom))) - } - - if requirements.count > 1 { - throw StringError( - "must specify at most one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" - ) - } - - guard let firstRequirement = requirements.first else { - throw StringError( - "must specify one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" - ) - } - - let requirement: PackageDependency.SourceControl.Requirement - if case .range(let range) = firstRequirement { - if let to { - requirement = .range(range.lowerBound ..< to) - } else { - requirement = .range(range) - } - } else { - requirement = firstRequirement - - if self.to != nil { - throw StringError("--to can only be specified with --from or --up-to-next-minor-from") - } - } - + let requirement = try checkRequirements() if let templateURL = templateURL{ - print(try await getPackageFromGit(destination: templateURL, requirement: requirement)) - throw StringError("did not specify template URL") + return try await getPackageFromGit(destination: templateURL, requirement: requirement) } else { throw StringError("did not specify template URL") } @@ -205,6 +156,10 @@ extension SwiftPackageCommand { return try await checkConditions(swiftCommandState) } + if templateType == .git { + try FileManager.default.removeItem(at: resolvedTemplatePath.asURL) + } + var supportedTemplateTestingLibraries = Set() if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || (templateInitType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { @@ -215,10 +170,25 @@ extension SwiftPackageCommand { supportedTemplateTestingLibraries.insert(.swiftTesting) } + func packageDependency() throws -> MappablePackageDependency.Kind { + switch templateType { + case .local: + return .fileSystem(name: packageName, path: resolvedTemplatePath.asURL.path) + + case .git: + guard let url = templateURL else { + throw StringError("Missing Git url") + } + return try .sourceControl(name: packageName, location: url, requirement: checkRequirements()) + + default: + throw StringError("Not implemented yet") + } + } let initTemplatePackage = try InitTemplatePackage( name: packageName, templateName: template, - initMode: templateType ?? .local, + initMode: packageDependency(), templatePath: resolvedTemplatePath, fileSystem: swiftCommandState.fileSystem, packageType: templateInitType, @@ -311,61 +281,121 @@ extension SwiftPackageCommand { } } - func getPackageFromGit(destination: String, requirement: PackageDependency.SourceControl.Requirement) async throws -> Basics.AbsolutePath { + func checkRequirements() throws -> PackageDependency.SourceControl.Requirement { + var requirements : [PackageDependency.SourceControl.Requirement] = [] + + if let exact { + requirements.append(.exact(exact)) + } + + if let branch { + requirements.append(.branch(branch)) + } + + if let revision { + requirements.append(.revision(revision)) + } + + if let from { + requirements.append(.range(.upToNextMajor(from: from))) + } + + if let upToNextMinorFrom { + requirements.append(.range(.upToNextMinor(from: upToNextMinorFrom))) + } + + if requirements.count > 1 { + throw StringError( + "must specify at most one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" + ) + } + + guard let firstRequirement = requirements.first else { + throw StringError( + "must specify one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" + ) + } + + let requirement: PackageDependency.SourceControl.Requirement + if case .range(let range) = firstRequirement { + if let to { + requirement = .range(range.lowerBound ..< to) + } else { + requirement = .range(range) + } + } else { + requirement = firstRequirement + + if self.to != nil { + throw StringError("--to can only be specified with --from or --up-to-next-minor-from") + } + } + return requirement + + } + func getPackageFromGit( + destination: String, + requirement: PackageDependency.SourceControl.Requirement + ) async throws -> Basics.AbsolutePath { let repositoryProvider = GitRepositoryProvider() - let fetchStandalonePackageByURL = {() async throws -> Basics.AbsolutePath in - let url = SourceControlURL(destination) - return try withTemporaryDirectory(removeTreeOnDeinit: true) { (tempDir: Basics.AbsolutePath) in - let tempPath = tempDir.appending(component: url.lastPathComponent) + let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in + try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in + let url = SourceControlURL(destination) let repositorySpecifier = RepositorySpecifier(url: url) - try repositoryProvider.fetch( - repository: repositorySpecifier, - to: tempPath, - progressHandler: nil - ) + // This is the working clone destination + let bareCopyPath = tempDir.appending(component: "bare-copy") - guard try repositoryProvider.isValidDirectory(tempPath), - let repository = repositoryProvider.open( - repository: repositorySpecifier, - at: tempPath - ) as? GitRepository else { - throw InternalError("Invalid directory at \(tempPath)") - } + let workingCopyPath = tempDir.appending(component: "working-copy") - // If requirement is a range, find the latest tag that matches - switch requirement { - case .range(let versionRange): - let tags = try repository.getTags() - let versions = tags.compactMap { Version($0) } - // Filter versions within range - let filteredVersions = versions.filter { versionRange.contains($0) } - guard let latestVersion = filteredVersions.max() else { - throw InternalError("No tags found within the specified version range \(versionRange)") - } - // Checkout the latest version tag - try repository.checkout(tag: latestVersion.description) + try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) - case .exact(let exactVersion): - try repository.checkout(tag: exactVersion.description) + try FileManager.default.createDirectory(atPath: workingCopyPath.pathString, withIntermediateDirectories: true) - case .branch(let branchName): + // Validate directory (now should exist) + guard try repositoryProvider.isValidDirectory(bareCopyPath) else { + throw InternalError("Invalid directory at \(workingCopyPath)") + } - throw InternalError("No branch option available for fetching a single commit") - case .revision(let revision): - try repository.checkout(revision: .init(identifier: revision)) - } - // Return the absolute path of the fetched git repository - return tempPath - } + try repositoryProvider.createWorkingCopyFromBare(repository: repositorySpecifier, sourcePath: bareCopyPath, at: workingCopyPath, editable: true) + + + try FileManager.default.removeItem(at: bareCopyPath.asURL) + + return workingCopyPath + + // checkout according to requirement + /*switch requirement { + case .range(let versionRange): + let tags = try repository.getTags() + let versions = tags.compactMap { Version($0) } + let filteredVersions = versions.filter { versionRange.contains($0) } + guard let latestVersion = filteredVersions.max() else { + throw InternalError("No tags found within the specified version range \(versionRange)") + } + try repository.checkout(tag: latestVersion.description) + + case .exact(let exactVersion): + try repository.checkout(tag: exactVersion.description) + + case .branch(let branchName): + throw InternalError("No branch option available for fetching a single commit") + + case .revision(let revision): + try repository.checkout(revision: .init(identifier: revision)) + } + + */ + } } return try await fetchStandalonePackageByURL() } + static func run( plugin: ResolvedModule, package: ResolvedPackage, diff --git a/Sources/SourceControl/GitRepository.swift b/Sources/SourceControl/GitRepository.swift index 6dbdf063275..f4965767f55 100644 --- a/Sources/SourceControl/GitRepository.swift +++ b/Sources/SourceControl/GitRepository.swift @@ -154,6 +154,8 @@ public struct GitRepositoryProvider: RepositoryProvider, Cancellable { ) } + + private func clone( _ repository: RepositorySpecifier, _ origin: String, @@ -224,6 +226,55 @@ public struct GitRepositoryProvider: RepositoryProvider, Cancellable { } } + public func createWorkingCopyFromBare( + repository: RepositorySpecifier, + sourcePath: Basics.AbsolutePath, + at destinationPath: Basics.AbsolutePath, + editable: Bool + ) throws -> WorkingCheckout { + + if editable { + // For editable clones, i.e. the user is expected to directly work on them, first we create + // a clone from our cache of repositories and then we replace the remote to the one originally + // present in the bare repository. + + try self.clone( + repository, + sourcePath.pathString, + destinationPath.pathString, + [] + ) + + // The default name of the remote. + let origin = "origin" + // In destination repo remove the remote which will be pointing to the source repo. + let clone = GitRepository(git: self.git, path: destinationPath) + // Set the original remote to the new clone. + try clone.setURL(remote: origin, url: repository.location.gitURL) + // FIXME: This is unfortunate that we have to fetch to update remote's data. + try clone.fetch() + } else { + // Clone using a shared object store with the canonical copy. + // + // We currently expect using shared storage here to be safe because we + // only ever expect to attempt to use the working copy to materialize a + // revision we selected in response to dependency resolution, and if we + // re-resolve such that the objects in this repository changed, we would + // only ever expect to get back a revision that remains present in the + // object storage. + + try self.clone( + repository, + sourcePath.pathString, + destinationPath.pathString, + ["--shared"] + ) + } + return try self.openWorkingCopy(at: destinationPath) + + } + + public func createWorkingCopy( repository: RepositorySpecifier, sourcePath: Basics.AbsolutePath, diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 2a8a4a50ba1..71675eef6cc 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -22,7 +22,7 @@ import ArgumentParserToolInfo public final class InitTemplatePackage { - var initMode: TemplateType + let packageDependency: MappablePackageDependency.Kind public var supportedTestingLibraries: Set @@ -85,7 +85,7 @@ public final class InitTemplatePackage { public init( name: String, templateName: String, - initMode: TemplateType, + initMode: MappablePackageDependency.Kind, templatePath: Basics.AbsolutePath, fileSystem: FileSystem, packageType: InitPackage.PackageType, @@ -94,7 +94,7 @@ public final class InitTemplatePackage { installedSwiftPMConfiguration: InstalledSwiftPMConfiguration, ) { self.packageName = name - self.initMode = initMode + self.packageDependency = initMode self.templatePath = templatePath self.packageType = packageType self.supportedTestingLibraries = supportedTestingLibraries @@ -130,7 +130,7 @@ public final class InitTemplatePackage { do { manifestContents = try fileSystem.readFileContents(manifestPath) } catch { - throw StringError("Cannot fin package manifest in \(manifestPath)") + throw StringError("Cannot find package manifest in \(manifestPath)") } let manifestSyntax = manifestContents.withData { data in @@ -142,7 +142,7 @@ public final class InitTemplatePackage { } let editResult = try AddPackageDependency.addPackageDependency( - .fileSystem(name: nil, path: self.templatePath.pathString), to: manifestSyntax) + packageDependency, to: manifestSyntax) try editResult.applyEdits(to: fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: false) } From 7f5822deef4aa281bc053ed4412cd081809c6b50 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Tue, 10 Jun 2025 11:24:17 -0400 Subject: [PATCH 165/225] Remove template target and product types and use the template init options instead --- .../ProductBuildDescription.swift | 6 +- .../SwiftModuleBuildDescription.swift | 4 +- .../LLBuildManifestBuilder+Clang.swift | 2 +- .../LLBuildManifestBuilder+Product.swift | 7 +- .../LLBuildManifestBuilder+Swift.swift | 6 +- Sources/Build/BuildOperation.swift | 2 +- .../Build/BuildPlan/BuildPlan+Product.swift | 4 +- Sources/Build/BuildPlan/BuildPlan+Test.swift | 3 +- Sources/Build/BuildPlan/BuildPlan.swift | 4 +- .../Commands/PackageCommands/AddProduct.swift | 2 - .../Commands/PackageCommands/AddTarget.swift | 1 - Sources/Commands/PackageCommands/Init.swift | 8 +- .../PackageCommands/ShowExecutables.swift | 2 +- .../PackageCommands/ShowTemplates.swift | 2 +- Sources/Commands/Snippets/Cards/TopCard.swift | 2 - .../PackageDescriptionSerialization.swift | 2 - ...geDescriptionSerializationConversion.swift | 13 - Sources/PackageDescription/Product.swift | 16 +- Sources/PackageDescription/Target.swift | 28 +- .../PackageGraph/ModulesGraph+Loading.swift | 2 +- Sources/PackageGraph/ModulesGraph.swift | 2 +- .../Resolution/ResolvedProduct.swift | 2 +- Sources/PackageLoading/Diagnostics.swift | 2 +- .../PackageLoading/ManifestJSONParser.swift | 4 +- Sources/PackageLoading/ManifestLoader.swift | 2 +- Sources/PackageLoading/PackageBuilder.swift | 76 ++- .../Manifest/TargetDescription.swift | 76 ++- .../ManifestSourceGeneration.swift | 4 - .../PackageModel/Module/BinaryModule.swift | 4 + Sources/PackageModel/Module/ClangModule.swift | 8 + Sources/PackageModel/Module/Module.swift | 25 +- .../PackageModel/Module/PluginModule.swift | 4 + Sources/PackageModel/Module/SwiftModule.swift | 22 +- .../Module/SystemLibraryModule.swift | 4 + Sources/PackageModel/Product.swift | 16 +- Sources/PackageModel/ToolsVersion.swift | 2 +- Sources/PackageModelSyntax/AddTarget.swift | 439 ++++++++++++++++++ .../ProductDescription+Syntax.swift | 1 - .../TargetDescription+Syntax.swift | 1 - .../BuildParameters/BuildParameters.swift | 6 - .../Plugins/PluginContextSerializer.swift | 2 +- .../Plugins/PluginInvocation.swift | 2 +- .../PackagePIFBuilder+Helpers.swift | 13 +- .../SwiftBuildSupport/PackagePIFBuilder.swift | 10 +- .../PackagePIFProjectBuilder+Modules.swift | 6 - .../PackagePIFProjectBuilder+Products.swift | 3 - Sources/XCBuildSupport/PIFBuilder.swift | 6 +- .../ResolvedModule+Mock.swift | 3 +- .../ClangTargetBuildDescriptionTests.swift | 3 +- Tests/FunctionalTests/PluginTests.swift | 15 +- 50 files changed, 623 insertions(+), 256 deletions(-) create mode 100644 Sources/PackageModelSyntax/AddTarget.swift diff --git a/Sources/Build/BuildDescription/ProductBuildDescription.swift b/Sources/Build/BuildDescription/ProductBuildDescription.swift index b9a6a09783a..cd6f19ae8ef 100644 --- a/Sources/Build/BuildDescription/ProductBuildDescription.swift +++ b/Sources/Build/BuildDescription/ProductBuildDescription.swift @@ -222,7 +222,7 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription args += ["-Xlinker", "-install_name", "-Xlinker", relativePath] } args += self.deadStripArguments - case .executable, .snippet, .template: //john-to-revisit + case .executable, .snippet: // Link the Swift stdlib statically, if requested. // TODO: unify this logic with SwiftTargetBuildDescription.stdlibArguments if self.buildParameters.linkingParameters.shouldLinkStaticSwiftStdlib { @@ -256,8 +256,6 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription } case .plugin: throw InternalError("unexpectedly asked to generate linker arguments for a plugin product") - /*case .template: //john-to-do revist - throw InternalError("unexpectedly asked to generate linker arguments for a template product")*/ } if let resourcesPath = self.buildParameters.toolchain.swiftResourcesPath(isStatic: isLinkingStaticStdlib) { @@ -314,7 +312,7 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription switch self.product.type { case .library(let type): useStdlibRpath = type == .dynamic - case .test, .executable, .snippet, .macro, .template: //john-to-revisit + case .test, .executable, .snippet, .macro: useStdlibRpath = true case .plugin: throw InternalError("unexpectedly asked to generate linker arguments for a plugin product") diff --git a/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift b/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift index 667d6e2bfda..9488548fb99 100644 --- a/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift +++ b/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift @@ -146,7 +146,7 @@ public final class SwiftModuleBuildDescription { // If we're an executable and we're not allowing test targets to link against us, we hide the module. let triple = buildParameters.triple let allowLinkingAgainstExecutables = [.coff, .macho, .elf].contains(triple.objectFormat) && self.toolsVersion >= .v5_5 - let dirPath = ((target.type == .executable || target.type == .template) && !allowLinkingAgainstExecutables) ? self.tempsPath : self.modulesPath //john-to-revisit + let dirPath = ((target.type == .executable) && !allowLinkingAgainstExecutables) ? self.tempsPath : self.modulesPath return dirPath.appending(component: "\(self.target.c99name).swiftmodule") } @@ -205,7 +205,7 @@ public final class SwiftModuleBuildDescription { switch self.target.type { case .library, .test: return true - case .executable, .snippet, .macro, .template: //john-to-revisit + case .executable, .snippet, .macro: //john-to-revisit // This deactivates heuristics in the Swift compiler that treats single-file modules and source files // named "main.swift" specially w.r.t. whether they can have an entry point. // diff --git a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift index 845fad6cebd..34476408fff 100644 --- a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift +++ b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Clang.swift @@ -46,7 +46,7 @@ extension LLBuildManifestBuilder { case .product(let product, let productDescription): switch product.type { - case .executable, .snippet, .library(.dynamic), .macro, .template: //john-to-revisit + case .executable, .snippet, .library(.dynamic), .macro: guard let productDescription else { throw InternalError("No build description for product: \(product)") } diff --git a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift index 61ff45102c9..33eb6f4558f 100644 --- a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift +++ b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Product.swift @@ -69,11 +69,6 @@ extension LLBuildManifestBuilder { buildProduct.buildParameters.debuggingParameters.shouldEnableDebuggingEntitlement { shouldCodeSign = true linkedBinaryNode = try .file(buildProduct.binaryPath, isMutated: true) - } else if case .template = buildProduct.product.type, //john-to-revisit - buildProduct.buildParameters.triple.isMacOSX, - buildProduct.buildParameters.debuggingParameters.shouldEnableDebuggingEntitlement { - shouldCodeSign = true - linkedBinaryNode = try .file(buildProduct.binaryPath, isMutated: true) } else { shouldCodeSign = false linkedBinaryNode = try .file(buildProduct.binaryPath) @@ -204,7 +199,7 @@ extension ResolvedProduct { return staticLibraryName(for: self.name, buildParameters: buildParameters) case .library(.automatic): throw InternalError("automatic library not supported") - case .executable, .snippet, .template: //john-to-revisit + case .executable, .snippet: return executableName(for: self.name, buildParameters: buildParameters) case .macro: guard let macroModule = self.modules.first else { diff --git a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift index c8b63148616..0616405e89b 100644 --- a/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift +++ b/Sources/Build/BuildManifest/LLBuildManifestBuilder+Swift.swift @@ -228,12 +228,12 @@ extension LLBuildManifestBuilder { } // Depend on the binary for executable targets. - if (module.type == .executable || module.type == .template) && prepareForIndexing == .off {//john-to-revisit + if module.type == .executable && prepareForIndexing == .off { // FIXME: Optimize. Build plan could build a mapping between executable modules // and their products to speed up search here, which is inefficient if the plan // contains a lot of products. if let productDescription = try plan.productMap.values.first(where: { - try ($0.product.type == .executable || $0.product.type == .template) && //john-to-revisit + try $0.product.type == .executable && $0.product.executableModule.id == module.id && $0.destination == description.destination }) { @@ -267,7 +267,7 @@ extension LLBuildManifestBuilder { case .product(let product, let productDescription): switch product.type { - case .executable, .snippet, .library(.dynamic), .macro, .template: //john-to-revisit + case .executable, .snippet, .library(.dynamic), .macro: guard let productDescription else { throw InternalError("No description for product: \(product)") } diff --git a/Sources/Build/BuildOperation.swift b/Sources/Build/BuildOperation.swift index d901f14e72d..ee3b48adfb6 100644 --- a/Sources/Build/BuildOperation.swift +++ b/Sources/Build/BuildOperation.swift @@ -903,7 +903,7 @@ public final class BuildOperation: PackageStructureDelegate, SPMBuildCore.BuildS // Look for a target with the same module name as the one that's being imported. if let importedTarget = self._buildPlan?.targets.first(where: { $0.module.c99name == importedModule }) { // For the moment we just check for executables that other targets try to import. - if importedTarget.module.type == .executable || importedTarget.module.type == .template { //john-to-revisit + if importedTarget.module.type == .executable { return "module '\(importedModule)' is the main module of an executable, and cannot be imported by tests and other targets" } if importedTarget.module.type == .macro { diff --git a/Sources/Build/BuildPlan/BuildPlan+Product.swift b/Sources/Build/BuildPlan/BuildPlan+Product.swift index 7e3a6800356..f7b1bb41231 100644 --- a/Sources/Build/BuildPlan/BuildPlan+Product.swift +++ b/Sources/Build/BuildPlan/BuildPlan+Product.swift @@ -174,7 +174,7 @@ extension BuildPlan { product: $0.product, context: $0.destination ) } - case .test, .executable, .snippet, .macro, .template: //john-to-revisit + case .test, .executable, .snippet, .macro: return [] } } @@ -249,7 +249,7 @@ extension BuildPlan { // In tool version .v5_5 or greater, we also include executable modules implemented in Swift in // any test products... this is to allow testing of executables. Note that they are also still // built as separate products that the test can invoke as subprocesses. - case .executable, .snippet, .macro, .template: //john-to-revisit + case .executable, .snippet, .macro: if product.modules.contains(id: module.id) { guard let description else { throw InternalError("Could not find a description for module: \(module)") diff --git a/Sources/Build/BuildPlan/BuildPlan+Test.swift b/Sources/Build/BuildPlan/BuildPlan+Test.swift index 545ecb2702f..281e2eebe7e 100644 --- a/Sources/Build/BuildPlan/BuildPlan+Test.swift +++ b/Sources/Build/BuildPlan/BuildPlan+Test.swift @@ -289,7 +289,8 @@ private extension PackageModel.SwiftModule { packageAccess: packageAccess, buildSettings: buildSettings, usesUnsafeFlags: false, - implicit: true + implicit: true, + template: false // test entry points are not templates ) } } diff --git a/Sources/Build/BuildPlan/BuildPlan.swift b/Sources/Build/BuildPlan/BuildPlan.swift index 4e45792dae7..5f101f9fbae 100644 --- a/Sources/Build/BuildPlan/BuildPlan.swift +++ b/Sources/Build/BuildPlan/BuildPlan.swift @@ -388,7 +388,7 @@ public class BuildPlan: SPMBuildCore.BuildPlan { try module.dependencies.compactMap { switch $0 { case .module(let moduleDependency, _): - if moduleDependency.type == .executable || moduleDependency.type == .template { + if moduleDependency.type == .executable { return graph.product(for: moduleDependency.name) } return nil @@ -1387,6 +1387,6 @@ extension ResolvedProduct { // We shouldn't create product descriptions for automatic libraries, plugins or products which consist solely of // binary targets, because they don't produce any output. fileprivate var shouldCreateProductDescription: Bool { - !self.isAutomaticLibrary && !self.isBinaryOnly && !self.isPlugin //john-to-revisit to see if include templates + !self.isAutomaticLibrary && !self.isBinaryOnly && !self.isPlugin } } diff --git a/Sources/Commands/PackageCommands/AddProduct.swift b/Sources/Commands/PackageCommands/AddProduct.swift index faa4c43a0c2..288e691797b 100644 --- a/Sources/Commands/PackageCommands/AddProduct.swift +++ b/Sources/Commands/PackageCommands/AddProduct.swift @@ -32,7 +32,6 @@ extension SwiftPackageCommand { case staticLibrary = "static-library" case dynamicLibrary = "dynamic-library" case plugin - case template } package static let configuration = CommandConfiguration( @@ -86,7 +85,6 @@ extension SwiftPackageCommand { case .dynamicLibrary: .library(.dynamic) case .staticLibrary: .library(.static) case .plugin: .plugin - case .template: .template } let product = ProductDescription( diff --git a/Sources/Commands/PackageCommands/AddTarget.swift b/Sources/Commands/PackageCommands/AddTarget.swift index b7f92cfd1f8..dc470614bd9 100644 --- a/Sources/Commands/PackageCommands/AddTarget.swift +++ b/Sources/Commands/PackageCommands/AddTarget.swift @@ -39,7 +39,6 @@ extension SwiftPackageCommand { case executable case test case macro - case template } package static let configuration = CommandConfiguration( diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 04f5046572d..455cfc014cf 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -655,12 +655,10 @@ extension SwiftPackageCommand { for product in products { for targetName in product.targets { if let target = targets.first(where: { _ in template == targetName }) { - if target.type == .template { - if let options = target.templateInitializationOptions { + if let options = target.templateInitializationOptions { - if case let .packageInit(templateType, _, _) = options { - return try .init(from: templateType) - } + if case let .packageInit(templateType, _, _) = options { + return try .init(from: templateType) } } } diff --git a/Sources/Commands/PackageCommands/ShowExecutables.swift b/Sources/Commands/PackageCommands/ShowExecutables.swift index 3195c2780cb..18c0c26532d 100644 --- a/Sources/Commands/PackageCommands/ShowExecutables.swift +++ b/Sources/Commands/PackageCommands/ShowExecutables.swift @@ -35,7 +35,7 @@ struct ShowExecutables: AsyncSwiftCommand { let executables = packageGraph.allProducts.filter({ $0.type == .executable || $0.type == .snippet }).filter({ - $0.modules.allSatisfy( {$0.type != .template}) + $0.modules.allSatisfy( { !$0.underlying.template }) }).map { product -> Executable in if !rootPackages.contains(product.packageIdentity) { return Executable(package: product.packageIdentity.description, name: product.name) diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 023744c4478..b304db93d8a 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -33,7 +33,7 @@ struct ShowTemplates: AsyncSwiftCommand { let rootPackages = packageGraph.rootPackages.map { $0.identity } let templates = packageGraph.allModules.filter({ - $0.type == .template + $0.underlying.template }).map { module -> Template in if !rootPackages.contains(module.packageIdentity) { return Template(package: module.packageIdentity.description, name: module.name) diff --git a/Sources/Commands/Snippets/Cards/TopCard.swift b/Sources/Commands/Snippets/Cards/TopCard.swift index 00363fbdb92..614a20b6080 100644 --- a/Sources/Commands/Snippets/Cards/TopCard.swift +++ b/Sources/Commands/Snippets/Cards/TopCard.swift @@ -182,8 +182,6 @@ fileprivate extension Module.Kind { return "snippets" case .macro: return "macros" - case .template: - return "templates" } } } diff --git a/Sources/PackageDescription/PackageDescriptionSerialization.swift b/Sources/PackageDescription/PackageDescriptionSerialization.swift index 43da07afe3b..a4c730e6d17 100644 --- a/Sources/PackageDescription/PackageDescriptionSerialization.swift +++ b/Sources/PackageDescription/PackageDescriptionSerialization.swift @@ -180,7 +180,6 @@ enum Serialization { case binary case plugin case `macro` - case template } enum PluginCapability: Codable { @@ -286,7 +285,6 @@ enum Serialization { case executable case library(type: LibraryType) case plugin - case template } let name: String diff --git a/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift b/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift index f59e8d380ba..06d1663602c 100644 --- a/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift +++ b/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift @@ -237,7 +237,6 @@ extension Serialization.TargetType { case .binary: self = .binary case .plugin: self = .plugin case .macro: self = .macro - case .template: self = .template } } } @@ -401,8 +400,6 @@ extension Serialization.Product { self.init(library) } else if let plugin = product as? PackageDescription.Product.Plugin { self.init(plugin) - } else if let template = product as? PackageDescription.Product.Template { - self.init(template) } else { fatalError("should not be reached") } @@ -435,16 +432,6 @@ extension Serialization.Product { self.settings = [] #endif } - - init(_ template: PackageDescription.Product.Template) { - self.name = template.name - self.targets = template.targets - self.productType = .template - #if ENABLE_APPLE_PRODUCT_TYPES - self.settings = [] - #endif - } - } extension Serialization.Trait { diff --git a/Sources/PackageDescription/Product.swift b/Sources/PackageDescription/Product.swift index 05df231980e..1f7efbeae3b 100644 --- a/Sources/PackageDescription/Product.swift +++ b/Sources/PackageDescription/Product.swift @@ -120,15 +120,6 @@ public class Product { } } - public final class Template: Product, @unchecked Sendable { - public let targets: [String] - - init(name: String, targets: [String]) { - self.targets = [name] - super.init(name: name) - } - } - /// Creates a library product to allow clients that declare a dependency on /// this package to use the package's functionality. /// @@ -178,12 +169,11 @@ public class Product { return Executable(name: name, targets: targets, settings: settings) } - //john-to-revisit documentation - /// Defines a template that vends a template plugin target and a template executable target for use by clients of the package. + /// Defines a product that vends a package plugin target for use by clients of the package. /// - /// It is not necessary to define a product for a template that + /// It is not necessary to define a product for a plugin that /// is only used within the same package where you define it. All the targets - /// listed must be template targets in the same package as the product. Swift Package Manager + /// listed must be plugin targets in the same package as the product. Swift Package Manager /// will apply them to any client targets of the product in the order /// they are listed. /// - Parameters: diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index 749a4d00e89..75b260577ec 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -39,8 +39,6 @@ public final class Target { case plugin /// A target that provides a Swift macro. case `macro` - /// A target that provides a Swift template - case template } /// The different types of a target's dependency on another entity. @@ -294,7 +292,7 @@ public final class Target { self.templateInitializationOptions = templateInitializationOptions switch type { - case .regular, .executable, .test: + case .regular, .test: precondition( url == nil && pkgConfig == nil && @@ -303,6 +301,14 @@ public final class Target { checksum == nil && templateInitializationOptions == nil ) + case .executable: + precondition( + url == nil && + pkgConfig == nil && + providers == nil && + pluginCapability == nil && + checksum == nil + ) case .system: precondition( url == nil && @@ -364,14 +370,6 @@ public final class Target { cxxSettings == nil && templateInitializationOptions == nil ) - case .template: - precondition( - url == nil && - pkgConfig == nil && - providers == nil && - pluginCapability == nil && - checksum == nil - ) } } @@ -1335,7 +1333,7 @@ public extension [Target] { sources: sources, resources: resources, publicHeadersPath: publicHeadersPath, - type: .template, + type: .executable, packageAccess: packageAccess, cSettings: cSettings, cxxSettings: cxxSettings, @@ -1688,7 +1686,7 @@ extension Target.PluginUsage: ExpressibleByStringLiteral { /// The type of permission a plug-in requires. /// /// Supported types are ``allowNetworkConnections(scope:reason:)`` and ``writeToPackageDirectory(reason:)``. -@available(_PackageDescription, introduced: 6.0) +@available(_PackageDescription, introduced: 999.0) public enum TemplatePermissions { /// Create a permission to make network connections. /// @@ -1696,7 +1694,7 @@ public enum TemplatePermissions { /// to the user at the time of request for approval, explaining why the plug-in is requesting access. /// - Parameter scope: The scope of the permission. /// - Parameter reason: A reason why the permission is needed. This is shown to the user when permission is sought. - @available(_PackageDescription, introduced: 6.0) + @available(_PackageDescription, introduced: 999.0) case allowNetworkConnections(scope: TemplateNetworkPermissionScope, reason: String) } @@ -1704,7 +1702,7 @@ public enum TemplatePermissions { /// The scope of a network permission. /// /// The scope can be none, local connections only, or all connections. -@available(_PackageDescription, introduced: 5.9) +@available(_PackageDescription, introduced: 999.0) public enum TemplateNetworkPermissionScope { /// Do not allow network access. case none diff --git a/Sources/PackageGraph/ModulesGraph+Loading.swift b/Sources/PackageGraph/ModulesGraph+Loading.swift index 8df4144c067..2ea14951129 100644 --- a/Sources/PackageGraph/ModulesGraph+Loading.swift +++ b/Sources/PackageGraph/ModulesGraph+Loading.swift @@ -272,7 +272,7 @@ private func checkAllDependenciesAreUsed( // We continue if the dependency contains executable products to make sure we don't // warn on a valid use-case for a lone dependency: swift run dependency executables. - guard !dependency.products.contains(where: { $0.type == .executable || $0.type == .template}) else { //john-to-revisit + guard !dependency.products.contains(where: { $0.type == .executable }) else { continue } // Skip this check if this dependency is a system module because system module packages diff --git a/Sources/PackageGraph/ModulesGraph.swift b/Sources/PackageGraph/ModulesGraph.swift index f632534874d..026347fdd31 100644 --- a/Sources/PackageGraph/ModulesGraph.swift +++ b/Sources/PackageGraph/ModulesGraph.swift @@ -268,7 +268,7 @@ public struct ModulesGraph { return try Dictionary(throwingUniqueKeysWithValues: testModuleDeps) }() - for module in rootModules where module.type == .executable || module.type == .template { //john-to-revisit + for module in rootModules where module.type == .executable { // Find all dependencies of this module within its package. Note that we do not traverse plugin usages. let dependencies = try topologicalSortIdentifiable(module.dependencies, successors: { $0.dependencies.compactMap{ $0.module }.filter{ $0.type != .plugin }.map{ .module($0, conditions: []) } diff --git a/Sources/PackageGraph/Resolution/ResolvedProduct.swift b/Sources/PackageGraph/Resolution/ResolvedProduct.swift index c0c86aa1c63..357cf515316 100644 --- a/Sources/PackageGraph/Resolution/ResolvedProduct.swift +++ b/Sources/PackageGraph/Resolution/ResolvedProduct.swift @@ -58,7 +58,7 @@ public struct ResolvedProduct { /// Note: This property is only valid for executable products. public var executableModule: ResolvedModule { get throws { - guard self.type == .executable || self.type == .snippet || self.type == .macro || self.type == .template else { //john-to-revisit + guard self.type == .executable || self.type == .snippet || self.type == .macro else { throw InternalError("`executableTarget` should only be called for executable targets") } guard let underlyingExecutableModule = modules.map(\.underlying).executables.first, diff --git a/Sources/PackageLoading/Diagnostics.swift b/Sources/PackageLoading/Diagnostics.swift index debc3030b52..5ec694a4403 100644 --- a/Sources/PackageLoading/Diagnostics.swift +++ b/Sources/PackageLoading/Diagnostics.swift @@ -38,7 +38,7 @@ extension Basics.Diagnostic { case .library(.automatic): typeString = "" case .executable, .snippet, .plugin, .test, .macro, - .library(.dynamic), .library(.static), .template: //john-to-revisit + .library(.dynamic), .library(.static): typeString = " (\(product.type))" } diff --git a/Sources/PackageLoading/ManifestJSONParser.swift b/Sources/PackageLoading/ManifestJSONParser.swift index 9b489a70a82..44240a407d8 100644 --- a/Sources/PackageLoading/ManifestJSONParser.swift +++ b/Sources/PackageLoading/ManifestJSONParser.swift @@ -499,7 +499,7 @@ extension ProductDescription { switch product.productType { case .executable: productType = .executable - case .plugin, .template: + case .plugin: productType = .plugin case .library(let type): productType = .library(.init(type)) @@ -568,8 +568,6 @@ extension TargetDescription.TargetKind { self = .plugin case .macro: self = .macro - case .template: - self = .template } } } diff --git a/Sources/PackageLoading/ManifestLoader.swift b/Sources/PackageLoading/ManifestLoader.swift index 00631fdf02c..70a93854142 100644 --- a/Sources/PackageLoading/ManifestLoader.swift +++ b/Sources/PackageLoading/ManifestLoader.swift @@ -943,10 +943,10 @@ public final class ManifestLoader: ManifestLoaderProtocol { return evaluationResult // Return the result containing the error output } - // Read the JSON output that was emitted by libPackageDescription. let jsonOutput: String = try localFileSystem.readFileContents(jsonOutputFile) evaluationResult.manifestJSON = jsonOutput + // withTemporaryDirectory handles cleanup automatically return evaluationResult } diff --git a/Sources/PackageLoading/PackageBuilder.swift b/Sources/PackageLoading/PackageBuilder.swift index 56f460f8f6c..9df43bfa555 100644 --- a/Sources/PackageLoading/PackageBuilder.swift +++ b/Sources/PackageLoading/PackageBuilder.swift @@ -34,7 +34,7 @@ public enum ModuleError: Swift.Error { case duplicateModule(moduleName: String, packages: [PackageIdentity]) /// The referenced target could not be found. - case moduleNotFound(String, TargetDescription.TargetKind, shouldSuggestRelaxedSourceDir: Bool) + case moduleNotFound(String, TargetDescription.TargetKind, shouldSuggestRelaxedSourceDir: Bool, expectedLocation: String) /// The artifact for the binary target could not be found. case artifactNotFound(moduleName: String, expectedArtifactName: String) @@ -112,22 +112,10 @@ extension ModuleError: CustomStringConvertible { case .duplicateModule(let target, let packages): let packages = packages.map(\.description).sorted().joined(separator: "', '") return "multiple packages ('\(packages)') declare targets with a conflicting name: '\(target)’; target names need to be unique across the package graph" - case .moduleNotFound(let target, let type, let shouldSuggestRelaxedSourceDir): - var folderName = "" - switch type { - case .test: - folderName = "Tests" - case .plugin: - folderName = "Plugins" - case .template: - folderName = "Templates" - default: - folderName = "Sources" - } - - var clauses = ["Source files for target \(target) should be located under '\(folderName)/\(target)'"] + case .moduleNotFound(let target, let type, let shouldSuggestRelaxedSourceDir, let expectedLocation): + var clauses = ["Source files for target \(target) should be located under '\(expectedLocation)/\(target)'"] if shouldSuggestRelaxedSourceDir { - clauses.append("'\(folderName)'") + clauses.append("'\(expectedLocation)'") } clauses.append("or a custom sources path can be set with the 'path' property in Package.swift") return clauses.joined(separator: ", ") @@ -623,7 +611,6 @@ public final class PackageBuilder { fs: fileSystem, path: packagePath.appending(component: predefinedDirs.pluginTargetDir) ) - let predefinedTemplateTargetDirectory = PredefinedTargetDirectory( fs: fileSystem, path: packagePath.appending(component: predefinedDirs.templateTargetDir) @@ -663,8 +650,12 @@ public final class PackageBuilder { predefinedTestTargetDirectory case .plugin: predefinedPluginTargetDirectory - case .template: - predefinedTemplateTargetDirectory + case .executable: + if target.templateInitializationOptions != nil { + predefinedTemplateTargetDirectory + } else { + predefinedTargetDirectory + } default: predefinedTargetDirectory } @@ -695,7 +686,8 @@ public final class PackageBuilder { target.name, target.type, shouldSuggestRelaxedSourceDir: self.manifest - .shouldSuggestRelaxedSourceDir(type: target.type) + .shouldSuggestRelaxedSourceDir(type: target.type), + expectedLocation: path.pathString ) } @@ -741,7 +733,8 @@ public final class PackageBuilder { throw ModuleError.moduleNotFound( missingModuleName, type, - shouldSuggestRelaxedSourceDir: self.manifest.shouldSuggestRelaxedSourceDir(type: type) + shouldSuggestRelaxedSourceDir: self.manifest.shouldSuggestRelaxedSourceDir(type: type), + expectedLocation: "Sources" // FIXME: this should provide the expected location of the module here ) } @@ -1076,8 +1069,6 @@ public final class PackageBuilder { moduleKind = .executable case .macro: moduleKind = .macro - case .template: //john-to-revisit - moduleKind = .template default: moduleKind = sources.computeModuleKind() if moduleKind == .executable && self.manifest.toolsVersion >= .v5_4 && self @@ -1108,7 +1099,8 @@ public final class PackageBuilder { buildSettingsDescription: manifestTarget.settings, // unsafe flags check disabled in 6.2 usesUnsafeFlags: manifest.toolsVersion >= .v6_2 ? false : manifestTarget.usesUnsafeFlags, - implicit: false + implicit: false, + template: manifestTarget.templateInitializationOptions != nil ) } else { // It's not a Swift target, so it's a Clang target (those are the only two types of source target currently @@ -1153,9 +1145,14 @@ public final class PackageBuilder { dependencies: dependencies, buildSettings: buildSettings, buildSettingsDescription: manifestTarget.settings, +<<<<<<< HEAD // unsafe flags check disabled in 6.2 usesUnsafeFlags: manifest.toolsVersion >= .v6_2 ? false : manifestTarget.usesUnsafeFlags, implicit: false +======= + usesUnsafeFlags: manifestTarget.usesUnsafeFlags, + template: manifestTarget.templateInitializationOptions != nil +>>>>>>> 7b7986368 (Remove template target and product types and use the template init options instead) ) } } @@ -1714,10 +1711,6 @@ public final class PackageBuilder { guard self.validatePluginProduct(product, with: modules) else { continue } - case .template: //john-to-revisit - guard self.validateTemplateProduct(product, with: modules) else { - continue - } } try append(Product(package: self.identity, name: product.name, type: product.type, modules: modules)) @@ -1732,7 +1725,7 @@ public final class PackageBuilder { switch product.type { case .library, .plugin, .test, .macro: return [] - case .executable, .snippet, .template: //john-to-revisit + case .executable, .snippet: return product.targets } }) @@ -1878,27 +1871,6 @@ public final class PackageBuilder { return true } - private func validateTemplateProduct(_ product: ProductDescription, with targets: [Module]) -> Bool { - let nonTemplateTargets = targets.filter { $0.type != .template } - guard nonTemplateTargets.isEmpty else { - self.observabilityScope - .emit(.templateProductWithNonTemplateTargets(product: product.name, - otherTargets: nonTemplateTargets.map(\.name))) - return false - } - guard !targets.isEmpty else { - self.observabilityScope.emit(.templateProductWithNoTargets(product: product.name)) - return false - } - - guard targets.count == 1 else { - self.observabilityScope.emit(.templateProductWithMultipleTemplates(product: product.name)) - return false - } - - return true - } - /// Returns the first suggested predefined source directory for a given target type. public static func suggestedPredefinedSourceDirectory(type: TargetDescription.TargetKind) -> String { // These are static constants, safe to access by index; the first choice is preferred. @@ -2050,7 +2022,11 @@ extension PackageBuilder { buildSettings: buildSettings, buildSettingsDescription: targetDescription.settings, usesUnsafeFlags: false, +<<<<<<< HEAD implicit: true +======= + template: false // Snippets are not templates +>>>>>>> 7b7986368 (Remove template target and product types and use the template init options instead) ) } } diff --git a/Sources/PackageModel/Manifest/TargetDescription.swift b/Sources/PackageModel/Manifest/TargetDescription.swift index 04bbcfcc15e..6db668c04a2 100644 --- a/Sources/PackageModel/Manifest/TargetDescription.swift +++ b/Sources/PackageModel/Manifest/TargetDescription.swift @@ -24,7 +24,6 @@ public struct TargetDescription: Hashable, Encodable, Sendable { case binary case plugin case `macro` - case template } /// Represents a target's dependency on another entity. @@ -255,7 +254,7 @@ public struct TargetDescription: Hashable, Encodable, Sendable { ) throws { let targetType = String(describing: type) switch type { - case .regular, .executable, .test: + case .regular, .test: if url != nil { throw Error.disallowedPropertyInTarget( targetName: name, targetType: targetType, @@ -293,7 +292,37 @@ public struct TargetDescription: Hashable, Encodable, Sendable { value: String(describing: templateInitializationOptions!) ) } - + case .executable: + if url != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "url", + value: url ?? "" + ) } + if pkgConfig != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "pkgConfig", + value: pkgConfig ?? "" + ) } + if providers != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "providers", + value: String(describing: providers!) + ) } + if pluginCapability != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "pluginCapability", + value: String(describing: pluginCapability!) + ) } + if checksum != nil { throw Error.disallowedPropertyInTarget( + targetName: name, + targetType: targetType, + propertyName: "checksum", + value: checksum ?? "" + ) } case .system: if !dependencies.isEmpty { throw Error.disallowedPropertyInTarget( targetName: name, @@ -520,45 +549,6 @@ public struct TargetDescription: Hashable, Encodable, Sendable { value: String(describing: templateInitializationOptions!) ) } - case .template: - // List forbidden properties for `.template` targets - if url != nil { throw Error.disallowedPropertyInTarget( - targetName: name, - targetType: targetType, - propertyName: "url", - value: url ?? "" - ) } - if pkgConfig != nil { throw Error.disallowedPropertyInTarget( - targetName: name, - targetType: targetType, - propertyName: "pkgConfig", - value: pkgConfig ?? "" - ) } - if providers != nil { throw Error.disallowedPropertyInTarget( - targetName: name, - targetType: targetType, - propertyName: "providers", - value: String(describing: providers!) - ) } - if pluginCapability != nil { throw Error.disallowedPropertyInTarget( - targetName: name, - targetType: targetType, - propertyName: "pluginCapability", - value: String(describing: pluginCapability!) - ) } - if checksum != nil { throw Error.disallowedPropertyInTarget( - targetName: name, - targetType: targetType, - propertyName: "checksum", - value: checksum ?? "" - ) } - if templateInitializationOptions == nil { throw Error.disallowedPropertyInTarget( //john-to-revisit - targetName: name, - targetType: targetType, - propertyName: "templateInitializationOptions", - value: String(describing: templateInitializationOptions) - ) } - } self.name = name @@ -759,5 +749,3 @@ private enum Error: LocalizedError, Equatable { } } } - - diff --git a/Sources/PackageModel/ManifestSourceGeneration.swift b/Sources/PackageModel/ManifestSourceGeneration.swift index 409954bac64..6b760d4b592 100644 --- a/Sources/PackageModel/ManifestSourceGeneration.swift +++ b/Sources/PackageModel/ManifestSourceGeneration.swift @@ -321,8 +321,6 @@ fileprivate extension SourceCodeFragment { self.init(enum: "test", subnodes: params, multiline: true) case .macro: self.init(enum: "macro", subnodes: params, multiline: true) - case .template: - self.init(enum: "template", subnodes: params, multiline: true) } } } @@ -460,8 +458,6 @@ fileprivate extension SourceCodeFragment { self.init(enum: "plugin", subnodes: params, multiline: true) case .macro: self.init(enum: "macro", subnodes: params, multiline: true) - case .template: - self.init(enum: "template", subnodes: params, multiline: true) } } diff --git a/Sources/PackageModel/Module/BinaryModule.swift b/Sources/PackageModel/Module/BinaryModule.swift index 31d47fbb948..57a0cc9a8b5 100644 --- a/Sources/PackageModel/Module/BinaryModule.swift +++ b/Sources/PackageModel/Module/BinaryModule.swift @@ -50,7 +50,11 @@ public final class BinaryModule: Module { buildSettingsDescription: [], pluginUsages: [], usesUnsafeFlags: false, +<<<<<<< HEAD implicit: false +======= + template: false // TODO: determine whether binary modules can be templates or not +>>>>>>> 7b7986368 (Remove template target and product types and use the template init options instead) ) } diff --git a/Sources/PackageModel/Module/ClangModule.swift b/Sources/PackageModel/Module/ClangModule.swift index 5105b7bb563..abee06a4369 100644 --- a/Sources/PackageModel/Module/ClangModule.swift +++ b/Sources/PackageModel/Module/ClangModule.swift @@ -62,7 +62,11 @@ public final class ClangModule: Module { buildSettings: BuildSettings.AssignmentTable = .init(), buildSettingsDescription: [TargetBuildSettingDescription.Setting] = [], usesUnsafeFlags: Bool, +<<<<<<< HEAD implicit: Bool +======= + template: Bool +>>>>>>> 7b7986368 (Remove template target and product types and use the template init options instead) ) throws { guard includeDir.isDescendantOfOrEqual(to: sources.root) else { throw StringError("\(includeDir) should be contained in the source root \(sources.root)") @@ -88,7 +92,11 @@ public final class ClangModule: Module { buildSettingsDescription: buildSettingsDescription, pluginUsages: [], usesUnsafeFlags: usesUnsafeFlags, +<<<<<<< HEAD implicit: implicit +======= + template: template +>>>>>>> 7b7986368 (Remove template target and product types and use the template init options instead) ) } } diff --git a/Sources/PackageModel/Module/Module.swift b/Sources/PackageModel/Module/Module.swift index 0b60a2178d8..2a3275bf297 100644 --- a/Sources/PackageModel/Module/Module.swift +++ b/Sources/PackageModel/Module/Module.swift @@ -29,7 +29,6 @@ public class Module { case plugin case snippet case `macro` - case template } /// A group a module belongs to that allows customizing access boundaries. A module is treated as @@ -243,9 +242,14 @@ public class Module { /// Whether or not this target uses any custom unsafe flags. public let usesUnsafeFlags: Bool +<<<<<<< HEAD /// Whether this module comes from a declaration in the manifest file /// or was synthesized (i.e. some test modules are synthesized). public let implicit: Bool +======= + /// Whether or not this is a module that represents a template + public let template: Bool +>>>>>>> 7b7986368 (Remove template target and product types and use the template init options instead) init( name: String, @@ -262,7 +266,11 @@ public class Module { buildSettingsDescription: [TargetBuildSettingDescription.Setting], pluginUsages: [PluginUsage], usesUnsafeFlags: Bool, +<<<<<<< HEAD implicit: Bool +======= + template: Bool +>>>>>>> 7b7986368 (Remove template target and product types and use the template init options instead) ) { self.name = name self.potentialBundleName = potentialBundleName @@ -279,7 +287,20 @@ public class Module { self.buildSettingsDescription = buildSettingsDescription self.pluginUsages = pluginUsages self.usesUnsafeFlags = usesUnsafeFlags +<<<<<<< HEAD self.implicit = implicit +======= + self.template = template + } + + @_spi(SwiftPMInternal) + public var isEmbeddedSwiftTarget: Bool { + for case .enableExperimentalFeature("Embedded") in self.buildSettingsDescription.swiftSettings.map(\.kind) { + return true + } + + return false +>>>>>>> 7b7986368 (Remove template target and product types and use the template init options instead) } } @@ -311,7 +332,7 @@ public extension Sequence where Iterator.Element == Module { switch $0.type { case .binary: return ($0 as? BinaryModule)?.containsExecutable == true - case .executable, .snippet, .macro, .template: //john-to-revisit + case .executable, .snippet, .macro: return true default: return false diff --git a/Sources/PackageModel/Module/PluginModule.swift b/Sources/PackageModel/Module/PluginModule.swift index ec180285b93..812d1fd4799 100644 --- a/Sources/PackageModel/Module/PluginModule.swift +++ b/Sources/PackageModel/Module/PluginModule.swift @@ -46,7 +46,11 @@ public final class PluginModule: Module { buildSettingsDescription: [], pluginUsages: [], usesUnsafeFlags: false, +<<<<<<< HEAD implicit: false +======= + template: false // Plugins cannot themselves be a template +>>>>>>> 7b7986368 (Remove template target and product types and use the template init options instead) ) } } diff --git a/Sources/PackageModel/Module/SwiftModule.swift b/Sources/PackageModel/Module/SwiftModule.swift index f382895c2a6..bd0573944d9 100644 --- a/Sources/PackageModel/Module/SwiftModule.swift +++ b/Sources/PackageModel/Module/SwiftModule.swift @@ -48,7 +48,11 @@ public final class SwiftModule: Module { buildSettingsDescription: [], pluginUsages: [], usesUnsafeFlags: false, +<<<<<<< HEAD implicit: implicit +======= + template: false // test modules cannot be templates +>>>>>>> 7b7986368 (Remove template target and product types and use the template init options instead) ) } @@ -71,7 +75,11 @@ public final class SwiftModule: Module { buildSettingsDescription: [TargetBuildSettingDescription.Setting] = [], pluginUsages: [PluginUsage] = [], usesUnsafeFlags: Bool, +<<<<<<< HEAD implicit: Bool +======= + template: Bool +>>>>>>> 7b7986368 (Remove template target and product types and use the template init options instead) ) { self.declaredSwiftVersions = declaredSwiftVersions super.init( @@ -89,7 +97,11 @@ public final class SwiftModule: Module { buildSettingsDescription: buildSettingsDescription, pluginUsages: pluginUsages, usesUnsafeFlags: usesUnsafeFlags, +<<<<<<< HEAD implicit: implicit +======= + template: template +>>>>>>> 7b7986368 (Remove template target and product types and use the template init options instead) ) } @@ -138,16 +150,20 @@ public final class SwiftModule: Module { buildSettingsDescription: [], pluginUsages: [], usesUnsafeFlags: false, +<<<<<<< HEAD implicit: true +======= + template: false // Modules from test entry point files are not templates +>>>>>>> 7b7986368 (Remove template target and product types and use the template init options instead) ) } public var supportsTestableExecutablesFeature: Bool { - // Exclude macros from testable executables if they are built as dylibs. john-to-revisit + // Exclude macros from testable executables if they are built as dylibs. #if BUILD_MACROS_AS_DYLIBS - return type == .executable || type == .snippet || type == .template + return type == .executable || type == .snippet #else - return type == .executable || type == .macro || type == .snippet || type == .template + return type == .executable || type == .macro || type == .snippet #endif } } diff --git a/Sources/PackageModel/Module/SystemLibraryModule.swift b/Sources/PackageModel/Module/SystemLibraryModule.swift index f9a42d2e51a..36b1298f12b 100644 --- a/Sources/PackageModel/Module/SystemLibraryModule.swift +++ b/Sources/PackageModel/Module/SystemLibraryModule.swift @@ -46,7 +46,11 @@ public final class SystemLibraryModule: Module { buildSettingsDescription: [], pluginUsages: [], usesUnsafeFlags: false, +<<<<<<< HEAD implicit: isImplicit +======= + template: false // System libraries are not templates +>>>>>>> 7b7986368 (Remove template target and product types and use the template init options instead) ) } } diff --git a/Sources/PackageModel/Product.swift b/Sources/PackageModel/Product.swift index 98033b7a97f..4b5c79f696b 100644 --- a/Sources/PackageModel/Product.swift +++ b/Sources/PackageModel/Product.swift @@ -108,18 +108,10 @@ public enum ProductType: Equatable, Hashable, Sendable { /// A macro product. case `macro` - /// A template product - case template - public var isLibrary: Bool { guard case .library = self else { return false } return true } - - public var isTemplate: Bool { - guard case .template = self else {return false} - return true - } } @@ -211,8 +203,6 @@ extension ProductType: CustomStringConvertible { return "plugin" case .macro: return "macro" - case .template: - return "template" } } } @@ -232,7 +222,7 @@ extension ProductFilter: CustomStringConvertible { extension ProductType: Codable { private enum CodingKeys: String, CodingKey { - case library, executable, snippet, plugin, test, `macro`, template + case library, executable, snippet, plugin, test, `macro` } public func encode(to encoder: Encoder) throws { @@ -251,8 +241,6 @@ extension ProductType: Codable { try container.encodeNil(forKey: .test) case .macro: try container.encodeNil(forKey: .macro) - case .template: - try container.encodeNil(forKey: .template) } } @@ -276,8 +264,6 @@ extension ProductType: Codable { self = .plugin case .macro: self = .macro - case .template: - self = .template } } } diff --git a/Sources/PackageModel/ToolsVersion.swift b/Sources/PackageModel/ToolsVersion.swift index 37e12c6703a..cded5bf9467 100644 --- a/Sources/PackageModel/ToolsVersion.swift +++ b/Sources/PackageModel/ToolsVersion.swift @@ -79,7 +79,7 @@ public struct ToolsVersion: Equatable, Hashable, Codable, Sendable { /// Returns the tools version with zeroed patch number. public var zeroedPatch: ToolsVersion { - return ToolsVersion(version: Version(6, 1, 0)) //john-to-revisit working on 6.1.0, just for using to test. revert to major, minor when finished + return ToolsVersion(version: Version(major, minor, 0)) } /// The underlying backing store. diff --git a/Sources/PackageModelSyntax/AddTarget.swift b/Sources/PackageModelSyntax/AddTarget.swift new file mode 100644 index 00000000000..b6a081a7d67 --- /dev/null +++ b/Sources/PackageModelSyntax/AddTarget.swift @@ -0,0 +1,439 @@ +//===----------------------------------------------------------------------===// +// +// 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 Basics +import Foundation +import PackageModel +import SwiftParser +import SwiftSyntax +import SwiftSyntaxBuilder +import struct TSCUtility.Version + +/// Add a target to a manifest's source code. +public enum AddTarget { + /// The set of argument labels that can occur after the "targets" + /// 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 argumentLabelsAfterTargets: Set = [ + "swiftLanguageVersions", + "cLanguageStandard", + "cxxLanguageStandard", + ] + + /// The kind of test harness to use. This isn't part of the manifest + /// itself, but is used to guide the generation process. + public enum TestHarness: String, Codable { + /// Don't use any library + case none + + /// Create a test using the XCTest library. + case xctest + + /// Create a test using the swift-testing package. + case swiftTesting = "swift-testing" + + /// The default testing library to use. + public static var `default`: TestHarness = .xctest + } + + /// Additional configuration information to guide the package editing + /// process. + public struct Configuration { + /// The test harness to use. + public var testHarness: TestHarness + + public init(testHarness: TestHarness = .default) { + self.testHarness = testHarness + } + } + + // Check if the package has a single target with that target's sources located + // directly in `./Sources`. If so, move the sources into a folder named after + // the target before adding a new target. + package static func moveSingleTargetSources( + packagePath: AbsolutePath, + manifest: SourceFileSyntax, + fileSystem: any FileSystem, + verbose: Bool = false + ) throws { + // 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 + } + + if let arg = packageCall.findArgument(labeled: "targets") { + guard let argArray = arg.expression.findArrayArgument() else { + throw ManifestEditError.cannotFindArrayLiteralArgument( + argumentName: "targets", + node: Syntax(arg.expression) + ) + } + + // Check the contents of the `targets` array to see if there is only one target defined. + guard argArray.elements.count == 1, + let firstTarget = argArray.elements.first?.expression.as(FunctionCallExprSyntax.self), + let targetStringLiteral = firstTarget.arguments.first?.expression.as(StringLiteralExprSyntax.self) else { + return + } + + let targetName = targetStringLiteral.segments.description + let sourcesFolder = packagePath.appending("Sources") + let expectedTargetFolder = sourcesFolder.appending(targetName) + + // If there is one target then pull its name out and use that to look for a folder in `Sources/TargetName`. + // If the folder doesn't exist then we know we have a single target package and we need to migrate files + // into this folder before we can add a new target. + if !fileSystem.isDirectory(expectedTargetFolder) { + if verbose { + print( + """ + Moving existing files from \( + sourcesFolder.relative(to: packagePath) + ) to \( + expectedTargetFolder.relative(to: packagePath) + )... + """, + terminator: "" + ) + } + let contentsToMove = try fileSystem.getDirectoryContents(sourcesFolder) + try fileSystem.createDirectory(expectedTargetFolder) + for file in contentsToMove { + let source = sourcesFolder.appending(file) + let destination = expectedTargetFolder.appending(file) + try fileSystem.move(from: source, to: destination) + } + if verbose { + print(" done.") + } + } + } + } + + /// Add the given target to the manifest, producing a set of edit results + /// that updates the manifest and adds some source files to stub out the + /// new target. + public static func addTarget( + _ target: TargetDescription, + to manifest: SourceFileSyntax, + configuration: Configuration = .init(), + installedSwiftPMConfiguration: InstalledSwiftPMConfiguration = .default + ) 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 + } + + // Create a mutable version of target to which we can add more + // content when needed. + var target = target + + // Add dependencies needed for various targets. + switch target.type { + case .macro: + // Macro targets need to depend on a couple of libraries from + // SwiftSyntax. + target.dependencies.append(contentsOf: macroTargetDependencies) + + default: + break + } + + var newPackageCall = try packageCall.appendingToArrayArgument( + label: "targets", + trailingLabels: Self.argumentLabelsAfterTargets, + newElement: target.asSyntax() + ) + + let outerDirectory: String? = switch target.type { + case .binary, .plugin, .system: nil + case .executable, .regular, .macro: "Sources" + case .test: "Tests" + } + + guard let outerDirectory else { + return PackageEditResult( + manifestEdits: [ + .replace(packageCall, with: newPackageCall.description), + ] + ) + } + + let outerPath = try RelativePath(validating: outerDirectory) + + /// The set of auxiliary files this refactoring will create. + var auxiliaryFiles: AuxiliaryFiles = [] + + // Add the primary source file. Every target type has this. + self.addPrimarySourceFile( + outerPath: outerPath, + target: target, + configuration: configuration, + to: &auxiliaryFiles + ) + + // Perform any other actions that are needed for this target type. + var extraManifestEdits: [SourceEdit] = [] + switch target.type { + case .macro: + self.addProvidedMacrosSourceFile( + outerPath: outerPath, + target: target, + to: &auxiliaryFiles + ) + + if !manifest.description.contains("swift-syntax") { + newPackageCall = try AddPackageDependency + .addPackageDependencyLocal( + .swiftSyntax( + configuration: installedSwiftPMConfiguration + ), + to: newPackageCall + ) + + // Look for the first import declaration and insert an + // import of `CompilerPluginSupport` there. + let newImport = "import CompilerPluginSupport\n" + for node in manifest.statements { + if let importDecl = node.item.as(ImportDeclSyntax.self) { + let insertPos = importDecl + .positionAfterSkippingLeadingTrivia + extraManifestEdits.append( + SourceEdit( + range: insertPos ..< insertPos, + replacement: newImport + ) + ) + break + } + } + } + + default: break + } + + return PackageEditResult( + manifestEdits: [ + .replace(packageCall, with: newPackageCall.description), + ] + extraManifestEdits, + auxiliaryFiles: auxiliaryFiles + ) + } + + /// Add the primary source file for a target to the list of auxiliary + /// source files. + fileprivate static func addPrimarySourceFile( + outerPath: RelativePath, + target: TargetDescription, + configuration: Configuration, + to auxiliaryFiles: inout AuxiliaryFiles + ) { + let sourceFilePath = outerPath.appending( + components: [target.name, "\(target.name).swift"] + ) + + // Introduce imports for each of the dependencies that were specified. + var importModuleNames = target.dependencies.map(\.name) + + // Add appropriate test module dependencies. + if target.type == .test { + switch configuration.testHarness { + case .none: + break + + case .xctest: + importModuleNames.append("XCTest") + + case .swiftTesting: + importModuleNames.append("Testing") + } + } + + let importDecls = importModuleNames.lazy.sorted().map { name in + DeclSyntax("import \(raw: name)").with(\.trailingTrivia, .newline) + } + + let imports = CodeBlockItemListSyntax { + for importDecl in importDecls { + importDecl + } + } + + let sourceFileText: SourceFileSyntax = switch target.type { + case .binary, .plugin, .system: + fatalError("should have exited above") + + case .macro: + """ + \(imports) + struct \(raw: target.sanitizedName): Macro { + /// TODO: Implement one or more of the protocols that inherit + /// from Macro. The appropriate macro protocol is determined + /// by the "macro" declaration that \(raw: target.sanitizedName) implements. + /// Examples include: + /// @freestanding(expression) macro --> ExpressionMacro + /// @attached(member) macro --> MemberMacro + } + """ + + case .test: + switch configuration.testHarness { + case .none: + """ + \(imports) + // Test code here + """ + + case .xctest: + """ + \(imports) + class \(raw: target.sanitizedName)Tests: XCTestCase { + func test\(raw: target.sanitizedName)() { + XCTAssertEqual(42, 17 + 25) + } + } + """ + + case .swiftTesting: + """ + \(imports) + @Suite + struct \(raw: target.sanitizedName)Tests { + @Test("\(raw: target.sanitizedName) tests") + func example() { + #expect(42 == 17 + 25) + } + } + """ + } + + case .regular: + """ + \(imports) + """ + + case .executable: + """ + \(imports) + @main + struct \(raw: target.sanitizedName)Main { + static func main() { + print("Hello, world") + } + } + """ + } + + auxiliaryFiles.addSourceFile( + path: sourceFilePath, + sourceCode: sourceFileText + ) + } + + /// Add a file that introduces the main entrypoint and provided macros + /// for a macro target. + fileprivate static func addProvidedMacrosSourceFile( + outerPath: RelativePath, + target: TargetDescription, + to auxiliaryFiles: inout AuxiliaryFiles + ) { + auxiliaryFiles.addSourceFile( + path: outerPath.appending( + components: [target.name, "ProvidedMacros.swift"] + ), + sourceCode: """ + import SwiftCompilerPlugin + + @main + struct \(raw: target.sanitizedName)Macros: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + \(raw: target.sanitizedName).self, + ] + } + """ + ) + } +} + +extension TargetDescription.Dependency { + /// Retrieve the name of the dependency + fileprivate var name: String { + switch self { + case .target(name: let name, condition: _), + .byName(name: let name, condition: _), + .product(name: let name, package: _, moduleAliases: _, condition: _): + name + } + } +} + +/// The array of auxiliary files that can be added by a package editing +/// operation. +private typealias AuxiliaryFiles = [(RelativePath, SourceFileSyntax)] + +extension AuxiliaryFiles { + /// Add a source file to the list of auxiliary files. + fileprivate mutating func addSourceFile( + path: RelativePath, + sourceCode: SourceFileSyntax + ) { + self.append((path, sourceCode)) + } +} + +/// The set of dependencies we need to introduce to a newly-created macro +/// target. +private let macroTargetDependencies: [TargetDescription.Dependency] = [ + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), +] + +/// The package dependency for swift-syntax, for use in macros. +extension MappablePackageDependency.Kind { + /// Source control URL for the swift-syntax package. + fileprivate static var swiftSyntaxURL: SourceControlURL { + "https://github.com/swiftlang/swift-syntax.git" + } + + /// Package dependency on the swift-syntax package. + fileprivate static func swiftSyntax( + configuration: InstalledSwiftPMConfiguration + ) -> MappablePackageDependency.Kind { + let swiftSyntaxVersionDefault = configuration + .swiftSyntaxVersionForMacroTemplate + let swiftSyntaxVersion = Version(swiftSyntaxVersionDefault.description)! + + return .sourceControl( + name: nil, + location: self.swiftSyntaxURL.absoluteString, + requirement: .range(.upToNextMajor(from: swiftSyntaxVersion)) + ) + } +} + +extension TargetDescription { + fileprivate var sanitizedName: String { + self.name + .spm_mangledToC99ExtendedIdentifier() + .localizedFirstWordCapitalized() + } +} + +extension String { + fileprivate func localizedFirstWordCapitalized() -> String { prefix(1).localizedCapitalized + dropFirst() } +} diff --git a/Sources/PackageModelSyntax/ProductDescription+Syntax.swift b/Sources/PackageModelSyntax/ProductDescription+Syntax.swift index d12cef5e31b..614d5db8bf4 100644 --- a/Sources/PackageModelSyntax/ProductDescription+Syntax.swift +++ b/Sources/PackageModelSyntax/ProductDescription+Syntax.swift @@ -29,7 +29,6 @@ extension ProductDescription: ManifestSyntaxRepresentable { case .plugin: "plugin" case .snippet: "snippet" case .test: "test" - case .template: "template" } } diff --git a/Sources/PackageModelSyntax/TargetDescription+Syntax.swift b/Sources/PackageModelSyntax/TargetDescription+Syntax.swift index 9f5ff2299ae..eed59e5fcae 100644 --- a/Sources/PackageModelSyntax/TargetDescription+Syntax.swift +++ b/Sources/PackageModelSyntax/TargetDescription+Syntax.swift @@ -27,7 +27,6 @@ extension TargetDescription: ManifestSyntaxRepresentable { case .regular: "target" case .system: "systemLibrary" case .test: "testTarget" - case .template: "template" } } diff --git a/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift b/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift index 55984aa1532..6bd5b8804c1 100644 --- a/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift +++ b/Sources/SPMBuildCore/BuildParameters/BuildParameters.swift @@ -307,15 +307,9 @@ public struct BuildParameters: Encodable { try RelativePath(validating: "\(name)\(self.suffix)\(self.triple.executableExtension)") } - package func templatePath(for name: String) throws -> Basics.RelativePath { - try RelativePath(validating: "\(name)\(self.suffix)\(self.triple.executableExtension)") //John-to-revisit - } - /// Returns the path to the binary of a product for the current build parameters, relative to the build directory. public func binaryRelativePath(for product: ResolvedProduct) throws -> Basics.RelativePath { switch product.type { - case .template: - return try templatePath(for: product.name) //john-to-revisit case .executable, .snippet: return try executablePath(for: product.name) case .library(.static): diff --git a/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift b/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift index 783c67b7c27..83b3b90321d 100644 --- a/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift +++ b/Sources/SPMBuildCore/Plugins/PluginContextSerializer.swift @@ -335,7 +335,7 @@ fileprivate extension WireInput.Target.TargetInfo.SourceModuleKind { switch kind { case .library: self = .generic - case .executable, .template: //john-to-revisit + case .executable: self = .executable case .snippet: self = .snippet diff --git a/Sources/SPMBuildCore/Plugins/PluginInvocation.swift b/Sources/SPMBuildCore/Plugins/PluginInvocation.swift index db345bceb8c..d86ef9ecad7 100644 --- a/Sources/SPMBuildCore/Plugins/PluginInvocation.swift +++ b/Sources/SPMBuildCore/Plugins/PluginInvocation.swift @@ -696,7 +696,7 @@ fileprivate func collectAccessibleTools( } } // For an executable target we create a `builtTool`. - else if executableOrBinaryModule.type == .executable || executableOrBinaryModule.type == .template { //john-to-revisit + else if executableOrBinaryModule.type == .executable { return try [.builtTool(name: builtToolName, path: RelativePath(validating: executableOrBinaryModule.name))] } else { diff --git a/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift b/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift index 49e600fff1f..b409518837d 100644 --- a/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift +++ b/Sources/SwiftBuildSupport/PackagePIFBuilder+Helpers.swift @@ -184,7 +184,7 @@ extension PackageModel.Module { switch self.type { case .executable, .snippet: true - case .library, .test, .macro, .systemModule, .plugin, .binary, .template: //john-to-revisit + case .library, .test, .macro, .systemModule, .plugin, .binary: false } } @@ -193,7 +193,7 @@ extension PackageModel.Module { switch self.type { case .binary: true - case .library, .executable, .snippet, .test, .plugin, .macro, .systemModule, .template: + case .library, .executable, .snippet, .test, .plugin, .macro, .systemModule: false } } @@ -203,7 +203,7 @@ extension PackageModel.Module { switch self.type { case .library, .executable, .snippet, .test, .macro: true - case .systemModule, .plugin, .binary, .template: //john-to-revisit + case .systemModule, .plugin, .binary: false } } @@ -218,7 +218,6 @@ extension PackageModel.ProductType { case .library: .library case .plugin: .plugin case .macro: .macro - case .template: .template } } } @@ -688,7 +687,7 @@ extension PackageGraph.ResolvedProduct { /// (e.g., executables have one executable module, test bundles have one test module, etc). var isMainModuleProduct: Bool { switch self.type { - case .executable, .snippet, .test, .template: //john-to-revisit + case .executable, .snippet, .test: true case .library, .macro, .plugin: false @@ -706,7 +705,7 @@ extension PackageGraph.ResolvedProduct { var isExecutable: Bool { switch self.type { - case .executable, .snippet, .template: //john-to-revisit + case .executable, .snippet: true case .library, .test, .plugin, .macro: false @@ -749,7 +748,7 @@ extension PackageGraph.ResolvedProduct { /// Shoud we link this product dependency? var isLinkable: Bool { switch self.type { - case .library, .executable, .snippet, .test, .macro, .template: //john-to-revisit + case .library, .executable, .snippet, .test, .macro: true case .plugin: false diff --git a/Sources/SwiftBuildSupport/PackagePIFBuilder.swift b/Sources/SwiftBuildSupport/PackagePIFBuilder.swift index 21ba0e129e7..65b303f68b4 100644 --- a/Sources/SwiftBuildSupport/PackagePIFBuilder.swift +++ b/Sources/SwiftBuildSupport/PackagePIFBuilder.swift @@ -459,9 +459,6 @@ public final class PackagePIFBuilder { case .macro: break // TODO: Double-check what's going on here as we skip snippet modules too (rdar://147705448) - case .template: - // john-to-revisit: makeTemplateproduct - try projectBuilder.makeMainModuleProduct(product) } } @@ -498,11 +495,6 @@ public final class PackagePIFBuilder { case .macro: try projectBuilder.makeMacroModule(module) - - case .template: - // Skip template modules — they represent tools, not code to compile. john-to-revisit - break - } } @@ -699,7 +691,7 @@ extension PackagePIFBuilder.LinkedPackageBinary { case .library, .binary, .macro: self.init(name: module.name, packageName: packageName, type: .target) - case .systemModule, .plugin, .template: //john-to-revisit + case .systemModule, .plugin: return nil } } diff --git a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift index c82243c413e..e9521d0dfcb 100644 --- a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift +++ b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift @@ -96,10 +96,6 @@ extension PackagePIFProjectBuilder { platformFilters: dependencyPlatformFilters ) log(.debug, indent: 1, "Added dependency on target '\(dependencyGUID)'") - case .template: //john-to-revisit - // Template targets are used as tooling, not build dependencies. - // Plugins will invoke them when needed. - break } @@ -683,8 +679,6 @@ extension PackagePIFProjectBuilder { indent: 1, "Added \(shouldLinkProduct ? "linked " : "")dependency on target '\(moduleDependency.pifTargetGUID)'" ) - case .template: //john-to-revisit - break } case .product(let productDependency, let packageConditions): diff --git a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift index 970cc42f3fa..7292cddc7fe 100644 --- a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift +++ b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Products.swift @@ -451,9 +451,6 @@ extension PackagePIFProjectBuilder { indent: 1, "Added \(shouldLinkProduct ? "linked " : "")dependency on target '\(dependencyGUID)'" ) - case .template: - //john-to-revisit - break } case .product(let productDependency, let packageConditions): diff --git a/Sources/XCBuildSupport/PIFBuilder.swift b/Sources/XCBuildSupport/PIFBuilder.swift index 41627c65442..1b231f68592 100644 --- a/Sources/XCBuildSupport/PIFBuilder.swift +++ b/Sources/XCBuildSupport/PIFBuilder.swift @@ -386,7 +386,7 @@ final class PackagePIFProjectBuilder: PIFProjectBuilder { private func addTarget(for product: ResolvedProduct) throws { switch product.type { - case .executable, .snippet, .test, .template: //john-to-revisit + case .executable, .snippet, .test: try self.addMainModuleTarget(for: product) case .library: self.addLibraryTarget(for: product) @@ -414,8 +414,6 @@ final class PackagePIFProjectBuilder: PIFProjectBuilder { case .macro: // Macros are not supported when using XCBuild, similar to package plugins. return - case .template: //john-to-revisit - return } } @@ -1620,8 +1618,6 @@ extension ProductType { .plugin case .macro: .macro - case .template: - .template } } } diff --git a/Sources/_InternalTestSupport/ResolvedModule+Mock.swift b/Sources/_InternalTestSupport/ResolvedModule+Mock.swift index aa2818d49e0..0ec69b59f5e 100644 --- a/Sources/_InternalTestSupport/ResolvedModule+Mock.swift +++ b/Sources/_InternalTestSupport/ResolvedModule+Mock.swift @@ -30,7 +30,8 @@ extension ResolvedModule { dependencies: [], packageAccess: false, usesUnsafeFlags: false, - implicit: true + implicit: true, + template: false ), dependencies: deps.map { .module($0, conditions: conditions) }, defaultLocalization: nil, diff --git a/Tests/BuildTests/ClangTargetBuildDescriptionTests.swift b/Tests/BuildTests/ClangTargetBuildDescriptionTests.swift index 493f3db64df..ff462d0dba0 100644 --- a/Tests/BuildTests/ClangTargetBuildDescriptionTests.swift +++ b/Tests/BuildTests/ClangTargetBuildDescriptionTests.swift @@ -62,7 +62,8 @@ final class ClangTargetBuildDescriptionTests: XCTestCase { path: .root, sources: .init(paths: [.root.appending(component: "foo.c")], root: .root), usesUnsafeFlags: false, - implicit: true + implicit: true, + template: false ) } diff --git a/Tests/FunctionalTests/PluginTests.swift b/Tests/FunctionalTests/PluginTests.swift index 0f4d31c67f5..78bf5038a9e 100644 --- a/Tests/FunctionalTests/PluginTests.swift +++ b/Tests/FunctionalTests/PluginTests.swift @@ -518,6 +518,10 @@ final class PluginTests { .disabled() ) func testCommandPluginInvocation() async throws { + try XCTSkipIf(true, "test is disabled because it isn't stable, see rdar://117870608") + + // Only run the test if the environment in which we're running actually supports Swift concurrency (which the plugin APIs require). + try XCTSkipIf(!UserToolchain.default.supportsSwiftConcurrency(), "skipping because test environment doesn't support concurrency") // FIXME: This test is getting quite long — we should add some support functionality for creating synthetic plugin tests and factor this out into separate tests. try await testWithTemporaryDirectory { tmpPath in // Create a sample package with a library target and a plugin. It depends on a sample package. @@ -527,7 +531,7 @@ final class PluginTests { try localFileSystem.writeFileContents( manifestFile, string: """ - // swift-tools-version: 6.1 + // swift-tools-version: 5.6 import PackageDescription let package = Package( name: "MyPackage", @@ -579,15 +583,6 @@ final class PluginTests { """ ) - let templateSourceFile = packageDir.appending(components: "Sources", "GenerateStuff", "generatestuff.swift") - try localFileSystem.createDirectory(templateSourceFile.parentDirectory, recursive: true) - try localFileSystem.writeFileContents( - templateSourceFile, - string: """ - public func Foo() { } - """ - ) - let printingPluginSourceFile = packageDir.appending(components: "Plugins", "PluginPrintingInfo", "plugin.swift") try localFileSystem.createDirectory(printingPluginSourceFile.parentDirectory, recursive: true) try localFileSystem.writeFileContents( From 5cff0a7e3f30e25cedd814c7b23980f4b6795470 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Tue, 10 Jun 2025 11:29:21 -0400 Subject: [PATCH 166/225] Match whitespace and formatting to original main branch --- Sources/Build/BuildDescription/ProductBuildDescription.swift | 2 +- .../Build/BuildDescription/SwiftModuleBuildDescription.swift | 4 ++-- Sources/Build/BuildPlan/BuildPlan.swift | 1 - Sources/Commands/PackageCommands/PluginCommand.swift | 4 ++-- Sources/PackageDescription/Product.swift | 2 +- Sources/PackageGraph/ModulesGraph.swift | 2 +- Sources/PackageLoading/Diagnostics.swift | 2 +- .../SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift | 1 - Tests/FunctionalTests/PluginTests.swift | 1 - 9 files changed, 8 insertions(+), 11 deletions(-) diff --git a/Sources/Build/BuildDescription/ProductBuildDescription.swift b/Sources/Build/BuildDescription/ProductBuildDescription.swift index cd6f19ae8ef..5c91a7c11d6 100644 --- a/Sources/Build/BuildDescription/ProductBuildDescription.swift +++ b/Sources/Build/BuildDescription/ProductBuildDescription.swift @@ -245,7 +245,7 @@ public final class ProductBuildDescription: SPMBuildCore.ProductBuildDescription // Support for linking tests against executables is conditional on the tools // version of the package that defines the executable product. let executableTarget = try product.executableModule - if let target = executableTarget.underlying as? SwiftModule, + if let target = executableTarget.underlying as? SwiftModule, self.toolsVersion >= .v5_5, self.buildParameters.driverParameters.canRenameEntrypointFunctionName, target.supportsTestableExecutablesFeature diff --git a/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift b/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift index 9488548fb99..b6b6c20fffa 100644 --- a/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift +++ b/Sources/Build/BuildDescription/SwiftModuleBuildDescription.swift @@ -146,7 +146,7 @@ public final class SwiftModuleBuildDescription { // If we're an executable and we're not allowing test targets to link against us, we hide the module. let triple = buildParameters.triple let allowLinkingAgainstExecutables = [.coff, .macho, .elf].contains(triple.objectFormat) && self.toolsVersion >= .v5_5 - let dirPath = ((target.type == .executable) && !allowLinkingAgainstExecutables) ? self.tempsPath : self.modulesPath + let dirPath = (target.type == .executable && !allowLinkingAgainstExecutables) ? self.tempsPath : self.modulesPath return dirPath.appending(component: "\(self.target.c99name).swiftmodule") } @@ -205,7 +205,7 @@ public final class SwiftModuleBuildDescription { switch self.target.type { case .library, .test: return true - case .executable, .snippet, .macro: //john-to-revisit + case .executable, .snippet, .macro: // This deactivates heuristics in the Swift compiler that treats single-file modules and source files // named "main.swift" specially w.r.t. whether they can have an entry point. // diff --git a/Sources/Build/BuildPlan/BuildPlan.swift b/Sources/Build/BuildPlan/BuildPlan.swift index 5f101f9fbae..3b2830268cc 100644 --- a/Sources/Build/BuildPlan/BuildPlan.swift +++ b/Sources/Build/BuildPlan/BuildPlan.swift @@ -175,7 +175,6 @@ public class BuildPlan: SPMBuildCore.BuildPlan { /// as targets, but they are not directly included in the build graph. public let pluginDescriptions: [PluginBuildDescription] - /// The build targets. public var targets: AnySequence { AnySequence(self.targetMap.values) diff --git a/Sources/Commands/PackageCommands/PluginCommand.swift b/Sources/Commands/PackageCommands/PluginCommand.swift index 68ef07efd39..c019de468e0 100644 --- a/Sources/Commands/PackageCommands/PluginCommand.swift +++ b/Sources/Commands/PackageCommands/PluginCommand.swift @@ -37,7 +37,7 @@ struct PluginCommand: AsyncSwiftCommand { ) var listCommands: Bool = false - public struct PluginOptions: ParsableArguments { + struct PluginOptions: ParsableArguments { @Flag( name: .customLong("allow-writing-to-package-directory"), help: "Allow the plugin to write to the package directory." @@ -376,7 +376,7 @@ struct PluginCommand: AsyncSwiftCommand { let allowNetworkConnectionsCopy = allowNetworkConnections let buildEnvironment = buildParameters.buildEnvironment - let pluginOutput = try await pluginTarget.invoke( + _ = try await pluginTarget.invoke( action: .performCommand(package: package, arguments: arguments), buildEnvironment: buildEnvironment, scriptRunner: pluginScriptRunner, diff --git a/Sources/PackageDescription/Product.swift b/Sources/PackageDescription/Product.swift index 1f7efbeae3b..34239fe0c6b 100644 --- a/Sources/PackageDescription/Product.swift +++ b/Sources/PackageDescription/Product.swift @@ -170,7 +170,7 @@ public class Product { } /// Defines a product that vends a package plugin target for use by clients of the package. - /// + /// /// It is not necessary to define a product for a plugin that /// is only used within the same package where you define it. All the targets /// listed must be plugin targets in the same package as the product. Swift Package Manager diff --git a/Sources/PackageGraph/ModulesGraph.swift b/Sources/PackageGraph/ModulesGraph.swift index 026347fdd31..a5df0fb46e4 100644 --- a/Sources/PackageGraph/ModulesGraph.swift +++ b/Sources/PackageGraph/ModulesGraph.swift @@ -267,7 +267,7 @@ public struct ModulesGraph { }) return try Dictionary(throwingUniqueKeysWithValues: testModuleDeps) }() - + for module in rootModules where module.type == .executable { // Find all dependencies of this module within its package. Note that we do not traverse plugin usages. let dependencies = try topologicalSortIdentifiable(module.dependencies, successors: { diff --git a/Sources/PackageLoading/Diagnostics.swift b/Sources/PackageLoading/Diagnostics.swift index 5ec694a4403..2873ac5444e 100644 --- a/Sources/PackageLoading/Diagnostics.swift +++ b/Sources/PackageLoading/Diagnostics.swift @@ -38,7 +38,7 @@ extension Basics.Diagnostic { case .library(.automatic): typeString = "" case .executable, .snippet, .plugin, .test, .macro, - .library(.dynamic), .library(.static): + .library(.dynamic), .library(.static): typeString = " (\(product.type))" } diff --git a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift index e9521d0dfcb..727562b27dd 100644 --- a/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift +++ b/Sources/SwiftBuildSupport/PackagePIFProjectBuilder+Modules.swift @@ -97,7 +97,6 @@ extension PackagePIFProjectBuilder { ) log(.debug, indent: 1, "Added dependency on target '\(dependencyGUID)'") } - case .product(let productDependency, let packageConditions): // Do not add a dependency for binary-only executable products since they are not part of the build. diff --git a/Tests/FunctionalTests/PluginTests.swift b/Tests/FunctionalTests/PluginTests.swift index 78bf5038a9e..793ff457beb 100644 --- a/Tests/FunctionalTests/PluginTests.swift +++ b/Tests/FunctionalTests/PluginTests.swift @@ -582,7 +582,6 @@ final class PluginTests { public func Foo() { } """ ) - let printingPluginSourceFile = packageDir.appending(components: "Plugins", "PluginPrintingInfo", "plugin.swift") try localFileSystem.createDirectory(printingPluginSourceFile.parentDirectory, recursive: true) try localFileSystem.writeFileContents( From 9023ff61aaa8bc00d345c368e87b9528664779c8 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 11 Jun 2025 10:00:42 -0400 Subject: [PATCH 167/225] added requirements + ability to checkout a branch without creating new one --- Sources/Commands/PackageCommands/Init.swift | 49 +++++++++---------- Sources/SourceControl/GitRepository.swift | 14 ++++++ Sources/SourceControl/Repository.swift | 3 ++ .../InMemoryGitRepository.swift | 6 +++ 4 files changed, 46 insertions(+), 26 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 455cfc014cf..ab166084766 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -360,36 +360,33 @@ extension SwiftPackageCommand { - try repositoryProvider.createWorkingCopyFromBare(repository: repositorySpecifier, sourcePath: bareCopyPath, at: workingCopyPath, editable: true) + let repository = try repositoryProvider.createWorkingCopyFromBare(repository: repositorySpecifier, sourcePath: bareCopyPath, at: workingCopyPath, editable: true) try FileManager.default.removeItem(at: bareCopyPath.asURL) - return workingCopyPath + switch requirement { + case .range(let versionRange): + let tags = try repository.getTags() + let versions = tags.compactMap { Version($0) } + let filteredVersions = versions.filter { versionRange.contains($0) } + guard let latestVersion = filteredVersions.max() else { + throw InternalError("No tags found within the specified version range \(versionRange)") + } + try repository.checkout(tag: latestVersion.description) + + case .exact(let exactVersion): + try repository.checkout(tag: exactVersion.description) + + case .branch(let branchName): + try repository.checkout(branch: branchName) - // checkout according to requirement - /*switch requirement { - case .range(let versionRange): - let tags = try repository.getTags() - let versions = tags.compactMap { Version($0) } - let filteredVersions = versions.filter { versionRange.contains($0) } - guard let latestVersion = filteredVersions.max() else { - throw InternalError("No tags found within the specified version range \(versionRange)") - } - try repository.checkout(tag: latestVersion.description) - - case .exact(let exactVersion): - try repository.checkout(tag: exactVersion.description) - - case .branch(let branchName): - throw InternalError("No branch option available for fetching a single commit") - - case .revision(let revision): - try repository.checkout(revision: .init(identifier: revision)) - } - - */ - } + case .revision(let revision): + try repository.checkout(revision: .init(identifier: revision)) + } + + return workingCopyPath + } } return try await fetchStandalonePackageByURL() @@ -654,7 +651,7 @@ extension SwiftPackageCommand { for product in products { for targetName in product.targets { - if let target = targets.first(where: { _ in template == targetName }) { + if let target = targets.first(where: { $0.name == template }) { if let options = target.templateInitializationOptions { if case let .packageInit(templateType, _, _) = options { diff --git a/Sources/SourceControl/GitRepository.swift b/Sources/SourceControl/GitRepository.swift index f4965767f55..63556dadebf 100644 --- a/Sources/SourceControl/GitRepository.swift +++ b/Sources/SourceControl/GitRepository.swift @@ -772,6 +772,20 @@ public final class GitRepository: Repository, WorkingCheckout { } } + public func checkout(branch: String) throws { + guard self.isWorkingRepo else { + throw InternalError("This operation is only valid in a working repository") + } + // use barrier for write operations + try self.lock.withLock { + try callGit( + "checkout", + branch, + failureMessage: "Couldn't check out branch '\(branch)'" + ) + } + } + public func archive(to path: AbsolutePath) throws { guard self.isWorkingRepo else { throw InternalError("This operation is only valid in a working repository") diff --git a/Sources/SourceControl/Repository.swift b/Sources/SourceControl/Repository.swift index 71a438681c1..99825efc548 100644 --- a/Sources/SourceControl/Repository.swift +++ b/Sources/SourceControl/Repository.swift @@ -268,6 +268,9 @@ public protocol WorkingCheckout { /// Note: It is an error to provide a branch name which already exists. func checkout(newBranch: String) throws + /// Checkout out the given branch + func checkout(branch: String) throws + /// Returns true if there is an alternative store in the checkout and it is valid. func isAlternateObjectStoreValid(expected: AbsolutePath) -> Bool diff --git a/Sources/_InternalTestSupport/InMemoryGitRepository.swift b/Sources/_InternalTestSupport/InMemoryGitRepository.swift index cb8f5870534..1ea63738c7c 100644 --- a/Sources/_InternalTestSupport/InMemoryGitRepository.swift +++ b/Sources/_InternalTestSupport/InMemoryGitRepository.swift @@ -383,6 +383,12 @@ extension InMemoryGitRepository: WorkingCheckout { } } + public func checkout(branch: String) throws { + self.lock.withLock { + self.history[branch] = head + } + } + public func isAlternateObjectStoreValid(expected: AbsolutePath) -> Bool { return true } From 0d10aa928a21be6e750b3990f306789c8c8bf9a6 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 11 Jun 2025 10:32:01 -0400 Subject: [PATCH 168/225] added git functionality to show-templates --- .../PackageCommands/ShowTemplates.swift | 168 +++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index b304db93d8a..97c2d62649a 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -17,6 +17,9 @@ import Foundation import PackageModel import PackageGraph import Workspace +import TSCUtility +import TSCBasic +import SourceControl struct ShowTemplates: AsyncSwiftCommand { static let configuration = CommandConfiguration( @@ -25,11 +28,60 @@ struct ShowTemplates: AsyncSwiftCommand { @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions + @Option(name: .customLong("template-url"), help: "The git URL of the template.") + var templateURL: String? + + // Git-specific options + @Option(help: "The exact package version to depend on.") + var exact: Version? + + @Option(help: "The specific package revision to depend on.") + var revision: String? + + @Option(help: "The branch of the package to depend on.") + var branch: String? + + @Option(help: "The package version to depend on (up to the next major version).") + var from: Version? + + @Option(help: "The package version to depend on (up to the next minor version).") + var upToNextMinorFrom: Version? + + @Option(help: "Specify upper bound on the package version range (exclusive).") + var to: Version? + @Option(help: "Set the output format.") var format: ShowTemplatesMode = .flatlist func run(_ swiftCommandState: SwiftCommandState) async throws { - let packageGraph = try await swiftCommandState.loadPackageGraph() + + let packagePath: Basics.AbsolutePath + var deleteAfter = false + + // Use local current directory or fetch Git package + if let templateURL = self.templateURL { + let requirement = try checkRequirements() + packagePath = try await getPackageFromGit(destination: templateURL, requirement: requirement) + deleteAfter = true + } else { + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("No template URL provided and no current directory") + } + packagePath = cwd + } + + defer { + if deleteAfter { + try? FileManager.default.removeItem(atPath: packagePath.pathString) + } + } + + + let packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: packagePath) { workspace, root in + return try await swiftCommandState.loadPackageGraph() + + } + let rootPackages = packageGraph.rootPackages.map { $0.identity } let templates = packageGraph.allModules.filter({ @@ -87,4 +139,118 @@ struct ShowTemplates: AsyncSwiftCommand { } } } + + func getPackageFromGit( + destination: String, + requirement: PackageDependency.SourceControl.Requirement + ) async throws -> Basics.AbsolutePath { + let repositoryProvider = GitRepositoryProvider() + + let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in + try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in + let url = SourceControlURL(destination) + let repositorySpecifier = RepositorySpecifier(url: url) + + // This is the working clone destination + let bareCopyPath = tempDir.appending(component: "bare-copy") + + let workingCopyPath = tempDir.appending(component: "working-copy") + + try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) + + try FileManager.default.createDirectory(atPath: workingCopyPath.pathString, withIntermediateDirectories: true) + + // Validate directory (now should exist) + guard try repositoryProvider.isValidDirectory(bareCopyPath) else { + throw InternalError("Invalid directory at \(workingCopyPath)") + } + + + + let repository = try repositoryProvider.createWorkingCopyFromBare(repository: repositorySpecifier, sourcePath: bareCopyPath, at: workingCopyPath, editable: true) + + + try FileManager.default.removeItem(at: bareCopyPath.asURL) + + switch requirement { + case .range(let versionRange): + let tags = try repository.getTags() + let versions = tags.compactMap { Version($0) } + let filteredVersions = versions.filter { versionRange.contains($0) } + guard let latestVersion = filteredVersions.max() else { + throw InternalError("No tags found within the specified version range \(versionRange)") + } + try repository.checkout(tag: latestVersion.description) + + case .exact(let exactVersion): + try repository.checkout(tag: exactVersion.description) + + case .branch(let branchName): + try repository.checkout(branch: branchName) + + case .revision(let revision): + try repository.checkout(revision: .init(identifier: revision)) + } + + return workingCopyPath + } + } + + return try await fetchStandalonePackageByURL() + } + + + func checkRequirements() throws -> PackageDependency.SourceControl.Requirement { + var requirements : [PackageDependency.SourceControl.Requirement] = [] + + if let exact { + requirements.append(.exact(exact)) + } + + if let branch { + requirements.append(.branch(branch)) + } + + if let revision { + requirements.append(.revision(revision)) + } + + if let from { + requirements.append(.range(.upToNextMajor(from: from))) + } + + if let upToNextMinorFrom { + requirements.append(.range(.upToNextMinor(from: upToNextMinorFrom))) + } + + if requirements.count > 1 { + throw StringError( + "must specify at most one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" + ) + } + + guard let firstRequirement = requirements.first else { + throw StringError( + "must specify one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" + ) + } + + let requirement: PackageDependency.SourceControl.Requirement + if case .range(let range) = firstRequirement { + if let to { + requirement = .range(range.lowerBound ..< to) + } else { + requirement = .range(range) + } + } else { + requirement = firstRequirement + + if self.to != nil { + throw StringError("--to can only be specified with --from or --up-to-next-minor-from") + } + } + return requirement + + } + } From 22b18b2e4d0da5e3bb4975fb405aca5263f8fa23 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 11 Jun 2025 13:55:32 -0400 Subject: [PATCH 169/225] refactoring and organizing code --- Sources/Commands/PackageCommands/Init.swift | 654 ++++-------------- .../RequirementResolver.swift | 46 ++ .../_InternalInitSupport/TemplateBuild.swift | 42 ++ .../TemplatePathResolver.swift | 137 ++++ .../TemplatePluginRunner.swift | 173 +++++ .../TestingLibrarySupport.swift | 39 ++ 6 files changed, 564 insertions(+), 527 deletions(-) create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index ab166084766..ce0088c7b6d 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -35,13 +35,13 @@ extension SwiftPackageCommand { public static let configuration = CommandConfiguration( abstract: "Initialize a new package.", ) - + @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions - + @OptionGroup(visibility: .hidden) var buildOptions: BuildCommandOptions - + @Option( name: .customLong("type"), help: ArgumentHelp("Package type:", discussion: """ @@ -56,214 +56,61 @@ extension SwiftPackageCommand { empty - An empty package with a Package.swift manifest. """)) var initMode: InitPackage.PackageType = .library - + /// Which testing libraries to use (and any related options.) @OptionGroup() var testLibraryOptions: TestLibraryOptions - + @Option(name: .customLong("name"), help: "Provide custom package name.") var packageName: String? - + // This command should support creating the supplied --package-path if it isn't created. var createPackagePath = true - + @Option(name: .customLong("template"), help: "Name of a template to initialize the package.") var template: String = "" var useTemplates: Bool { !template.isEmpty } - + @Option(name: .customLong("template-type"), help: "Template type: registry, git, local.") var templateType: InitTemplatePackage.TemplateType? - + @Option(name: .customLong("template-path"), help: "Path to the local template.", completion: .directory) var templateDirectory: Basics.AbsolutePath? - + @Option(name: .customLong("template-url"), help: "The git URL of the template.") var templateURL: String? - + // Git-specific options @Option(help: "The exact package version to depend on.") var exact: Version? - + @Option(help: "The specific package revision to depend on.") var revision: String? - + @Option(help: "The branch of the package to depend on.") var branch: String? - + @Option(help: "The package version to depend on (up to the next major version).") var from: Version? - + @Option(help: "The package version to depend on (up to the next minor version).") var upToNextMinorFrom: Version? - + @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? - - //swift package init --template woof --template-type local --template-path path here - - //first check the path and see if the template woof is actually there - //if yes, build and get the templateInitializationOptions from it - // read templateInitializationOptions and parse permissions + type of package to initialize - // once read, initialize barebones package with what is needed, and add dependency to local template product - // swift build, then call --experimental-dump-help on the product - // prompt user - // run the executable with the command line stuff - - /// Returns the resolved template path for a given template source. - func resolveTemplatePath() async throws -> Basics.AbsolutePath { - switch templateType { - case .local: - guard let path = templateDirectory else { - throw InternalError("Template path must be specified for local templates.") - } - return path - - case .git: - - let requirement = try checkRequirements() - if let templateURL = templateURL{ - return try await getPackageFromGit(destination: templateURL, requirement: requirement) - } else { - throw StringError("did not specify template URL") - } - - case .registry: - // TODO: Lookup and download from registry - throw StringError("Registry-based templates not yet implemented") - - case .none: - throw InternalError("Missing --template-type for --template") - } - } - - - - //first, + func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") } - + let packageName = self.packageName ?? cwd.basename - - // Testing is on by default, with XCTest only enabled explicitly. - // For macros this is reversed, since we don't support testing - // macros with Swift Testing yet. + if useTemplates { - let resolvedTemplatePath = try await resolveTemplatePath() - - let templateInitType = try await swiftCommandState.withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { workspace, root in - return try await checkConditions(swiftCommandState) - } - - if templateType == .git { - try FileManager.default.removeItem(at: resolvedTemplatePath.asURL) - } - - var supportedTemplateTestingLibraries = Set() - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (templateInitType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { - supportedTemplateTestingLibraries.insert(.xctest) - } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (templateInitType != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { - supportedTemplateTestingLibraries.insert(.swiftTesting) - } - - func packageDependency() throws -> MappablePackageDependency.Kind { - switch templateType { - case .local: - return .fileSystem(name: packageName, path: resolvedTemplatePath.asURL.path) - - case .git: - guard let url = templateURL else { - throw StringError("Missing Git url") - } - return try .sourceControl(name: packageName, location: url, requirement: checkRequirements()) - - default: - throw StringError("Not implemented yet") - } - } - let initTemplatePackage = try InitTemplatePackage( - name: packageName, - templateName: template, - initMode: packageDependency(), - templatePath: resolvedTemplatePath, - fileSystem: swiftCommandState.fileSystem, - packageType: templateInitType, - supportedTestingLibraries: supportedTemplateTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration - ) - - try initTemplatePackage.setupTemplateManifest() - - // Build system setup - let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in - try await swiftCommandState.createBuildSystem( - explicitProduct: buildOptions.product, - traitConfiguration: .init(traitOptions: buildOptions.traits), - shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: swiftCommandState.toolsBuildParameters, - outputStream: TSCBasic.stdoutStream - ) - } - - guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { - throw ExitCode.failure - } - - try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in - do { - try await buildSystem.build(subset: subset) - } catch _ as Diagnostics { - throw ExitCode.failure - } - } - - let packageGraph = try await swiftCommandState.loadPackageGraph() - let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) - - - - let output = try await Self.run( - plugin: matchingPlugins[0], - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: ["--", "--experimental-dump-help"], - swiftCommandState: swiftCommandState - ) - - let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let response = try initTemplatePackage.promptUser(tool: toolInfo) - do { - - let _ = try await Self.run( - plugin: matchingPlugins[0], - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: response, - swiftCommandState: swiftCommandState - ) - - - } - + try await runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) } else { - - var supportedTestingLibraries = Set() - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.xctest) - } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.swiftTesting) - } - - + let supportedTestingLibraries = computeSupportedTestingLibraries(for: testLibraryOptions, initMode: initMode, swiftCommandState: swiftCommandState) + let initPackage = try InitPackage( name: packageName, packageType: initMode, @@ -272,372 +119,105 @@ extension SwiftPackageCommand { installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, fileSystem: swiftCommandState.fileSystem ) - + initPackage.progressReporter = { message in print(message) } - + try initPackage.writePackageStructure() } } - - func checkRequirements() throws -> PackageDependency.SourceControl.Requirement { - var requirements : [PackageDependency.SourceControl.Requirement] = [] - - if let exact { - requirements.append(.exact(exact)) - } - - if let branch { - requirements.append(.branch(branch)) - } - - if let revision { - requirements.append(.revision(revision)) - } - - if let from { - requirements.append(.range(.upToNextMajor(from: from))) - } - - if let upToNextMinorFrom { - requirements.append(.range(.upToNextMinor(from: upToNextMinorFrom))) - } - - if requirements.count > 1 { - throw StringError( - "must specify at most one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" - ) - } - - guard let firstRequirement = requirements.first else { - throw StringError( - "must specify one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" - ) + + private func runTemplateInit(swiftCommandState: SwiftCommandState, packageName: String, cwd: Basics.AbsolutePath) async throws { + + let resolvedTemplatePath: Basics.AbsolutePath + var requirement: PackageDependency.SourceControl.Requirement? + + switch self.templateType { + case .git: + requirement = try DependencyRequirementResolver( + exact: self.exact, + revision: self.revision, + branch: self.branch, + from: self.from, + upToNextMinorFrom: self.upToNextMinorFrom, + to: self.to + ).resolve() + + resolvedTemplatePath = try await TemplatePathResolver( + templateType: self.templateType, + templateDirectory: self.templateDirectory, + templateURL: self.templateURL, + requirement: requirement + ).resolve() + + case .local, .registry: + resolvedTemplatePath = try await TemplatePathResolver( + templateType: self.templateType, + templateDirectory: self.templateDirectory, + templateURL: self.templateURL, + requirement: nil + ).resolve() + + case .none: + throw StringError("Missing template type") } - - let requirement: PackageDependency.SourceControl.Requirement - if case .range(let range) = firstRequirement { - if let to { - requirement = .range(range.lowerBound ..< to) - } else { - requirement = .range(range) - } - } else { - requirement = firstRequirement - - if self.to != nil { - throw StringError("--to can only be specified with --from or --up-to-next-minor-from") - } + + let templateInitType = try await swiftCommandState.withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { workspace, root in + return try await checkConditions(swiftCommandState) } - return requirement - - } - func getPackageFromGit( - destination: String, - requirement: PackageDependency.SourceControl.Requirement - ) async throws -> Basics.AbsolutePath { - let repositoryProvider = GitRepositoryProvider() - - let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in - try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in - let url = SourceControlURL(destination) - let repositorySpecifier = RepositorySpecifier(url: url) - - // This is the working clone destination - let bareCopyPath = tempDir.appending(component: "bare-copy") - - let workingCopyPath = tempDir.appending(component: "working-copy") - - try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) - - try FileManager.default.createDirectory(atPath: workingCopyPath.pathString, withIntermediateDirectories: true) - - // Validate directory (now should exist) - guard try repositoryProvider.isValidDirectory(bareCopyPath) else { - throw InternalError("Invalid directory at \(workingCopyPath)") - } - - - - let repository = try repositoryProvider.createWorkingCopyFromBare(repository: repositorySpecifier, sourcePath: bareCopyPath, at: workingCopyPath, editable: true) - - - try FileManager.default.removeItem(at: bareCopyPath.asURL) - - switch requirement { - case .range(let versionRange): - let tags = try repository.getTags() - let versions = tags.compactMap { Version($0) } - let filteredVersions = versions.filter { versionRange.contains($0) } - guard let latestVersion = filteredVersions.max() else { - throw InternalError("No tags found within the specified version range \(versionRange)") - } - try repository.checkout(tag: latestVersion.description) - - case .exact(let exactVersion): - try repository.checkout(tag: exactVersion.description) - - case .branch(let branchName): - try repository.checkout(branch: branchName) - - case .revision(let revision): - try repository.checkout(revision: .init(identifier: revision)) - } - - return workingCopyPath - } + + if templateType == .git { + try FileManager.default.removeItem(at: resolvedTemplatePath.asURL) } - - return try await fetchStandalonePackageByURL() - } - - - static func run( - plugin: ResolvedModule, - package: ResolvedPackage, - packageGraph: ModulesGraph, - allowNetworkConnections: [SandboxNetworkPermission] = [], - arguments: [String], - swiftCommandState: SwiftCommandState - ) async throws -> Data { - let pluginTarget = plugin.underlying as! PluginModule - - // The `plugins` directory is inside the workspace's main data directory, and contains all temporary files related to this plugin in the workspace. - let pluginsDir = try swiftCommandState.getActiveWorkspace().location.pluginWorkingDirectory - .appending(component: plugin.name) - - // The `cache` directory is in the plugin’s directory and is where the plugin script runner caches compiled plugin binaries and any other derived information for this plugin. - let pluginScriptRunner = try swiftCommandState.getPluginScriptRunner( - customPluginsDir: pluginsDir + + let supportedTemplateTestingLibraries = computeSupportedTestingLibraries(for: testLibraryOptions, initMode: templateInitType, swiftCommandState: swiftCommandState) + + let initTemplatePackage = try InitTemplatePackage( + name: packageName, + templateName: template, + initMode: packageDependency(requirement: requirement, resolvedTemplatePath: resolvedTemplatePath), + templatePath: resolvedTemplatePath, + fileSystem: swiftCommandState.fileSystem, + packageType: templateInitType, + supportedTestingLibraries: supportedTemplateTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration ) - - // The `outputs` directory contains subdirectories for each combination of package and command plugin. Each usage of a plugin has an output directory that is writable by the plugin, where it can write additional files, and to which it can configure tools to write their outputs, etc. - let outputDir = pluginsDir.appending("outputs") - - // Determine the set of directories under which plugins are allowed to write. We always include the output directory. - var writableDirectories = [outputDir] - - // FIXME: decide whether this permission needs to be explicitly requested by the plugin target, or not - writableDirectories.append(package.path) - - var allowNetworkConnections = allowNetworkConnections - - // If the plugin requires permissions, we ask the user for approval. - if case .command(_, let permissions) = pluginTarget.capability { - try permissions.forEach { - let permissionString: String - let reasonString: String - let remedyOption: String - - switch $0 { - case .writeToPackageDirectory(let reason): - //guard !options.allowWritingToPackageDirectory else { return } // permission already granted - permissionString = "write to the package directory" - reasonString = reason - remedyOption = "--allow-writing-to-package-directory" - case .allowNetworkConnections(let scope, let reason): - guard scope != .none else { return } // no need to prompt - //guard options.allowNetworkConnections != .init(scope) else { return } // permission already granted - - switch scope { - case .all, .local: - let portsString = scope.ports - .isEmpty ? "on all ports" : - "on ports: \(scope.ports.map { "\($0)" }.joined(separator: ", "))" - permissionString = "allow \(scope.label) network connections \(portsString)" - case .docker, .unixDomainSocket: - permissionString = "allow \(scope.label) connections" - case .none: - permissionString = "" // should not be reached - } - - reasonString = reason - // FIXME compute the correct reason for the type of network connection - remedyOption = - "--allow-network-connections 'Network connection is needed'" - } - - let problem = "Plugin ‘\(plugin.name)’ wants permission to \(permissionString)." - let reason = "Stated reason: “\(reasonString)”." - if swiftCommandState.outputStream.isTTY { - // We can ask the user directly, so we do so. - let query = "Allow this plugin to \(permissionString)?" - swiftCommandState.outputStream.write("\(problem)\n\(reason)\n\(query) (yes/no) ".utf8) - swiftCommandState.outputStream.flush() - let answer = readLine(strippingNewline: true) - // Throw an error if we didn't get permission. - if answer?.lowercased() != "yes" { - throw StringError("Plugin was denied permission to \(permissionString).") - } - } else { - // We can't ask the user, so emit an error suggesting passing the flag. - let remedy = "Use `\(remedyOption)` to allow this." - throw StringError([problem, reason, remedy].joined(separator: "\n")) - } - - switch $0 { - case .writeToPackageDirectory: - // Otherwise append the directory to the list of allowed ones. - writableDirectories.append(package.path) - case .allowNetworkConnections(let scope, _): - allowNetworkConnections.append(.init(scope)) - } - } - } - - // Make sure that the package path is read-only unless it's covered by any of the explicitly writable directories. - let readOnlyDirectories = writableDirectories - .contains { package.path.isDescendantOfOrEqual(to: $0) } ? [] : [package.path] - - // Use the directory containing the compiler as an additional search directory, and add the $PATH. - let toolSearchDirs = [try swiftCommandState.getTargetToolchain().swiftCompilerPath.parentDirectory] - + getEnvSearchPaths(pathString: Environment.current[.path], currentWorkingDirectory: .none) - - let buildParameters = try swiftCommandState.toolsBuildParameters - // Build or bring up-to-date any executable host-side tools on which this plugin depends. Add them and any binary dependencies to the tool-names-to-path map. - let buildSystem = try await swiftCommandState.createBuildSystem( - explicitBuildSystem: .native, - traitConfiguration: .init(), - cacheBuildManifest: false, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: buildParameters, - packageGraphLoader: { packageGraph } + + try initTemplatePackage.setupTemplateManifest() + + try await TemplateBuildSupport.build(swiftCommandState: swiftCommandState, buildOptions: buildOptions, globalOptions: globalOptions, cwd: cwd) + + let packageGraph = try await swiftCommandState.loadPackageGraph() + let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) + + let output = try await TemplatePluginRunner.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: ["--", "--experimental-dump-help"], + swiftCommandState: swiftCommandState ) - - let accessibleTools = try await plugin.preparePluginTools( - fileSystem: swiftCommandState.fileSystem, - environment: buildParameters.buildEnvironment, - for: try pluginScriptRunner.hostTriple - ) { name, _ in - // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies are not supported within a package, so if the tool happens to be from the same package, we instead find the executable that corresponds to the product. There is always one, because of autogeneration of implicit executables with the same name as the target if there isn't an explicit one. - try await buildSystem.build(subset: .product(name, for: .host)) - if let builtTool = try buildSystem.buildPlan.buildProducts.first(where: { - $0.product.name == name && $0.buildParameters.destination == .host - }) { - return try builtTool.binaryPath - } else { - return nil - } - } - - // Set up a delegate to handle callbacks from the command plugin. - let pluginDelegate = PluginDelegate(swiftCommandState: swiftCommandState, plugin: pluginTarget, echoOutput: false) - let delegateQueue = DispatchQueue(label: "plugin-invocation") - - // Run the command plugin. - - // TODO: use region based isolation when swift 6 is available - let writableDirectoriesCopy = writableDirectories - let allowNetworkConnectionsCopy = allowNetworkConnections - - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") - } - - guard let workingDirectory = swiftCommandState.options.locations.packageDirectory ?? swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find current working directory") + + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + let response = try initTemplatePackage.promptUser(tool: toolInfo) + do { + let _ = try await TemplatePluginRunner.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: response, + swiftCommandState: swiftCommandState + ) } - let buildEnvironment = buildParameters.buildEnvironment - try await pluginTarget.invoke( - action: .performCommand(package: package, arguments: arguments), - buildEnvironment: buildEnvironment, - scriptRunner: pluginScriptRunner, - workingDirectory: workingDirectory, - outputDirectory: outputDir, - toolSearchDirectories: toolSearchDirs, - accessibleTools: accessibleTools, - writableDirectories: writableDirectoriesCopy, - readOnlyDirectories: readOnlyDirectories, - allowNetworkConnections: allowNetworkConnectionsCopy, - pkgConfigDirectories: swiftCommandState.options.locations.pkgConfigDirectories, - sdkRootPath: buildParameters.toolchain.sdkRootPath, - fileSystem: swiftCommandState.fileSystem, - modulesGraph: packageGraph, - observabilityScope: swiftCommandState.observabilityScope, - callbackQueue: delegateQueue, - delegate: pluginDelegate - ) - - return pluginDelegate.lineBufferedOutput } - // Set up a delegate to handle callbacks from the command plugin. - let pluginDelegate = PluginDelegate(swiftCommandState: swiftCommandState, plugin: pluginTarget, echoOutput: false) - let delegateQueue = DispatchQueue(label: "plugin-invocation") - - // Run the command plugin. - - // TODO: use region based isolation when swift 6 is available - let writableDirectoriesCopy = writableDirectories - let allowNetworkConnectionsCopy = allowNetworkConnections - - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") - } - - guard let workingDirectory = swiftCommandState.options.locations.packageDirectory ?? swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find current working directory") - } - let buildEnvironment = buildParameters.buildEnvironment - try await pluginTarget.invoke( - action: .performCommand(package: package, arguments: arguments), - buildEnvironment: buildEnvironment, - scriptRunner: pluginScriptRunner, - workingDirectory: workingDirectory, - outputDirectory: outputDir, - toolSearchDirectories: toolSearchDirs, - accessibleTools: accessibleTools, - writableDirectories: writableDirectoriesCopy, - readOnlyDirectories: readOnlyDirectories, - allowNetworkConnections: allowNetworkConnectionsCopy, - pkgConfigDirectories: swiftCommandState.options.locations.pkgConfigDirectories, - sdkRootPath: buildParameters.toolchain.sdkRootPath, - fileSystem: swiftCommandState.fileSystem, - modulesGraph: packageGraph, - observabilityScope: swiftCommandState.observabilityScope, - callbackQueue: delegateQueue, - delegate: pluginDelegate - ) - - return pluginDelegate.lineBufferedOutput - } - - private func captureStdout(_ block: () async throws -> Void) async throws -> String { - let originalStdout = dup(fileno(stdout)) - - let pipe = Pipe() - let readHandle = pipe.fileHandleForReading - let writeHandle = pipe.fileHandleForWriting - - dup2(writeHandle.fileDescriptor, fileno(stdout)) - - - var output = "" - let outputQueue = DispatchQueue(label: "outputQueue") - let group = DispatchGroup() - group.enter() - - outputQueue.async { - let data = readHandle.readDataToEndOfFile() - output = String(data: data, encoding: .utf8) ?? "" - } - - - fflush(stdout) - writeHandle.closeFile() - - dup2(originalStdout, fileno(stdout)) - return output - } - // first save current activeWorkspace - //second switch activeWorkspace to the template Path - //third revert after conditions have been checked, (we will also get stuff needed for dpeende private func checkConditions(_ swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType{ let workspace = try swiftCommandState.getActiveWorkspace() let root = try swiftCommandState.getWorkspaceRoot() - + let rootManifests = try await workspace.loadRootManifests( packages: root.packages, observabilityScope: swiftCommandState.observabilityScope @@ -645,15 +225,15 @@ extension SwiftPackageCommand { guard let rootManifest = rootManifests.values.first else { throw InternalError("invalid manifests at \(root.packages)") } - + let products = rootManifest.products let targets = rootManifest.targets - + for product in products { for targetName in product.targets { if let target = targets.first(where: { $0.name == template }) { if let options = target.templateInitializationOptions { - + if case let .packageInit(templateType, _, _) = options { return try .init(from: templateType) } @@ -663,6 +243,26 @@ extension SwiftPackageCommand { } throw InternalError("Could not find template \(template)") } + + private func packageDependency(requirement: PackageDependency.SourceControl.Requirement?, resolvedTemplatePath: Basics.AbsolutePath) throws -> MappablePackageDependency.Kind { + switch templateType { + case .local: + return .fileSystem(name: packageName, path: resolvedTemplatePath.asURL.path) + + case .git: + guard let url = templateURL else { + throw StringError("Missing Git url") + } + + guard let gitRequirement = requirement else { + throw StringError("Missing Git requirement") + } + return .sourceControl(name: packageName, location: url, requirement: gitRequirement) + + default: + throw StringError("Not implemented yet") + } + } } } @@ -685,7 +285,7 @@ extension InitPackage.PackageType: ExpressibleByArgument { self = .empty } } - + } extension InitTemplatePackage.TemplateType: ExpressibleByArgument {} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift new file mode 100644 index 00000000000..f5f0f6ccdec --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// 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 PackageModel +import TSCBasic +import TSCUtility + +struct DependencyRequirementResolver { + let exact: Version? + let revision: String? + let branch: String? + let from: Version? + let upToNextMinorFrom: Version? + let to: Version? + + func resolve() throws -> PackageDependency.SourceControl.Requirement { + var all: [PackageDependency.SourceControl.Requirement] = [] + + if let v = exact { all.append(.exact(v)) } + if let b = branch { all.append(.branch(b)) } + if let r = revision { all.append(.revision(r)) } + if let f = from { all.append(.range(.upToNextMajor(from: f))) } + if let u = upToNextMinorFrom { all.append(.range(.upToNextMinor(from: u))) } + + guard all.count == 1, let requirement = all.first else { + throw StringError("Specify exactly one version requirement.") + } + + if case .range(let range) = requirement, let upper = to { + return .range(range.lowerBound ..< upper) + } else if to != nil { + throw StringError("--to requires --from or --up-to-next-minor-from") + } + + return requirement + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift new file mode 100644 index 00000000000..ad8814ee52d --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -0,0 +1,42 @@ +// +// TemplateBuild.swift +// SwiftPM +// +// Created by John Bute on 2025-06-11. +// + + +import CoreCommands +import Basics +import TSCBasic +import ArgumentParser +import TSCUtility +import SPMBuildCore + +struct TemplateBuildSupport { + static func build(swiftCommandState: SwiftCommandState, buildOptions: BuildCommandOptions, globalOptions: GlobalOptions, cwd: Basics.AbsolutePath) async throws { + let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + try await swiftCommandState.createBuildSystem( + explicitProduct: buildOptions.product, + traitConfiguration: .init(traitOptions: buildOptions.traits), + shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: swiftCommandState.toolsBuildParameters, + outputStream: TSCBasic.stdoutStream + ) + } + + guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { + throw ExitCode.failure + } + + try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + do { + try await buildSystem.build(subset: subset) + } catch _ as Diagnostics { + throw ExitCode.failure + } + } + + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift new file mode 100644 index 00000000000..f0c2506e214 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -0,0 +1,137 @@ +//===----------------------------------------------------------------------===// +// +// 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 Workspace +import Basics +import PackageModel +import TSCBasic +import SourceControl +import Foundation +import TSCUtility + + +struct TemplatePathResolver { + let templateType: InitTemplatePackage.TemplateType? + let templateDirectory: Basics.AbsolutePath? + let templateURL: String? + let requirement: PackageDependency.SourceControl.Requirement? + + func resolve() async throws -> Basics.AbsolutePath { + switch templateType { + case .local: + guard let path = templateDirectory else { + throw StringError("Template path must be specified for local templates.") + } + return path + + case .git: + guard let url = templateURL else { + throw StringError("Missing Git URL for git template.") + } + + guard let gitRequirement = requirement else { + throw StringError("Missing version requirement for git template.") + } + + return try await GitTemplateFetcher(destination: url, requirement: gitRequirement).fetch() + + case .registry: + throw StringError("Registry templates not supported yet.") + + case .none: + throw StringError("Missing --template-type.") + } + } + + struct GitTemplateFetcher { + let destination: String + let requirement: PackageDependency.SourceControl.Requirement + + func fetch() async throws -> Basics.AbsolutePath { + + let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in + try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in + + let url = SourceControlURL(destination) + let repositorySpecifier = RepositorySpecifier(url: url) + let repositoryProvider = GitRepositoryProvider() + + + let bareCopyPath = tempDir.appending(component: "bare-copy") + + let workingCopyPath = tempDir.appending(component: "working-copy") + + try fetchBareRepository(provider: repositoryProvider, specifier: repositorySpecifier, to: bareCopyPath) + try validateDirectory(provider: repositoryProvider, at: bareCopyPath) + + + try FileManager.default.createDirectory( + atPath: workingCopyPath.pathString, + withIntermediateDirectories: true + ) + + let repository = try repositoryProvider.createWorkingCopyFromBare( + repository: repositorySpecifier, + sourcePath: bareCopyPath, + at: workingCopyPath, + editable: true + ) + + try FileManager.default.removeItem(at: bareCopyPath.asURL) + + try checkout(repository: repository) + + return workingCopyPath + } + } + + return try await fetchStandalonePackageByURL() + } + + private func fetchBareRepository( + provider: GitRepositoryProvider, + specifier: RepositorySpecifier, + to path: Basics.AbsolutePath + ) throws { + try provider.fetch(repository: specifier, to: path) + } + + private func validateDirectory(provider: GitRepositoryProvider, at path: Basics.AbsolutePath) throws { + guard try provider.isValidDirectory(path) else { + throw InternalError("Invalid directory at \(path)") + } + } + + private func checkout(repository: WorkingCheckout) throws { + switch requirement { + case .exact(let version): + try repository.checkout(tag: version.description) + + case .branch(let name): + try repository.checkout(branch: name) + + case .revision(let revision): + try repository.checkout(revision: .init(identifier: revision)) + + case .range(let range): + let tags = try repository.getTags() + let versions = tags.compactMap { Version($0) } + let filteredVersions = versions.filter { range.contains($0) } + guard let latestVersion = filteredVersions.max() else { + throw InternalError("No tags found within the specified version range \(range)") + } + try repository.checkout(tag: latestVersion.description) + } + } + + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift new file mode 100644 index 00000000000..09ad73a51d6 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift @@ -0,0 +1,173 @@ +// +// TemplatePluginRunner.swift +// SwiftPM +// +// Created by John Bute on 2025-06-11. +// + +import ArgumentParser +import Basics + +@_spi(SwiftPMInternal) +import CoreCommands + +import PackageModel +import Workspace +import SPMBuildCore +import TSCUtility + +import Foundation +import PackageGraph +import SPMBuildCore +import XCBuildSupport +import TSCBasic +import SourceControl + + +struct TemplatePluginRunner { + + static func run( + plugin: ResolvedModule, + package: ResolvedPackage, + packageGraph: ModulesGraph, + arguments: [String], + swiftCommandState: SwiftCommandState, + allowNetworkConnections: [SandboxNetworkPermission] = [] + ) async throws -> Data { + let pluginTarget = try castToPlugin(plugin) + let pluginsDir = try pluginDirectory(for: plugin.name, in: swiftCommandState) + let outputDir = pluginsDir.appending("outputs") + let pluginScriptRunner = try swiftCommandState.getPluginScriptRunner(customPluginsDir: pluginsDir) + + var writableDirs = [outputDir, package.path] + var allowedNetworkConnections = allowNetworkConnections + + try requestPluginPermissions( + from: pluginTarget, + pluginName: plugin.name, + packagePath: package.path, + writableDirectories: &writableDirs, + allowNetworkConnections: &allowedNetworkConnections, + state: swiftCommandState + ) + + let readOnlyDirs = writableDirs.contains(where: { package.path.isDescendantOfOrEqual(to: $0) }) ? [] : [package.path] + let toolSearchDirs = try defaultToolSearchDirectories(using: swiftCommandState) + + let buildParams = try swiftCommandState.toolsBuildParameters + let buildSystem = try await swiftCommandState.createBuildSystem( + explicitBuildSystem: .native, + traitConfiguration: .init(), + cacheBuildManifest: false, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: buildParams, + packageGraphLoader: { packageGraph } + ) + + let accessibleTools = try await plugin.preparePluginTools( + fileSystem: swiftCommandState.fileSystem, + environment: try swiftCommandState.toolsBuildParameters.buildEnvironment, + for: try pluginScriptRunner.hostTriple + ) { name, _ in + // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies are not supported within a package, so if the tool happens to be from the same package, we instead find the executable that corresponds to the product. There is always one, because of autogeneraxtion of implicit executables with the same name as the target if there isn't an explicit one. + try await buildSystem.build(subset: .product(name, for: .host)) + if let builtTool = try buildSystem.buildPlan.buildProducts.first(where: { + $0.product.name == name && $0.buildParameters.destination == .host + }) { + return try builtTool.binaryPath + } else { + return nil + } + } + + let delegate = PluginDelegate(swiftCommandState: swiftCommandState, plugin: pluginTarget, echoOutput: false) + + let workingDir = try swiftCommandState.options.locations.packageDirectory + ?? swiftCommandState.fileSystem.currentWorkingDirectory + ?? { throw InternalError("Could not determine working directory") }() + + try await pluginTarget.invoke( + action: .performCommand(package: package, arguments: arguments), + buildEnvironment: buildParams.buildEnvironment, + scriptRunner: pluginScriptRunner, + workingDirectory: workingDir, + outputDirectory: outputDir, + toolSearchDirectories: toolSearchDirs, + accessibleTools: accessibleTools, + writableDirectories: writableDirs, + readOnlyDirectories: readOnlyDirs, + allowNetworkConnections: allowedNetworkConnections, + pkgConfigDirectories: swiftCommandState.options.locations.pkgConfigDirectories, + sdkRootPath: buildParams.toolchain.sdkRootPath, + fileSystem: swiftCommandState.fileSystem, + modulesGraph: packageGraph, + observabilityScope: swiftCommandState.observabilityScope, + callbackQueue: DispatchQueue(label: "plugin-invocation"), + delegate: delegate + ) + + return delegate.lineBufferedOutput + } + + private static func castToPlugin(_ plugin: ResolvedModule) throws -> PluginModule { + guard let pluginTarget = plugin.underlying as? PluginModule else { + throw InternalError("Expected PluginModule") + } + return pluginTarget + } + + private static func pluginDirectory(for name: String, in state: SwiftCommandState) throws -> Basics.AbsolutePath { + try state.getActiveWorkspace().location.pluginWorkingDirectory.appending(component: name) + } + + private static func defaultToolSearchDirectories(using state: SwiftCommandState) throws -> [Basics.AbsolutePath] { + let toolchainPath = try state.getTargetToolchain().swiftCompilerPath.parentDirectory + let envPaths = Basics.getEnvSearchPaths(pathString: Environment.current[.path], currentWorkingDirectory: nil) + return [toolchainPath] + envPaths + } + + private static func requestPluginPermissions( + from plugin: PluginModule, + pluginName: String, + packagePath: Basics.AbsolutePath, + writableDirectories: inout [Basics.AbsolutePath], + allowNetworkConnections: inout [SandboxNetworkPermission], + state: SwiftCommandState + ) throws { + guard case .command(_, let permissions) = plugin.capability else { return } + + for permission in permissions { + let (desc, reason, remedy) = describe(permission) + + if state.outputStream.isTTY { + state.outputStream.write("Plugin '\(pluginName)' wants permission to \(desc).\nStated reason: “\(reason)”.\nAllow? (yes/no) ".utf8) + state.outputStream.flush() + + guard readLine()?.lowercased() == "yes" else { + throw StringError("Permission denied: \(desc)") + } + } else { + throw StringError("Plugin '\(pluginName)' requires: \(desc).\nReason: “\(reason)”.\nUse \(remedy) to allow.") + } + + switch permission { + case .writeToPackageDirectory: + writableDirectories.append(packagePath) + case .allowNetworkConnections(let scope, _): + allowNetworkConnections.append(SandboxNetworkPermission(scope)) + } + } + } + + private static func describe(_ permission: PluginPermission) -> (String, String, String) { + switch permission { + case .writeToPackageDirectory(let reason): + return ("write to the package directory", reason, "--allow-writing-to-package-directory") + case .allowNetworkConnections(let scope, let reason): + let ports = scope.ports.map(String.init).joined(separator: ", ") + let desc = scope.ports.isEmpty ? "allow \(scope.label) connections" : "allow \(scope.label) on ports: \(ports)" + return (desc, reason, "--allow-network-connections") + } + } +} + diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift b/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift new file mode 100644 index 00000000000..24588c894c4 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// +// 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 +@_spi(SwiftPMInternal) +import CoreCommands +import Workspace + +func computeSupportedTestingLibraries( + for testLibraryOptions: TestLibraryOptions, + initMode: InitPackage.PackageType, + swiftCommandState: SwiftCommandState +) -> Set { + + var supportedTemplateTestingLibraries: Set = .init() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTemplateTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTemplateTestingLibraries.insert(.swiftTesting) + } + + return supportedTemplateTestingLibraries + +} + From 5d047f27b71e91ba8e2a1bb4ffad63e83671555a Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 11 Jun 2025 13:58:36 -0400 Subject: [PATCH 170/225] organizing code to differentiate between initializing regular package and initializing template package --- Sources/Commands/PackageCommands/Init.swift | 39 +++++++++++-------- .../TemplatePluginRunner.swift | 2 +- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index ce0088c7b6d..e04d2abc849 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -109,25 +109,30 @@ extension SwiftPackageCommand { if useTemplates { try await runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) } else { - let supportedTestingLibraries = computeSupportedTestingLibraries(for: testLibraryOptions, initMode: initMode, swiftCommandState: swiftCommandState) - - let initPackage = try InitPackage( - name: packageName, - packageType: initMode, - supportedTestingLibraries: supportedTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, - fileSystem: swiftCommandState.fileSystem - ) - - initPackage.progressReporter = { message in - print(message) - } - - try initPackage.writePackageStructure() + try runPackageInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) } } - + + private func runPackageInit(swiftCommandState: SwiftCommandState, packageName: String, cwd: Basics.AbsolutePath) throws { + let supportedTestingLibraries = computeSupportedTestingLibraries(for: testLibraryOptions, initMode: initMode, swiftCommandState: swiftCommandState) + + let initPackage = try InitPackage( + name: packageName, + packageType: initMode, + supportedTestingLibraries: supportedTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, + fileSystem: swiftCommandState.fileSystem + ) + + initPackage.progressReporter = { message in + print(message) + } + + try initPackage.writePackageStructure() + + } + private func runTemplateInit(swiftCommandState: SwiftCommandState, packageName: String, cwd: Basics.AbsolutePath) async throws { let resolvedTemplatePath: Basics.AbsolutePath diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift index 09ad73a51d6..daf459b922c 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift @@ -86,7 +86,7 @@ struct TemplatePluginRunner { ?? swiftCommandState.fileSystem.currentWorkingDirectory ?? { throw InternalError("Could not determine working directory") }() - try await pluginTarget.invoke( + let _ = try await pluginTarget.invoke( action: .performCommand(package: package, arguments: arguments), buildEnvironment: buildParams.buildEnvironment, scriptRunner: pluginScriptRunner, From 3d0772aa83348f2a2d48a40b33ad0f3f0e05b0d6 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 11 Jun 2025 15:07:20 -0400 Subject: [PATCH 171/225] added documentation + reorganized show-templates code --- Sources/Commands/PackageCommands/Init.swift | 188 ++++++++----- .../PackageCommands/ShowTemplates.swift | 231 ++++++---------- .../RequirementResolver.swift | 38 ++- .../_InternalInitSupport/TemplateBuild.swift | 86 ++++-- .../TemplatePathResolver.swift | 61 ++++- .../TemplatePluginRunner.swift | 88 +++++-- .../TestingLibrarySupport.swift | 28 +- Sources/SourceControl/GitRepository.swift | 1 - Sources/Workspace/InitTemplatePackage.swift | 249 ++++++++++++------ 9 files changed, 601 insertions(+), 369 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index e04d2abc849..6ea942ec214 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -17,16 +17,16 @@ import Basics import CoreCommands import PackageModel -import Workspace import SPMBuildCore import TSCUtility +import Workspace import Foundation import PackageGraph +import SourceControl import SPMBuildCore -import XCBuildSupport import TSCBasic -import SourceControl +import XCBuildSupport import ArgumentParserToolInfo @@ -35,86 +35,110 @@ extension SwiftPackageCommand { public static let configuration = CommandConfiguration( abstract: "Initialize a new package.", ) - + @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions - + @OptionGroup(visibility: .hidden) var buildOptions: BuildCommandOptions - + @Option( name: .customLong("type"), help: ArgumentHelp("Package type:", discussion: """ - library - A package with a library. - executable - A package with an executable. - tool - A package with an executable that uses - Swift Argument Parser. Use this template if you - plan to have a rich set of command-line arguments. - build-tool-plugin - A package that vends a build tool plugin. - command-plugin - A package that vends a command plugin. - macro - A package that vends a macro. - empty - An empty package with a Package.swift manifest. - """)) + library - A package with a library. + executable - A package with an executable. + tool - A package with an executable that uses + Swift Argument Parser. Use this template if you + plan to have a rich set of command-line arguments. + build-tool-plugin - A package that vends a build tool plugin. + command-plugin - A package that vends a command plugin. + macro - A package that vends a macro. + empty - An empty package with a Package.swift manifest. + """) + ) var initMode: InitPackage.PackageType = .library - + /// Which testing libraries to use (and any related options.) @OptionGroup() var testLibraryOptions: TestLibraryOptions - + + /// A custom name for the package. Defaults to the current directory name. @Option(name: .customLong("name"), help: "Provide custom package name.") var packageName: String? - + // This command should support creating the supplied --package-path if it isn't created. var createPackagePath = true - + + /// Name of a template to use for package initialization. @Option(name: .customLong("template"), help: "Name of a template to initialize the package.") var template: String = "" - var useTemplates: Bool { !template.isEmpty } - + + /// Returns true if a template is specified. + var useTemplates: Bool { !self.template.isEmpty } + + /// The type of template to use: `registry`, `git`, or `local`. @Option(name: .customLong("template-type"), help: "Template type: registry, git, local.") var templateType: InitTemplatePackage.TemplateType? - + + /// Path to a local template. @Option(name: .customLong("template-path"), help: "Path to the local template.", completion: .directory) var templateDirectory: Basics.AbsolutePath? - + + /// Git URL of the template. @Option(name: .customLong("template-url"), help: "The git URL of the template.") var templateURL: String? - - // Git-specific options + + // MARK: - Versioning Options for Remote Git Templates + + /// The exact version of the remote package to use. @Option(help: "The exact package version to depend on.") var exact: Version? - + + /// Specific revision to use (for Git templates). @Option(help: "The specific package revision to depend on.") var revision: String? - + + /// Branch name to use (for Git templates). @Option(help: "The branch of the package to depend on.") var branch: String? - + + /// Version to depend on, up to the next major version. @Option(help: "The package version to depend on (up to the next major version).") var from: Version? - + + /// Version to depend on, up to the next minor version. @Option(help: "The package version to depend on (up to the next minor version).") var upToNextMinorFrom: Version? - + + /// Upper bound on the version range (exclusive). @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? - + func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") } - + let packageName = self.packageName ?? cwd.basename - - if useTemplates { - try await runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) + + if self.useTemplates { + try await self.runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) } else { - try runPackageInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) + try self.runPackageInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) } } - private func runPackageInit(swiftCommandState: SwiftCommandState, packageName: String, cwd: Basics.AbsolutePath) throws { - let supportedTestingLibraries = computeSupportedTestingLibraries(for: testLibraryOptions, initMode: initMode, swiftCommandState: swiftCommandState) + /// Runs the standard package initialization (non-template). + private func runPackageInit( + swiftCommandState: SwiftCommandState, + packageName: String, + cwd: Basics.AbsolutePath + ) throws { + let supportedTestingLibraries = computeSupportedTestingLibraries( + for: testLibraryOptions, + initMode: initMode, + swiftCommandState: swiftCommandState + ) let initPackage = try InitPackage( name: packageName, @@ -130,14 +154,17 @@ extension SwiftPackageCommand { } try initPackage.writePackageStructure() - } - private func runTemplateInit(swiftCommandState: SwiftCommandState, packageName: String, cwd: Basics.AbsolutePath) async throws { - + /// Runs the package initialization using an author-defined template. + private func runTemplateInit( + swiftCommandState: SwiftCommandState, + packageName: String, + cwd: Basics.AbsolutePath + ) async throws { let resolvedTemplatePath: Basics.AbsolutePath var requirement: PackageDependency.SourceControl.Requirement? - + switch self.templateType { case .git: requirement = try DependencyRequirementResolver( @@ -148,14 +175,14 @@ extension SwiftPackageCommand { upToNextMinorFrom: self.upToNextMinorFrom, to: self.to ).resolve() - + resolvedTemplatePath = try await TemplatePathResolver( templateType: self.templateType, templateDirectory: self.templateDirectory, templateURL: self.templateURL, requirement: requirement ).resolve() - + case .local, .registry: resolvedTemplatePath = try await TemplatePathResolver( templateType: self.templateType, @@ -163,20 +190,28 @@ extension SwiftPackageCommand { templateURL: self.templateURL, requirement: nil ).resolve() - + case .none: throw StringError("Missing template type") } - - let templateInitType = try await swiftCommandState.withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { workspace, root in - return try await checkConditions(swiftCommandState) - } - - if templateType == .git { - try FileManager.default.removeItem(at: resolvedTemplatePath.asURL) + + let templateInitType = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in + try await self.checkConditions(swiftCommandState) + } + + // Clean up downloaded package after execution. + defer { + if templateType == .git { + try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) + } } - - let supportedTemplateTestingLibraries = computeSupportedTestingLibraries(for: testLibraryOptions, initMode: templateInitType, swiftCommandState: swiftCommandState) + + let supportedTemplateTestingLibraries = computeSupportedTestingLibraries( + for: testLibraryOptions, + initMode: templateInitType, + swiftCommandState: swiftCommandState + ) let initTemplatePackage = try InitTemplatePackage( name: packageName, @@ -189,14 +224,19 @@ extension SwiftPackageCommand { destinationPath: cwd, installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration ) - + try initTemplatePackage.setupTemplateManifest() - try await TemplateBuildSupport.build(swiftCommandState: swiftCommandState, buildOptions: buildOptions, globalOptions: globalOptions, cwd: cwd) + try await TemplateBuildSupport.build( + swiftCommandState: swiftCommandState, + buildOptions: self.buildOptions, + globalOptions: self.globalOptions, + cwd: cwd + ) let packageGraph = try await swiftCommandState.loadPackageGraph() - let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) - + let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: packageGraph, limitedTo: nil) + let output = try await TemplatePluginRunner.run( plugin: matchingPlugins[0], package: packageGraph.rootPackages.first!, @@ -204,7 +244,7 @@ extension SwiftPackageCommand { arguments: ["--", "--experimental-dump-help"], swiftCommandState: swiftCommandState ) - + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) let response = try initTemplatePackage.promptUser(tool: toolInfo) do { @@ -218,11 +258,11 @@ extension SwiftPackageCommand { } } - private func checkConditions(_ swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType{ - + /// Validates the loaded manifest to determine package type. + private func checkConditions(_ swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType { let workspace = try swiftCommandState.getActiveWorkspace() let root = try swiftCommandState.getWorkspaceRoot() - + let rootManifests = try await workspace.loadRootManifests( packages: root.packages, observabilityScope: swiftCommandState.observabilityScope @@ -230,29 +270,32 @@ extension SwiftPackageCommand { guard let rootManifest = rootManifests.values.first else { throw InternalError("invalid manifests at \(root.packages)") } - + let products = rootManifest.products let targets = rootManifest.targets - + for product in products { for targetName in product.targets { if let target = targets.first(where: { $0.name == template }) { if let options = target.templateInitializationOptions { - - if case let .packageInit(templateType, _, _) = options { + if case .packageInit(let templateType, _, _) = options { return try .init(from: templateType) } } } } } - throw InternalError("Could not find template \(template)") + throw InternalError("Could not find template \(self.template)") } - private func packageDependency(requirement: PackageDependency.SourceControl.Requirement?, resolvedTemplatePath: Basics.AbsolutePath) throws -> MappablePackageDependency.Kind { - switch templateType { + /// Transforms the author's package into the required dependency + private func packageDependency( + requirement: PackageDependency.SourceControl.Requirement?, + resolvedTemplatePath: Basics.AbsolutePath + ) throws -> MappablePackageDependency.Kind { + switch self.templateType { case .local: - return .fileSystem(name: packageName, path: resolvedTemplatePath.asURL.path) + return .fileSystem(name: self.packageName, path: resolvedTemplatePath.asURL.path) case .git: guard let url = templateURL else { @@ -262,7 +305,7 @@ extension SwiftPackageCommand { guard let gitRequirement = requirement else { throw StringError("Missing Git requirement") } - return .sourceControl(name: packageName, location: url, requirement: gitRequirement) + return .sourceControl(name: self.packageName, location: url, requirement: gitRequirement) default: throw StringError("Not implemented yet") @@ -290,7 +333,6 @@ extension InitPackage.PackageType: ExpressibleByArgument { self = .empty } } - } extension InitTemplatePackage.TemplateType: ExpressibleByArgument {} diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 97c2d62649a..65c3e25689d 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -2,7 +2,7 @@ // // This source file is part of the Swift open source project // -// Copyright (c) 2014-2025 Apple Inc. and the Swift project authors +// 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 @@ -14,89 +14,121 @@ import ArgumentParser import Basics import CoreCommands import Foundation -import PackageModel import PackageGraph -import Workspace +import PackageModel import TSCUtility -import TSCBasic -import SourceControl +import Workspace +/// A Swift command that lists the available executable templates from a package. +/// +/// The command can work with either a local package or a remote Git-based package template. +/// It supports version specification and configurable output formats (flat list or JSON). struct ShowTemplates: AsyncSwiftCommand { static let configuration = CommandConfiguration( - abstract: "List the available executables from this package.") + abstract: "List the available executables from this package." + ) @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions + /// The Git URL of the template to list executables from. + /// + /// If not provided, the command uses the current working directory. @Option(name: .customLong("template-url"), help: "The git URL of the template.") var templateURL: String? - // Git-specific options + /// Output format for the templates list. + /// + /// Can be either `.flatlist` (default) or `.json`. + @Option(help: "Set the output format.") + var format: ShowTemplatesMode = .flatlist + + // MARK: - Versioning Options for Remote Git Templates + + /// The exact version of the remote package to use. @Option(help: "The exact package version to depend on.") var exact: Version? + /// Specific revision to use (for Git templates). @Option(help: "The specific package revision to depend on.") var revision: String? + /// Branch name to use (for Git templates). @Option(help: "The branch of the package to depend on.") var branch: String? + /// Version to depend on, up to the next major version. @Option(help: "The package version to depend on (up to the next major version).") var from: Version? + /// Version to depend on, up to the next minor version. @Option(help: "The package version to depend on (up to the next minor version).") var upToNextMinorFrom: Version? + /// Upper bound on the version range (exclusive). @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? - @Option(help: "Set the output format.") - var format: ShowTemplatesMode = .flatlist - func run(_ swiftCommandState: SwiftCommandState) async throws { - let packagePath: Basics.AbsolutePath - var deleteAfter = false + var shouldDeleteAfter = false + + if let templateURL = self.templateURL { + // Resolve dependency requirement based on provided options. + let requirement = try DependencyRequirementResolver( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ).resolve() + + // Download and resolve the Git-based template. + let resolver = TemplatePathResolver( + templateType: .git, + templateDirectory: nil, + templateURL: templateURL, + requirement: requirement + ) + packagePath = try await resolver.resolve() + shouldDeleteAfter = true - // Use local current directory or fetch Git package - if let templateURL = self.templateURL { - let requirement = try checkRequirements() - packagePath = try await getPackageFromGit(destination: templateURL, requirement: requirement) - deleteAfter = true - } else { - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("No template URL provided and no current directory") - } - packagePath = cwd + } else { + // Use the current working directory. + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("No template URL provided and no current directory") } + packagePath = cwd + } - defer { - if deleteAfter { - try? FileManager.default.removeItem(atPath: packagePath.pathString) - } + // Clean up downloaded package after execution. + defer { + if shouldDeleteAfter { + try? FileManager.default.removeItem(atPath: packagePath.pathString) } + } - - let packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: packagePath) { workspace, root in - return try await swiftCommandState.loadPackageGraph() - + // Load the package graph. + let packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: packagePath) { _, _ in + try await swiftCommandState.loadPackageGraph() } - let rootPackages = packageGraph.rootPackages.map { $0.identity } + let rootPackages = packageGraph.rootPackages.map(\.identity) - let templates = packageGraph.allModules.filter({ - $0.underlying.template - }).map { module -> Template in + // Extract executable modules marked as templates. + let templates = packageGraph.allModules.filter(\.underlying.template).map { module -> Template in if !rootPackages.contains(module.packageIdentity) { return Template(package: module.packageIdentity.description, name: module.name) } else { - return Template(package: Optional.none, name: module.name) + return Template(package: String?.none, name: module.name) } } + // Display templates in the requested format. switch self.format { case .flatlist: - for template in templates.sorted(by: {$0.name < $1.name }) { + for template in templates.sorted(by: { $0.name < $1.name }) { if let package = template.package { print("\(template.name) (\(package))") } else { @@ -113,13 +145,20 @@ struct ShowTemplates: AsyncSwiftCommand { } } + /// Represents a discovered template. struct Template: Codable { + /// Optional name of the external package, if the template comes from one. var package: String? + /// The name of the executable template. var name: String } + /// Output format modes for the `ShowTemplates` command. enum ShowTemplatesMode: String, RawRepresentable, CustomStringConvertible, ExpressibleByArgument, CaseIterable { - case flatlist, json + /// Output as a simple list of template names. + case flatlist + /// Output as a JSON array of template objects. + case json public init?(rawValue: String) { switch rawValue.lowercased() { @@ -134,123 +173,9 @@ struct ShowTemplates: AsyncSwiftCommand { public var description: String { switch self { - case .flatlist: return "flatlist" - case .json: return "json" + case .flatlist: "flatlist" + case .json: "json" } } } - - func getPackageFromGit( - destination: String, - requirement: PackageDependency.SourceControl.Requirement - ) async throws -> Basics.AbsolutePath { - let repositoryProvider = GitRepositoryProvider() - - let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in - try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in - let url = SourceControlURL(destination) - let repositorySpecifier = RepositorySpecifier(url: url) - - // This is the working clone destination - let bareCopyPath = tempDir.appending(component: "bare-copy") - - let workingCopyPath = tempDir.appending(component: "working-copy") - - try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) - - try FileManager.default.createDirectory(atPath: workingCopyPath.pathString, withIntermediateDirectories: true) - - // Validate directory (now should exist) - guard try repositoryProvider.isValidDirectory(bareCopyPath) else { - throw InternalError("Invalid directory at \(workingCopyPath)") - } - - - - let repository = try repositoryProvider.createWorkingCopyFromBare(repository: repositorySpecifier, sourcePath: bareCopyPath, at: workingCopyPath, editable: true) - - - try FileManager.default.removeItem(at: bareCopyPath.asURL) - - switch requirement { - case .range(let versionRange): - let tags = try repository.getTags() - let versions = tags.compactMap { Version($0) } - let filteredVersions = versions.filter { versionRange.contains($0) } - guard let latestVersion = filteredVersions.max() else { - throw InternalError("No tags found within the specified version range \(versionRange)") - } - try repository.checkout(tag: latestVersion.description) - - case .exact(let exactVersion): - try repository.checkout(tag: exactVersion.description) - - case .branch(let branchName): - try repository.checkout(branch: branchName) - - case .revision(let revision): - try repository.checkout(revision: .init(identifier: revision)) - } - - return workingCopyPath - } - } - - return try await fetchStandalonePackageByURL() - } - - - func checkRequirements() throws -> PackageDependency.SourceControl.Requirement { - var requirements : [PackageDependency.SourceControl.Requirement] = [] - - if let exact { - requirements.append(.exact(exact)) - } - - if let branch { - requirements.append(.branch(branch)) - } - - if let revision { - requirements.append(.revision(revision)) - } - - if let from { - requirements.append(.range(.upToNextMajor(from: from))) - } - - if let upToNextMinorFrom { - requirements.append(.range(.upToNextMinor(from: upToNextMinorFrom))) - } - - if requirements.count > 1 { - throw StringError( - "must specify at most one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" - ) - } - - guard let firstRequirement = requirements.first else { - throw StringError( - "must specify one of --exact, --branch, --revision, --from, or --up-to-next-minor-from" - ) - } - - let requirement: PackageDependency.SourceControl.Requirement - if case .range(let range) = firstRequirement { - if let to { - requirement = .range(range.lowerBound ..< to) - } else { - requirement = .range(range) - } - } else { - requirement = firstRequirement - - if self.to != nil { - throw StringError("--to can only be specified with --from or --up-to-next-minor-from") - } - } - return requirement - - } - } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index f5f0f6ccdec..48150041557 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -14,14 +14,50 @@ import PackageModel import TSCBasic import TSCUtility +/// A utility for resolving a single, well-formed source control dependency requirement +/// based on mutually exclusive versioning inputs such as `exact`, `branch`, `revision`, +/// or version ranges (`from`, `upToNextMinorFrom`, `to`). +/// +/// This is typically used to translate user-specified version inputs (e.g., from the command line) +/// into a concrete `PackageDependency.SourceControl.Requirement` that SwiftPM can understand. +/// +/// Only one of the following fields should be non-nil: +/// - `exact`: A specific version (e.g., 1.2.3). +/// - `revision`: A specific VCS revision (e.g., commit hash). +/// - `branch`: A named branch (e.g., "main"). +/// - `from`: Lower bound of a version range with an upper bound inferred as the next major version. +/// - `upToNextMinorFrom`: Lower bound of a version range with an upper bound inferred as the next minor version. +/// +/// Optionally, a `to` value can be specified to manually cap the upper bound of a version range, +/// but it must be combined with `from` or `upToNextMinorFrom`. + struct DependencyRequirementResolver { + /// An exact version to use. let exact: Version? + + /// A specific source control revision (e.g., a commit SHA). let revision: String? + + /// A branch name to track. let branch: String? + + /// The lower bound for a version range with an implicit upper bound to the next major version. let from: Version? + + /// The lower bound for a version range with an implicit upper bound to the next minor version. let upToNextMinorFrom: Version? + + /// An optional manual upper bound for the version range. Must be used with `from` or `upToNextMinorFrom`. let to: Version? + /// Resolves the provided requirement fields into a concrete `PackageDependency.SourceControl.Requirement`. + /// + /// - Returns: A valid, single requirement representing a source control constraint. + /// - Throws: A `StringError` if: + /// - More than one requirement type is provided. + /// - None of the requirement fields are set. + /// - A `to` value is provided without a corresponding `from` or `upToNextMinorFrom`. + func resolve() throws -> PackageDependency.SourceControl.Requirement { var all: [PackageDependency.SourceControl.Requirement] = [] @@ -37,7 +73,7 @@ struct DependencyRequirementResolver { if case .range(let range) = requirement, let upper = to { return .range(range.lowerBound ..< upper) - } else if to != nil { + } else if self.to != nil { throw StringError("--to requires --from or --up-to-next-minor-from") } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift index ad8814ee52d..e8b8c4212d9 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -1,42 +1,76 @@ +//===----------------------------------------------------------------------===// // -// TemplateBuild.swift -// SwiftPM +// This source file is part of the Swift open source project // -// Created by John Bute on 2025-06-11. +// 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 CoreCommands +import ArgumentParser import Basics +import CoreCommands +import SPMBuildCore import TSCBasic -import ArgumentParser import TSCUtility -import SPMBuildCore -struct TemplateBuildSupport { - static func build(swiftCommandState: SwiftCommandState, buildOptions: BuildCommandOptions, globalOptions: GlobalOptions, cwd: Basics.AbsolutePath) async throws { - let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in - try await swiftCommandState.createBuildSystem( - explicitProduct: buildOptions.product, - traitConfiguration: .init(traitOptions: buildOptions.traits), - shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: swiftCommandState.toolsBuildParameters, - outputStream: TSCBasic.stdoutStream - ) - } +/// A utility for building Swift packages using the SwiftPM build system. +/// +/// `TemplateBuildSupport` encapsulates the logic needed to initialize the +/// SwiftPM build system and perform a build operation based on a specific +/// command configuration and workspace context. + +enum TemplateBuildSupport { + /// Builds a Swift package using the given command state, options, and working directory. + /// + /// This method performs the following steps: + /// 1. Initializes a temporary workspace, optionally switching to a user-specified package directory. + /// 2. Creates a build system with the specified configuration, including product, traits, and build parameters. + /// 3. Resolves the build subset (e.g., targets or products to build). + /// 4. Executes the build within the workspace. + /// + /// - Parameters: + /// - swiftCommandState: The current Swift command state, containing context such as the workspace and + /// diagnostics. + /// - buildOptions: Options used to configure what and how to build, including the product and traits. + /// - globalOptions: Global configuration such as the package directory and logging verbosity. + /// - cwd: The current working directory to use if no package directory is explicitly provided. + /// + /// - Throws: + /// - `ExitCode.failure` if no valid build subset can be resolved or if the build fails due to diagnostics. + /// - Any other errors thrown during workspace setup or build system creation. + static func build( + swiftCommandState: SwiftCommandState, + buildOptions: BuildCommandOptions, + globalOptions: GlobalOptions, + cwd: Basics.AbsolutePath + ) async throws { + let buildSystem = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + try await swiftCommandState.createBuildSystem( + explicitProduct: buildOptions.product, + traitConfiguration: .init(traitOptions: buildOptions.traits), + shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: swiftCommandState.toolsBuildParameters, + outputStream: TSCBasic.stdoutStream + ) + } guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { throw ExitCode.failure } - try await swiftCommandState.withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in - do { - try await buildSystem.build(subset: subset) - } catch _ as Diagnostics { - throw ExitCode.failure + try await swiftCommandState + .withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + do { + try await buildSystem.build(subset: subset) + } catch _ as Diagnostics { + throw ExitCode.failure + } } - } - } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index f0c2506e214..0fb1cf6fad5 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -10,23 +10,45 @@ // //===----------------------------------------------------------------------===// -import Workspace import Basics +import Foundation import PackageModel -import TSCBasic import SourceControl -import Foundation +import TSCBasic import TSCUtility +import Workspace +/// A utility responsible for resolving the path to a package template, +/// based on the provided template type and associated configuration. +/// +/// Supported template types include: +/// - `.local`: A local file system path to a template directory. +/// - `.git`: A remote Git repository containing the template. +/// - `.registry`: (Currently unsupported) +/// +/// Used during package initialization (e.g., via `swift package init --template`). struct TemplatePathResolver { - let templateType: InitTemplatePackage.TemplateType? + /// The type of template to resolve (e.g., local, git, registry). + let templateType: InitTemplatePackage.TemplateSource? + + /// The local path to a template directory, used for `.local` templates. let templateDirectory: Basics.AbsolutePath? + + /// The URL of the Git repository containing the template, used for `.git` templates. let templateURL: String? + + /// The versioning requirement for the Git repository (e.g., exact version, branch, revision, or version range). let requirement: PackageDependency.SourceControl.Requirement? + /// Resolves the template path by downloading or validating it based on the template type. + /// + /// - Returns: The resolved path to the template directory. + /// - Throws: + /// - `StringError` if required values (e.g., path, URL, requirement) are missing, + /// or if the template type is unsupported or unspecified. func resolve() async throws -> Basics.AbsolutePath { - switch templateType { + switch self.templateType { case .local: guard let path = templateDirectory else { throw StringError("Template path must be specified for local templates.") @@ -52,12 +74,19 @@ struct TemplatePathResolver { } } + /// A helper that fetches a Git-based template repository and checks out the specified version or revision. struct GitTemplateFetcher { + /// The Git URL of the remote repository. let destination: String + + /// The source control requirement used to determine which version/branch/revision to check out. let requirement: PackageDependency.SourceControl.Requirement + /// Fetches the repository and returns the path to the checked-out working copy. + /// + /// - Returns: A path to the directory containing the fetched template. + /// - Throws: Any error encountered during repository fetch, checkout, or validation. func fetch() async throws -> Basics.AbsolutePath { - let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in @@ -65,14 +94,16 @@ struct TemplatePathResolver { let repositorySpecifier = RepositorySpecifier(url: url) let repositoryProvider = GitRepositoryProvider() - let bareCopyPath = tempDir.appending(component: "bare-copy") let workingCopyPath = tempDir.appending(component: "working-copy") - try fetchBareRepository(provider: repositoryProvider, specifier: repositorySpecifier, to: bareCopyPath) - try validateDirectory(provider: repositoryProvider, at: bareCopyPath) - + try self.fetchBareRepository( + provider: repositoryProvider, + specifier: repositorySpecifier, + to: bareCopyPath + ) + try self.validateDirectory(provider: repositoryProvider, at: bareCopyPath) try FileManager.default.createDirectory( atPath: workingCopyPath.pathString, @@ -88,7 +119,7 @@ struct TemplatePathResolver { try FileManager.default.removeItem(at: bareCopyPath.asURL) - try checkout(repository: repository) + try self.checkout(repository: repository) return workingCopyPath } @@ -97,6 +128,7 @@ struct TemplatePathResolver { return try await fetchStandalonePackageByURL() } + /// Fetches a bare clone of the Git repository to the specified path. private func fetchBareRepository( provider: GitRepositoryProvider, specifier: RepositorySpecifier, @@ -105,14 +137,18 @@ struct TemplatePathResolver { try provider.fetch(repository: specifier, to: path) } + /// Validates that the directory contains a valid Git repository. private func validateDirectory(provider: GitRepositoryProvider, at path: Basics.AbsolutePath) throws { guard try provider.isValidDirectory(path) else { throw InternalError("Invalid directory at \(path)") } } + /// Checks out the desired state (branch, tag, revision) in the working copy based on the requirement. + /// + /// - Throws: An error if no matching version is found in a version range, or if checkout fails. private func checkout(repository: WorkingCheckout) throws { - switch requirement { + switch self.requirement { case .exact(let version): try repository.checkout(tag: version.description) @@ -132,6 +168,5 @@ struct TemplatePathResolver { try repository.checkout(tag: latestVersion.description) } } - } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift index daf459b922c..891418903c9 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift @@ -1,9 +1,14 @@ +//===----------------------------------------------------------------------===// // -// TemplatePluginRunner.swift -// SwiftPM +// This source file is part of the Swift open source project // -// Created by John Bute on 2025-06-11. +// 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 ArgumentParser import Basics @@ -12,20 +17,48 @@ import Basics import CoreCommands import PackageModel -import Workspace import SPMBuildCore import TSCUtility +import Workspace import Foundation import PackageGraph +import SourceControl import SPMBuildCore -import XCBuildSupport import TSCBasic -import SourceControl - - -struct TemplatePluginRunner { +import XCBuildSupport +/// A utility that runs a plugin target within the context of a resolved Swift package. +/// +/// This is used to perform plugin invocations involved in template initialization scripts— +/// with proper sandboxing, permissions, and build system support. +/// +/// The plugin must be part of a resolved package graph, and the invocation is handled +/// asynchronously through SwiftPM’s plugin infrastructure. + +enum TemplatePluginRunner { + /// Runs the given plugin target with the specified arguments and environment context. + /// + /// This function performs the following steps: + /// 1. Validates and prepares plugin metadata and permissions. + /// 2. Prepares the plugin working directory and toolchain. + /// 3. Resolves required plugin tools, building any products referenced by the plugin. + /// 4. Invokes the plugin via the configured script runner with sandboxing. + /// + /// - Parameters: + /// - plugin: The resolved plugin module to run. + /// - package: The resolved package to which the plugin belongs. + /// - packageGraph: The complete graph of modules used by the build. + /// - arguments: Arguments to pass to the plugin at invocation time. + /// - swiftCommandState: The current Swift command state including environment, toolchain, and workspace. + /// - allowNetworkConnections: A list of pre-authorized network permissions for the plugin sandbox. + /// + /// - Returns: A `Data` value representing the plugin’s buffered stdout output. + /// + /// - Throws: + /// - `InternalError` if expected components (e.g., plugin module or working directory) are missing. + /// - `StringError` if permission is denied by the user or plugin configuration is invalid. + /// - Any other error thrown during tool resolution, plugin script execution, or build system creation. static func run( plugin: ResolvedModule, package: ResolvedPackage, @@ -51,12 +84,13 @@ struct TemplatePluginRunner { state: swiftCommandState ) - let readOnlyDirs = writableDirs.contains(where: { package.path.isDescendantOfOrEqual(to: $0) }) ? [] : [package.path] + let readOnlyDirs = writableDirs + .contains(where: { package.path.isDescendantOfOrEqual(to: $0) }) ? [] : [package.path] let toolSearchDirs = try defaultToolSearchDirectories(using: swiftCommandState) let buildParams = try swiftCommandState.toolsBuildParameters let buildSystem = try await swiftCommandState.createBuildSystem( - explicitBuildSystem: .native, + explicitBuildSystem: .native, // FIXME: This should be based on BuildSystemProvider. traitConfiguration: .init(), cacheBuildManifest: false, productsBuildParameters: swiftCommandState.productsBuildParameters, @@ -66,10 +100,13 @@ struct TemplatePluginRunner { let accessibleTools = try await plugin.preparePluginTools( fileSystem: swiftCommandState.fileSystem, - environment: try swiftCommandState.toolsBuildParameters.buildEnvironment, - for: try pluginScriptRunner.hostTriple + environment: swiftCommandState.toolsBuildParameters.buildEnvironment, + for: pluginScriptRunner.hostTriple ) { name, _ in - // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies are not supported within a package, so if the tool happens to be from the same package, we instead find the executable that corresponds to the product. There is always one, because of autogeneraxtion of implicit executables with the same name as the target if there isn't an explicit one. + // Build the product referenced by the tool, and add the executable to the tool map. Product dependencies + // are not supported within a package, so if the tool happens to be from the same package, we instead find + // the executable that corresponds to the product. There is always one, because of autogeneraxtion of + // implicit executables with the same name as the target if there isn't an explicit one. try await buildSystem.build(subset: .product(name, for: .host)) if let builtTool = try buildSystem.buildPlan.buildProducts.first(where: { $0.product.name == name && $0.buildParameters.destination == .host @@ -109,6 +146,7 @@ struct TemplatePluginRunner { return delegate.lineBufferedOutput } + /// Safely casts a `ResolvedModule` to a `PluginModule`, or throws if invalid. private static func castToPlugin(_ plugin: ResolvedModule) throws -> PluginModule { guard let pluginTarget = plugin.underlying as? PluginModule else { throw InternalError("Expected PluginModule") @@ -116,16 +154,21 @@ struct TemplatePluginRunner { return pluginTarget } + /// Returns the plugin working directory for the specified plugin name. private static func pluginDirectory(for name: String, in state: SwiftCommandState) throws -> Basics.AbsolutePath { try state.getActiveWorkspace().location.pluginWorkingDirectory.appending(component: name) } + /// Resolves default tool search directories including the toolchain path and user $PATH. private static func defaultToolSearchDirectories(using state: SwiftCommandState) throws -> [Basics.AbsolutePath] { let toolchainPath = try state.getTargetToolchain().swiftCompilerPath.parentDirectory let envPaths = Basics.getEnvSearchPaths(pathString: Environment.current[.path], currentWorkingDirectory: nil) return [toolchainPath] + envPaths } + /// Prompts for and grants plugin permissions as specified in the plugin manifest. + /// + /// This supports terminal-based interactive prompts and non-interactive failure modes. private static func requestPluginPermissions( from plugin: PluginModule, pluginName: String, @@ -137,17 +180,23 @@ struct TemplatePluginRunner { guard case .command(_, let permissions) = plugin.capability else { return } for permission in permissions { - let (desc, reason, remedy) = describe(permission) + let (desc, reason, remedy) = self.describe(permission) if state.outputStream.isTTY { - state.outputStream.write("Plugin '\(pluginName)' wants permission to \(desc).\nStated reason: “\(reason)”.\nAllow? (yes/no) ".utf8) + state.outputStream + .write( + "Plugin '\(pluginName)' wants permission to \(desc).\nStated reason: “\(reason)”.\nAllow? (yes/no) " + .utf8 + ) state.outputStream.flush() guard readLine()?.lowercased() == "yes" else { throw StringError("Permission denied: \(desc)") } } else { - throw StringError("Plugin '\(pluginName)' requires: \(desc).\nReason: “\(reason)”.\nUse \(remedy) to allow.") + throw StringError( + "Plugin '\(pluginName)' requires: \(desc).\nReason: “\(reason)”.\nUse \(remedy) to allow." + ) } switch permission { @@ -159,15 +208,16 @@ struct TemplatePluginRunner { } } + /// Describes a plugin permission request with a description, reason, and CLI remedy flag. private static func describe(_ permission: PluginPermission) -> (String, String, String) { switch permission { case .writeToPackageDirectory(let reason): return ("write to the package directory", reason, "--allow-writing-to-package-directory") case .allowNetworkConnections(let scope, let reason): let ports = scope.ports.map(String.init).joined(separator: ", ") - let desc = scope.ports.isEmpty ? "allow \(scope.label) connections" : "allow \(scope.label) on ports: \(ports)" + let desc = scope.ports + .isEmpty ? "allow \(scope.label) connections" : "allow \(scope.label) on ports: \(ports)" return (desc, reason, "--allow-network-connections") } } } - diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift b/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift index 24588c894c4..c802e4ac71b 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift @@ -10,30 +10,44 @@ // //===----------------------------------------------------------------------===// - - import Basics @_spi(SwiftPMInternal) import CoreCommands import Workspace +/// Computes the set of supported testing libraries to be included in a package template +/// based on the user's specified testing options, the type of package being initialized, +/// and the Swift command state. +/// +/// This function takes into account whether the testing libraries were explicitly requested +/// (via command-line flags or configuration) or implicitly enabled based on package type. +/// +/// - Parameters: +/// - testLibraryOptions: The testing library preferences specified by the user. +/// - initMode: The type of package being initialized (e.g., executable, library, macro). +/// - swiftCommandState: The command state which includes environment and context information. +/// +/// - Returns: A set of `TestingLibrary` values that should be included in the generated template. func computeSupportedTestingLibraries( for testLibraryOptions: TestLibraryOptions, initMode: InitPackage.PackageType, swiftCommandState: SwiftCommandState ) -> Set { - var supportedTemplateTestingLibraries: Set = .init() + + // XCTest is enabled either explicitly, or implicitly for macro packages. if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) + { supportedTemplateTestingLibraries.insert(.xctest) } + + // Swift Testing is enabled either explicitly, or implicitly for non-macro packages. if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) + { supportedTemplateTestingLibraries.insert(.swiftTesting) } return supportedTemplateTestingLibraries - } - diff --git a/Sources/SourceControl/GitRepository.swift b/Sources/SourceControl/GitRepository.swift index 63556dadebf..d406391f4f9 100644 --- a/Sources/SourceControl/GitRepository.swift +++ b/Sources/SourceControl/GitRepository.swift @@ -271,7 +271,6 @@ public struct GitRepositoryProvider: RepositoryProvider, Cancellable { ) } return try self.openWorkingCopy(at: destinationPath) - } diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 71675eef6cc..683a088cfec 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -5,44 +5,58 @@ // Created by John Bute on 2025-05-13. // +import ArgumentParserToolInfo import Basics -import PackageModel -import SPMBuildCore -import TSCUtility import Foundation -import Basics import PackageModel +import PackageModelSyntax import SPMBuildCore -import TSCUtility +import SwiftParser import System -import PackageModelSyntax import TSCBasic -import SwiftParser -import ArgumentParserToolInfo +import TSCUtility -public final class InitTemplatePackage { +/// A class responsible for initializing a Swift package from a specified template. +/// +/// This class handles creating the package structure, applying a template dependency +/// to the package manifest, and optionally prompting the user for input to customize +/// the generated package. +/// +/// It supports different types of templates (local, git, registry) and multiple +/// testing libraries. +/// +/// Usage: +/// - Initialize an instance with the package name, template details, file system, destination path, etc. +/// - Call `setupTemplateManifest()` to create the package and add the template dependency. +/// - Use `promptUser(tool:)` to interactively prompt the user for command line argument values. +public final class InitTemplatePackage { + /// The kind of package dependency to add for the template. let packageDependency: MappablePackageDependency.Kind - + /// The set of testing libraries supported by the generated package. public var supportedTestingLibraries: Set - + /// The name of the template to use. let templateName: String - /// The file system to use + /// The file system abstraction to use for file operations. let fileSystem: FileSystem - /// Where to create the new package + /// The absolute path where the package will be created. let destinationPath: Basics.AbsolutePath - /// Configuration from the used toolchain. + /// Configuration information from the installed Swift Package Manager toolchain. let installedSwiftPMConfiguration: InstalledSwiftPMConfiguration + /// The name of the package to create. var packageName: String + /// The path to the template files. var templatePath: Basics.AbsolutePath + /// The type of package to create (e.g., library, executable). let packageType: InitPackage.PackageType + /// Options used to configure package initialization. public struct InitPackageOptions { /// The type of package to create. @@ -51,11 +65,17 @@ public final class InitTemplatePackage { /// The set of supported testing libraries to include in the package. public var supportedTestingLibraries: Set - /// The list of platforms in the manifest. + /// The list of supported platforms to target in the manifest. /// - /// Note: This should only contain Apple platforms right now. + /// Note: Currently only Apple platforms are supported. public var platforms: [SupportedPlatform] + /// Creates a new `InitPackageOptions` instance. + /// - Parameters: + /// - packageType: The type of package to create. + /// - supportedTestingLibraries: The set of testing libraries to support. + /// - platforms: The list of supported platforms (default is empty). + public init( packageType: InitPackage.PackageType, supportedTestingLibraries: Set, @@ -67,21 +87,29 @@ public final class InitTemplatePackage { } } - - - public enum TemplateType: String, CustomStringConvertible { - case local = "local" - case git = "git" - case registry = "registry" + /// The type of template source. + public enum TemplateSource: String, CustomStringConvertible { + case local + case git + case registry public var description: String { - return rawValue + rawValue } } - - - + /// Creates a new `InitTemplatePackage` instance. + /// + /// - Parameters: + /// - name: The name of the package to create. + /// - templateName: The name of the template to use. + /// - initMode: The kind of package dependency to add for the template. + /// - templatePath: The file system path to the template files. + /// - fileSystem: The file system to use for operations. + /// - packageType: The type of package to create (e.g., library, executable). + /// - supportedTestingLibraries: The set of testing libraries to support. + /// - destinationPath: The directory where the new package should be created. + /// - installedSwiftPMConfiguration: Configuration from the SwiftPM toolchain. public init( name: String, templateName: String, @@ -104,31 +132,47 @@ public final class InitTemplatePackage { self.templateName = templateName } - + /// Sets up the package manifest by creating the package structure and + /// adding the template dependency to the manifest. + /// + /// This method initializes an empty package using `InitPackage`, writes the + /// package structure, and then applies the template dependency to the manifest file. + /// + /// - Throws: An error if package initialization or manifest modification fails. public func setupTemplateManifest() throws { // initialize empty swift package - let initializedPackage = try InitPackage(name: self.packageName, options: .init(packageType: self.packageType, supportedTestingLibraries: self.supportedTestingLibraries), destinationPath: self.destinationPath, installedSwiftPMConfiguration: self.installedSwiftPMConfiguration, fileSystem: self.fileSystem) + let initializedPackage = try InitPackage( + name: self.packageName, + options: .init(packageType: self.packageType, supportedTestingLibraries: self.supportedTestingLibraries), + destinationPath: self.destinationPath, + installedSwiftPMConfiguration: self.installedSwiftPMConfiguration, + fileSystem: self.fileSystem + ) try initializedPackage.writePackageStructure() - try initializePackageFromTemplate() - - //try build - // try --experimental-help-dump - //prompt - //run the executable. + try self.initializePackageFromTemplate() } + /// Initializes the package by adding the template dependency to the manifest. + /// + /// - Throws: An error if adding the dependency or modifying the manifest fails. private func initializePackageFromTemplate() throws { - try addTemplateDepenency() + try self.addTemplateDepenency() } - private func addTemplateDepenency() throws { - + /// Adds the template dependency to the package manifest. + /// + /// This reads the manifest file, parses it into a syntax tree, modifies it + /// to include the template dependency, and then writes the updated manifest + /// back to disk. + /// + /// - Throws: An error if the manifest file cannot be read, parsed, or modified. - let manifestPath = destinationPath.appending(component: Manifest.filename) + private func addTemplateDepenency() throws { + let manifestPath = self.destinationPath.appending(component: Manifest.filename) let manifestContents: ByteString do { - manifestContents = try fileSystem.readFileContents(manifestPath) + manifestContents = try self.fileSystem.readFileContents(manifestPath) } catch { throw StringError("Cannot find package manifest in \(manifestPath)") } @@ -142,22 +186,42 @@ public final class InitTemplatePackage { } let editResult = try AddPackageDependency.addPackageDependency( - packageDependency, to: manifestSyntax) - - try editResult.applyEdits(to: fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: false) + self.packageDependency, to: manifestSyntax + ) + + try editResult.applyEdits( + to: self.fileSystem, + manifest: manifestSyntax, + manifestPath: manifestPath, + verbose: false + ) } - + /// Prompts the user for input based on the given tool information. + /// + /// This method converts the command arguments of the tool into prompt questions, + /// collects user input, and builds a command line argument array from the responses. + /// + /// - Parameter tool: The tool information containing command and argument metadata. + /// - Returns: An array of strings representing the command line arguments built from user input. + /// - Throws: `TemplateError.noArguments` if the tool command has no arguments. + public func promptUser(tool: ToolInfoV0) throws -> [String] { let arguments = try convertArguments(from: tool.command) let responses = UserPrompter.prompt(for: arguments) - let commandLine = buildCommandLine(from: responses) + let commandLine = self.buildCommandLine(from: responses) return commandLine } + /// Converts the command information into an array of argument metadata. + /// + /// - Parameter command: The command info object. + /// - Returns: An array of argument info objects. + /// - Throws: `TemplateError.noArguments` if the command has no arguments. + private func convertArguments(from command: CommandInfoV0) throws -> [ArgumentInfoV0] { guard let rawArgs = command.arguments else { throw TemplateError.noArguments @@ -165,23 +229,31 @@ public final class InitTemplatePackage { return rawArgs } + /// A helper struct to prompt the user for input values for command arguments. - private struct UserPrompter { + private enum UserPrompter { + /// Prompts the user for input for each argument, handling flags, options, and positional arguments. + /// + /// - Parameter arguments: The list of argument metadata to prompt for. + /// - Returns: An array of `ArgumentResponse` representing the user's input. static func prompt(for arguments: [ArgumentInfoV0]) -> [ArgumentResponse] { - return arguments + arguments .filter { $0.valueName != "help" && $0.shouldDisplay != false } .map { arg in let defaultText = arg.defaultValue.map { " (default: \($0))" } ?? "" - let allValuesText = (arg.allValues?.isEmpty == false) ? " [\(arg.allValues!.joined(separator: ", "))]" : "" + let allValuesText = (arg.allValues?.isEmpty == false) ? + " [\(arg.allValues!.joined(separator: ", "))]" : "" let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(defaultText):" var values: [String] = [] switch arg.kind { case .flag: - let confirmed = promptForConfirmation(prompt: promptMessage, - defaultBehavior: arg.defaultValue?.lowercased() == "true") + let confirmed = promptForConfirmation( + prompt: promptMessage, + defaultBehavior: arg.defaultValue?.lowercased() == "true" + ) values = [confirmed ? "true" : "false"] case .option, .positional: @@ -190,7 +262,9 @@ public final class InitTemplatePackage { if arg.isRepeating { while let input = readLine(), !input.isEmpty { if let allowed = arg.allValues, !allowed.contains(input) { - print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + print( + "Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))" + ) continue } values.append(input) @@ -200,9 +274,11 @@ public final class InitTemplatePackage { } } else { let input = readLine() - if let input = input, !input.isEmpty { + if let input, !input.isEmpty { if let allowed = arg.allValues, !allowed.contains(input) { - print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + print( + "Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))" + ) exit(1) } values = [input] @@ -218,66 +294,87 @@ public final class InitTemplatePackage { } } } + + /// Builds an array of command line argument strings from the given argument responses. + /// + /// - Parameter responses: The array of argument responses containing user inputs. + /// - Returns: An array of strings representing the command line arguments. + func buildCommandLine(from responses: [ArgumentResponse]) -> [String] { - return responses.flatMap(\.commandLineFragments) + responses.flatMap(\.commandLineFragments) } - + /// Prompts the user for a yes/no confirmation. + /// + /// - Parameters: + /// - prompt: The prompt message to display. + /// - defaultBehavior: The default value if the user provides no input. + /// - Returns: `true` if the user confirmed, otherwise `false`. private static func promptForConfirmation(prompt: String, defaultBehavior: Bool?) -> Bool { - let suffix = defaultBehavior == true ? " [Y/n]" : defaultBehavior == false ? " [y/N]" : " [y/n]" - print(prompt + suffix, terminator: " ") - guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { - return defaultBehavior ?? false - } + let suffix = defaultBehavior == true ? " [Y/n]" : defaultBehavior == false ? " [y/N]" : " [y/n]" + print(prompt + suffix, terminator: " ") + guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + return defaultBehavior ?? false + } - switch input { - case "y", "yes": return true - case "n", "no": return false - default: return defaultBehavior ?? false - } + switch input { + case "y", "yes": return true + case "n", "no": return false + default: return defaultBehavior ?? false } + } + + /// Represents a user's response to an argument prompt. struct ArgumentResponse { + /// The argument metadata. + let argument: ArgumentInfoV0 + /// The values provided by the user. + let values: [String] + /// Returns the command line fragments representing this argument and its values. var commandLineFragments: [String] { guard let name = argument.valueName else { - return values + return self.values } - switch argument.kind { + switch self.argument.kind { case .flag: - return values.first == "true" ? ["--\(name)"] : [] + return self.values.first == "true" ? ["--\(name)"] : [] case .option: - return values.flatMap { ["--\(name)", $0] } + return self.values.flatMap { ["--\(name)", $0] } case .positional: - return values + return self.values } } } } - +/// An error enum representing various template-related errors. private enum TemplateError: Swift.Error { + /// The provided path is invalid or does not exist. case invalidPath + + /// A manifest file already exists in the target directory. case manifestAlreadyExists + + /// The template has no arguments to prompt for. case noArguments } - extension TemplateError: CustomStringConvertible { + /// A readable description of the error var description: String { switch self { case .manifestAlreadyExists: - return "a manifest file already exists in this directory" + "a manifest file already exists in this directory" case .invalidPath: - return "Path does not exist, or is invalid." + "Path does not exist, or is invalid." case .noArguments: - return "Template has no arguments" + "Template has no arguments" } } } - - From 9fd9e05f5c7e1ce557e029422cfd57274a9b71a4 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 11 Jun 2025 15:26:47 -0400 Subject: [PATCH 172/225] changed naming from template types (git, local, registry) to template source --- Sources/Commands/PackageCommands/Init.swift | 14 +++++++------- .../Commands/PackageCommands/ShowTemplates.swift | 2 +- .../TemplatePathResolver.swift | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 6ea942ec214..b6d0234a401 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -78,7 +78,7 @@ extension SwiftPackageCommand { /// The type of template to use: `registry`, `git`, or `local`. @Option(name: .customLong("template-type"), help: "Template type: registry, git, local.") - var templateType: InitTemplatePackage.TemplateType? + var templateSource: InitTemplatePackage.TemplateSource? /// Path to a local template. @Option(name: .customLong("template-path"), help: "Path to the local template.", completion: .directory) @@ -165,7 +165,7 @@ extension SwiftPackageCommand { let resolvedTemplatePath: Basics.AbsolutePath var requirement: PackageDependency.SourceControl.Requirement? - switch self.templateType { + switch self.templateSource { case .git: requirement = try DependencyRequirementResolver( exact: self.exact, @@ -177,7 +177,7 @@ extension SwiftPackageCommand { ).resolve() resolvedTemplatePath = try await TemplatePathResolver( - templateType: self.templateType, + templateSource: self.templateSource, templateDirectory: self.templateDirectory, templateURL: self.templateURL, requirement: requirement @@ -185,7 +185,7 @@ extension SwiftPackageCommand { case .local, .registry: resolvedTemplatePath = try await TemplatePathResolver( - templateType: self.templateType, + templateSource: self.templateSource, templateDirectory: self.templateDirectory, templateURL: self.templateURL, requirement: nil @@ -202,7 +202,7 @@ extension SwiftPackageCommand { // Clean up downloaded package after execution. defer { - if templateType == .git { + if templateSource == .git { try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) } } @@ -293,7 +293,7 @@ extension SwiftPackageCommand { requirement: PackageDependency.SourceControl.Requirement?, resolvedTemplatePath: Basics.AbsolutePath ) throws -> MappablePackageDependency.Kind { - switch self.templateType { + switch self.templateSource { case .local: return .fileSystem(name: self.packageName, path: resolvedTemplatePath.asURL.path) @@ -335,4 +335,4 @@ extension InitPackage.PackageType: ExpressibleByArgument { } } -extension InitTemplatePackage.TemplateType: ExpressibleByArgument {} +extension InitTemplatePackage.TemplateSource: ExpressibleByArgument {} diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 65c3e25689d..00cc83b2d7a 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -86,7 +86,7 @@ struct ShowTemplates: AsyncSwiftCommand { // Download and resolve the Git-based template. let resolver = TemplatePathResolver( - templateType: .git, + templateSource: .git, templateDirectory: nil, templateURL: templateURL, requirement: requirement diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index 0fb1cf6fad5..82039efcd5e 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -29,8 +29,8 @@ import Workspace /// Used during package initialization (e.g., via `swift package init --template`). struct TemplatePathResolver { - /// The type of template to resolve (e.g., local, git, registry). - let templateType: InitTemplatePackage.TemplateSource? + /// The source of template to resolve (e.g., local, git, registry). + let templateSource: InitTemplatePackage.TemplateSource? /// The local path to a template directory, used for `.local` templates. let templateDirectory: Basics.AbsolutePath? @@ -48,7 +48,7 @@ struct TemplatePathResolver { /// - `StringError` if required values (e.g., path, URL, requirement) are missing, /// or if the template type is unsupported or unspecified. func resolve() async throws -> Basics.AbsolutePath { - switch self.templateType { + switch self.templateSource { case .local: guard let path = templateDirectory else { throw StringError("Template path must be specified for local templates.") From 5604cd41cc5975a41ef277f54313fb77aaa38fd9 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 12 Jun 2025 13:37:28 -0400 Subject: [PATCH 173/225] generating templates from package registry --- Package.swift | 3 +- Sources/Commands/PackageCommands/Init.swift | 72 ++++++++-- .../PackageCommands/ShowTemplates.swift | 8 +- .../RequirementResolver.swift | 50 ++++--- .../TemplatePathResolver.swift | 125 +++++++++++++++++- 5 files changed, 217 insertions(+), 41 deletions(-) diff --git a/Package.swift b/Package.swift index c1f34839214..bb9328c002b 100644 --- a/Package.swift +++ b/Package.swift @@ -614,7 +614,8 @@ let package = Package( "XCBuildSupport", "SwiftBuildSupport", "SwiftFixIt", - ] + swiftSyntaxDependencies(["SwiftIDEUtils", "SwiftRefactor"]), + "PackageRegistry", + ] + swiftSyntaxDependencies(["SwiftIDEUtils"]), exclude: ["CMakeLists.txt", "README.md"], swiftSettings: swift6CompatibleExperimentalFeatures + [ .unsafeFlags(["-static"]), diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index b6d0234a401..7d96a7080d8 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -88,7 +88,10 @@ extension SwiftPackageCommand { @Option(name: .customLong("template-url"), help: "The git URL of the template.") var templateURL: String? - // MARK: - Versioning Options for Remote Git Templates + @Option(name: .customLong("package-id"), help: "The package identifier of the template") + var templatePackageID: String? + + // MARK: - Versioning Options for Remote Git Templates and Registry templates /// The exact version of the remote package to use. @Option(help: "The exact package version to depend on.") @@ -114,6 +117,8 @@ extension SwiftPackageCommand { @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? + + func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") @@ -163,34 +168,58 @@ extension SwiftPackageCommand { cwd: Basics.AbsolutePath ) async throws { let resolvedTemplatePath: Basics.AbsolutePath - var requirement: PackageDependency.SourceControl.Requirement? + var registryRequirement: PackageDependency.Registry.Requirement? + var sourceControlRequirement: PackageDependency.SourceControl.Requirement? switch self.templateSource { case .git: - requirement = try DependencyRequirementResolver( + sourceControlRequirement = try DependencyRequirementResolver( exact: self.exact, revision: self.revision, branch: self.branch, from: self.from, upToNextMinorFrom: self.upToNextMinorFrom, to: self.to - ).resolve() + ).resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + resolvedTemplatePath = try await TemplatePathResolver( templateSource: self.templateSource, templateDirectory: self.templateDirectory, templateURL: self.templateURL, - requirement: requirement - ).resolve() + sourceControlRequirement: sourceControlRequirement, + registryRequirement: nil, + packageIdentity: nil + ).resolve(swiftCommandState: swiftCommandState) - case .local, .registry: + case .local: resolvedTemplatePath = try await TemplatePathResolver( templateSource: self.templateSource, templateDirectory: self.templateDirectory, templateURL: self.templateURL, - requirement: nil - ).resolve() + sourceControlRequirement: nil, + registryRequirement: nil, + packageIdentity: nil + ).resolve(swiftCommandState: swiftCommandState) + case .registry: + + registryRequirement = try DependencyRequirementResolver( + exact: self.exact, + revision: self.revision, + branch: self.branch, + from: self.from, + upToNextMinorFrom: self.upToNextMinorFrom, + to: self.to + ).resolve(for: .registry) as? PackageDependency.Registry.Requirement + resolvedTemplatePath = try await TemplatePathResolver( + templateSource: self.templateSource, + templateDirectory: self.templateDirectory, + templateURL: self.templateURL, + sourceControlRequirement: nil, + registryRequirement: registryRequirement, + packageIdentity: templatePackageID + ).resolve(swiftCommandState: swiftCommandState) case .none: throw StringError("Missing template type") } @@ -202,7 +231,7 @@ extension SwiftPackageCommand { // Clean up downloaded package after execution. defer { - if templateSource == .git { + if templateSource == .git || templateSource == .registry { try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) } } @@ -216,7 +245,7 @@ extension SwiftPackageCommand { let initTemplatePackage = try InitTemplatePackage( name: packageName, templateName: template, - initMode: packageDependency(requirement: requirement, resolvedTemplatePath: resolvedTemplatePath), + initMode: packageDependency(sourceControlRequirement: sourceControlRequirement, registryRequirement: registryRequirement, resolvedTemplatePath: resolvedTemplatePath), templatePath: resolvedTemplatePath, fileSystem: swiftCommandState.fileSystem, packageType: templateInitType, @@ -290,7 +319,8 @@ extension SwiftPackageCommand { /// Transforms the author's package into the required dependency private func packageDependency( - requirement: PackageDependency.SourceControl.Requirement?, + sourceControlRequirement: PackageDependency.SourceControl.Requirement? = nil, + registryRequirement: PackageDependency.Registry.Requirement? = nil, resolvedTemplatePath: Basics.AbsolutePath ) throws -> MappablePackageDependency.Kind { switch self.templateSource { @@ -302,14 +332,28 @@ extension SwiftPackageCommand { throw StringError("Missing Git url") } - guard let gitRequirement = requirement else { + guard let gitRequirement = sourceControlRequirement else { throw StringError("Missing Git requirement") } return .sourceControl(name: self.packageName, location: url, requirement: gitRequirement) + case .registry: + + guard let packageID = templatePackageID else { + throw StringError("Missing Package ID") + } + + + guard let packageRegistryRequirement = registryRequirement else { + throw StringError("Missing Registry requirement") + } + + return .registry(id: packageID, requirement: packageRegistryRequirement) + default: - throw StringError("Not implemented yet") + throw StringError("Missing template source type") } + } } } diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 00cc83b2d7a..2507d6ea5be 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -82,16 +82,18 @@ struct ShowTemplates: AsyncSwiftCommand { from: from, upToNextMinorFrom: upToNextMinorFrom, to: to - ).resolve() + ).resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement // Download and resolve the Git-based template. let resolver = TemplatePathResolver( templateSource: .git, templateDirectory: nil, templateURL: templateURL, - requirement: requirement + sourceControlRequirement: requirement, + registryRequirement: nil, + packageIdentity: nil ) - packagePath = try await resolver.resolve() + packagePath = try await resolver.resolve(swiftCommandState: swiftCommandState) shouldDeleteAfter = true } else { diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index 48150041557..f4aab1d8288 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -58,25 +58,43 @@ struct DependencyRequirementResolver { /// - None of the requirement fields are set. /// - A `to` value is provided without a corresponding `from` or `upToNextMinorFrom`. - func resolve() throws -> PackageDependency.SourceControl.Requirement { - var all: [PackageDependency.SourceControl.Requirement] = [] + func resolve(for type: DependencyType) throws -> Any { + // Resolve all possibilities first + var allGitRequirements: [PackageDependency.SourceControl.Requirement] = [] + if let v = exact { allGitRequirements.append(.exact(v)) } + if let b = branch { allGitRequirements.append(.branch(b)) } + if let r = revision { allGitRequirements.append(.revision(r)) } + if let f = from { allGitRequirements.append(.range(.upToNextMajor(from: f))) } + if let u = upToNextMinorFrom { allGitRequirements.append(.range(.upToNextMinor(from: u))) } - if let v = exact { all.append(.exact(v)) } - if let b = branch { all.append(.branch(b)) } - if let r = revision { all.append(.revision(r)) } - if let f = from { all.append(.range(.upToNextMajor(from: f))) } - if let u = upToNextMinorFrom { all.append(.range(.upToNextMinor(from: u))) } + // For Registry, only exact or range allowed: + var allRegistryRequirements: [PackageDependency.Registry.Requirement] = [] + if let v = exact { allRegistryRequirements.append(.exact(v)) } - guard all.count == 1, let requirement = all.first else { - throw StringError("Specify exactly one version requirement.") - } + switch type { + case .sourceControl: + guard allGitRequirements.count == 1, let requirement = allGitRequirements.first else { + throw StringError("Specify exactly one source control version requirement.") + } + if case .range(let range) = requirement, let upper = to { + return PackageDependency.SourceControl.Requirement.range(range.lowerBound ..< upper) + } else if self.to != nil { + throw StringError("--to requires --from or --up-to-next-minor-from") + } + return requirement - if case .range(let range) = requirement, let upper = to { - return .range(range.lowerBound ..< upper) - } else if self.to != nil { - throw StringError("--to requires --from or --up-to-next-minor-from") + case .registry: + guard allRegistryRequirements.count == 1, let requirement = allRegistryRequirements.first else { + throw StringError("Specify exactly one registry version requirement.") + } + // Registry does not support `to` separately, so range should already consider upper bound + return requirement } - - return requirement } } + + +enum DependencyType { + case sourceControl + case registry +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index 82039efcd5e..31d336a8b44 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -17,6 +17,11 @@ import SourceControl import TSCBasic import TSCUtility import Workspace +import CoreCommands +import PackageRegistry +import ArgumentParser +import PackageFingerprint +import PackageSigning /// A utility responsible for resolving the path to a package template, /// based on the provided template type and associated configuration. @@ -39,7 +44,13 @@ struct TemplatePathResolver { let templateURL: String? /// The versioning requirement for the Git repository (e.g., exact version, branch, revision, or version range). - let requirement: PackageDependency.SourceControl.Requirement? + let sourceControlRequirement: PackageDependency.SourceControl.Requirement? + + /// The versioning requirement for the registry package (e.g., exact version). + let registryRequirement: PackageDependency.Registry.Requirement? + + /// The package identifier of the package in package-registry + let packageIdentity: String? /// Resolves the template path by downloading or validating it based on the template type. /// @@ -47,7 +58,7 @@ struct TemplatePathResolver { /// - Throws: /// - `StringError` if required values (e.g., path, URL, requirement) are missing, /// or if the template type is unsupported or unspecified. - func resolve() async throws -> Basics.AbsolutePath { + func resolve(swiftCommandState: SwiftCommandState) async throws -> Basics.AbsolutePath { switch self.templateSource { case .local: guard let path = templateDirectory else { @@ -60,24 +71,124 @@ struct TemplatePathResolver { throw StringError("Missing Git URL for git template.") } - guard let gitRequirement = requirement else { + guard let requirement = sourceControlRequirement else { throw StringError("Missing version requirement for git template.") } - return try await GitTemplateFetcher(destination: url, requirement: gitRequirement).fetch() + return try await GitTemplateFetcher(source: url, requirement: requirement).fetch() case .registry: - throw StringError("Registry templates not supported yet.") + + guard let packageID = packageIdentity else { + throw StringError("Missing package identity for registry template") + } + + guard let requirement = registryRequirement else { + throw StringError("Missing version requirement for registry template.") + } + + return try await RegistryTemplateFetcher().fetch(swiftCommandState: swiftCommandState, packageIdentity: packageID, requirement: requirement) case .none: throw StringError("Missing --template-type.") } } + struct RegistryTemplateFetcher { + + + func fetch(swiftCommandState: SwiftCommandState, packageIdentity: String, requirement: PackageDependency.Registry.Requirement) async throws -> Basics.AbsolutePath { + + return try await withTemporaryDirectory(removeTreeOnDeinit: false) { tempDir in + + let configuration = try TemplatePathResolver.RegistryTemplateFetcher.getRegistriesConfig(swiftCommandState, global: true) + let registryConfiguration = configuration.configuration + + let authorizationProvider: AuthorizationProvider? + authorizationProvider = try swiftCommandState.getRegistryAuthorizationProvider() + + + let registryClient = RegistryClient( + configuration: registryConfiguration, + fingerprintStorage: .none, + fingerprintCheckingMode: .strict, + skipSignatureValidation: false, + signingEntityStorage: .none, + signingEntityCheckingMode: .strict, + authorizationProvider: authorizationProvider, + delegate: .none, + checksumAlgorithm: SHA256() + ) + + + let package = PackageIdentity.plain(packageIdentity) + + switch requirement { + case .exact(let version): + try await registryClient.downloadSourceArchive( + package: package, + version: Version(0, 0, 0), + destinationPath: tempDir.appending(component: packageIdentity), + progressHandler: nil, + timeout: nil, + fileSystem: swiftCommandState.fileSystem, + observabilityScope: swiftCommandState.observabilityScope + ) + + default: fatalError("Unsupported requirement: \(requirement)") + + } + + // Unpack directory and bring it to temp directory level. + let contents = try swiftCommandState.fileSystem.getDirectoryContents(tempDir) + guard let extractedDir = contents.first else { + throw StringError("No directory found after extraction.") + } + let extractedPath = tempDir.appending(component: extractedDir) + + for item in try swiftCommandState.fileSystem.getDirectoryContents(extractedPath) { + let src = extractedPath.appending(component: item) + let dst = tempDir.appending(component: item) + try swiftCommandState.fileSystem.move(from: src, to: dst) + } + + // Optionally remove the now-empty subdirectory + try swiftCommandState.fileSystem.removeFileTree(extractedPath) + + return tempDir + } + } + + static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace.Configuration.Registries { + if global { + let sharedRegistriesFile = Workspace.DefaultLocations.registriesConfigurationFile( + at: swiftCommandState.sharedConfigurationDirectory + ) + // Workspace not needed when working with user-level registries config + return try .init( + fileSystem: swiftCommandState.fileSystem, + localRegistriesFile: .none, + sharedRegistriesFile: sharedRegistriesFile + ) + } else { + let workspace = try swiftCommandState.getActiveWorkspace() + return try .init( + fileSystem: swiftCommandState.fileSystem, + localRegistriesFile: workspace.location.localRegistriesConfigurationFile, + sharedRegistriesFile: workspace.location.sharedRegistriesConfigurationFile + ) + } + } + + + + } + + /// A helper that fetches a Git-based template repository and checks out the specified version or revision. struct GitTemplateFetcher { /// The Git URL of the remote repository. - let destination: String + let source: String /// The source control requirement used to determine which version/branch/revision to check out. let requirement: PackageDependency.SourceControl.Requirement @@ -90,7 +201,7 @@ struct TemplatePathResolver { let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in - let url = SourceControlURL(destination) + let url = SourceControlURL(source) let repositorySpecifier = RepositorySpecifier(url: url) let repositoryProvider = GitRepositoryProvider() From 2ee1882b11659cccc43128f3c75528644d194545 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 12 Jun 2025 13:55:38 -0400 Subject: [PATCH 174/225] added registry support for show-templates --- .../PackageCommands/ShowTemplates.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 2507d6ea5be..e23789ffa32 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -37,6 +37,9 @@ struct ShowTemplates: AsyncSwiftCommand { @Option(name: .customLong("template-url"), help: "The git URL of the template.") var templateURL: String? + @Option(name: .customLong("package-id"), help: "The package identifier of the template") + var templatePackageID: String? + /// Output format for the templates list. /// /// Can be either `.flatlist` (default) or `.json`. @@ -96,6 +99,29 @@ struct ShowTemplates: AsyncSwiftCommand { packagePath = try await resolver.resolve(swiftCommandState: swiftCommandState) shouldDeleteAfter = true + } else if let packageID = self.templatePackageID { + + let requirement = try DependencyRequirementResolver( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ).resolve(for: .registry) as? PackageDependency.Registry.Requirement + + // Download and resolve the Git-based template. + let resolver = TemplatePathResolver( + templateSource: .registry, + templateDirectory: nil, + templateURL: nil, + sourceControlRequirement: nil, + registryRequirement: requirement, + packageIdentity: packageID + ) + + packagePath = try await resolver.resolve(swiftCommandState: swiftCommandState) + shouldDeleteAfter = true } else { // Use the current working directory. guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { From 75db87f4803237f14b5903f0e15ad93cc6e24dbd Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 12 Jun 2025 15:43:45 -0400 Subject: [PATCH 175/225] formatting + documentation + quality --- Sources/Commands/PackageCommands/Init.swift | 143 ++---- .../PackageCommands/ShowTemplates.swift | 99 ++-- .../PackageDependencyBuilder.swift | 99 ++++ .../RequirementResolver.swift | 132 ++++-- .../TemplatePathResolver.swift | 434 +++++++++--------- 5 files changed, 505 insertions(+), 402 deletions(-) create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 7d96a7080d8..62c1d24c852 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -117,8 +117,6 @@ extension SwiftPackageCommand { @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? - - func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") @@ -167,63 +165,35 @@ extension SwiftPackageCommand { packageName: String, cwd: Basics.AbsolutePath ) async throws { - let resolvedTemplatePath: Basics.AbsolutePath - var registryRequirement: PackageDependency.Registry.Requirement? - var sourceControlRequirement: PackageDependency.SourceControl.Requirement? - - switch self.templateSource { - case .git: - sourceControlRequirement = try DependencyRequirementResolver( - exact: self.exact, - revision: self.revision, - branch: self.branch, - from: self.from, - upToNextMinorFrom: self.upToNextMinorFrom, - to: self.to - ).resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement - - - resolvedTemplatePath = try await TemplatePathResolver( - templateSource: self.templateSource, - templateDirectory: self.templateDirectory, - templateURL: self.templateURL, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: nil, - packageIdentity: nil - ).resolve(swiftCommandState: swiftCommandState) - - case .local: - resolvedTemplatePath = try await TemplatePathResolver( - templateSource: self.templateSource, - templateDirectory: self.templateDirectory, - templateURL: self.templateURL, - sourceControlRequirement: nil, - registryRequirement: nil, - packageIdentity: nil - ).resolve(swiftCommandState: swiftCommandState) - case .registry: - - registryRequirement = try DependencyRequirementResolver( - exact: self.exact, - revision: self.revision, - branch: self.branch, - from: self.from, - upToNextMinorFrom: self.upToNextMinorFrom, - to: self.to - ).resolve(for: .registry) as? PackageDependency.Registry.Requirement - - resolvedTemplatePath = try await TemplatePathResolver( - templateSource: self.templateSource, - templateDirectory: self.templateDirectory, - templateURL: self.templateURL, - sourceControlRequirement: nil, - registryRequirement: registryRequirement, - packageIdentity: templatePackageID - ).resolve(swiftCommandState: swiftCommandState) - case .none: - throw StringError("Missing template type") + guard let source = templateSource else { + throw ValidationError("No template source specified.") } + let requirementResolver = DependencyRequirementResolver( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ) + + let registryRequirement: PackageDependency.Registry.Requirement? = + try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement + + let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = + try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + + let resolvedTemplatePath = try await TemplatePathResolver( + source: templateSource, + templateDirectory: templateDirectory, + templateURL: templateURL, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() + let templateInitType = try await swiftCommandState .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in try await self.checkConditions(swiftCommandState) @@ -231,8 +201,11 @@ extension SwiftPackageCommand { // Clean up downloaded package after execution. defer { - if templateSource == .git || templateSource == .registry { + if templateSource == .git { try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) + } else if templateSource == .registry { + let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL + try? FileManager.default.removeItem(at: parentDirectoryURL) } } @@ -242,10 +215,23 @@ extension SwiftPackageCommand { swiftCommandState: swiftCommandState ) + let builder = DefaultPackageDependencyBuilder( + templateSource: source, + packageName: packageName, + templateURL: self.templateURL, + templatePackageID: self.templatePackageID + ) + + let dependencyKind = try builder.makePackageDependency( + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ) + let initTemplatePackage = try InitTemplatePackage( name: packageName, templateName: template, - initMode: packageDependency(sourceControlRequirement: sourceControlRequirement, registryRequirement: registryRequirement, resolvedTemplatePath: resolvedTemplatePath), + initMode: dependencyKind, templatePath: resolvedTemplatePath, fileSystem: swiftCommandState.fileSystem, packageType: templateInitType, @@ -316,45 +302,6 @@ extension SwiftPackageCommand { } throw InternalError("Could not find template \(self.template)") } - - /// Transforms the author's package into the required dependency - private func packageDependency( - sourceControlRequirement: PackageDependency.SourceControl.Requirement? = nil, - registryRequirement: PackageDependency.Registry.Requirement? = nil, - resolvedTemplatePath: Basics.AbsolutePath - ) throws -> MappablePackageDependency.Kind { - switch self.templateSource { - case .local: - return .fileSystem(name: self.packageName, path: resolvedTemplatePath.asURL.path) - - case .git: - guard let url = templateURL else { - throw StringError("Missing Git url") - } - - guard let gitRequirement = sourceControlRequirement else { - throw StringError("Missing Git requirement") - } - return .sourceControl(name: self.packageName, location: url, requirement: gitRequirement) - - case .registry: - - guard let packageID = templatePackageID else { - throw StringError("Missing Package ID") - } - - - guard let packageRegistryRequirement = registryRequirement else { - throw StringError("Missing Registry requirement") - } - - return .registry(id: packageID, requirement: packageRegistryRequirement) - - default: - throw StringError("Missing template source type") - } - - } } } diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index e23789ffa32..9a25f254470 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -76,71 +76,86 @@ struct ShowTemplates: AsyncSwiftCommand { let packagePath: Basics.AbsolutePath var shouldDeleteAfter = false - if let templateURL = self.templateURL { - // Resolve dependency requirement based on provided options. - let requirement = try DependencyRequirementResolver( - exact: exact, - revision: revision, - branch: branch, - from: from, - upToNextMinorFrom: upToNextMinorFrom, - to: to - ).resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + let requirementResolver = DependencyRequirementResolver( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ) + + let registryRequirement: PackageDependency.Registry.Requirement? = + try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement + + let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = + try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + + var resolvedTemplatePath: Basics.AbsolutePath + var templateSource: InitTemplatePackage.TemplateSource + if let templateURL = self.templateURL { // Download and resolve the Git-based template. - let resolver = TemplatePathResolver( - templateSource: .git, + resolvedTemplatePath = try await TemplatePathResolver( + source: .git, templateDirectory: nil, templateURL: templateURL, - sourceControlRequirement: requirement, - registryRequirement: nil, - packageIdentity: nil - ) - packagePath = try await resolver.resolve(swiftCommandState: swiftCommandState) - shouldDeleteAfter = true - - } else if let packageID = self.templatePackageID { + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: self.templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() - let requirement = try DependencyRequirementResolver( - exact: exact, - revision: revision, - branch: branch, - from: from, - upToNextMinorFrom: upToNextMinorFrom, - to: to - ).resolve(for: .registry) as? PackageDependency.Registry.Requirement + templateSource = .git + } else if let packageID = self.templatePackageID { // Download and resolve the Git-based template. - let resolver = TemplatePathResolver( - templateSource: .registry, + resolvedTemplatePath = try await TemplatePathResolver( + source: .registry, templateDirectory: nil, templateURL: nil, - sourceControlRequirement: nil, - registryRequirement: requirement, - packageIdentity: packageID - ) + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: self.templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() + + templateSource = .registry - packagePath = try await resolver.resolve(swiftCommandState: swiftCommandState) - shouldDeleteAfter = true } else { // Use the current working directory. guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("No template URL provided and no current directory") } - packagePath = cwd + + resolvedTemplatePath = try await TemplatePathResolver( + source: .local, + templateDirectory: cwd, + templateURL: nil, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: nil, + swiftCommandState: swiftCommandState + ).resolve() + + templateSource = .local } // Clean up downloaded package after execution. defer { - if shouldDeleteAfter { - try? FileManager.default.removeItem(atPath: packagePath.pathString) + if templateSource == .git { + try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) + } else if templateSource == .registry { + let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL + try? FileManager.default.removeItem(at: parentDirectoryURL) } } // Load the package graph. - let packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: packagePath) { _, _ in - try await swiftCommandState.loadPackageGraph() - } + let packageGraph = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in + try await swiftCommandState.loadPackageGraph() + } let rootPackages = packageGraph.rootPackages.map(\.identity) diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift new file mode 100644 index 00000000000..5c84727436c --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation +import PackageModel +import TSCBasic +import TSCUtility +import Workspace + +/// A protocol for building `MappablePackageDependency.Kind` instances from provided dependency information. +/// +/// Conforming types are responsible for converting high-level dependency configuration +/// (such as template source type and associated metadata) into a concrete dependency +/// that SwiftPM can work with. +protocol PackageDependencyBuilder { + /// Constructs a `MappablePackageDependency.Kind` based on the provided requirements and template path. + /// + /// - Parameters: + /// - sourceControlRequirement: The source control requirement (e.g., Git-based), if applicable. + /// - registryRequirement: The registry requirement, if applicable. + /// - resolvedTemplatePath: The resolved absolute path to a local package template, if applicable. + /// + /// - Returns: A concrete `MappablePackageDependency.Kind` value. + /// + /// - Throws: A `StringError` if required inputs (e.g., Git URL, Package ID) are missing or invalid for the selected + /// source type. + func makePackageDependency( + sourceControlRequirement: PackageDependency.SourceControl.Requirement?, + registryRequirement: PackageDependency.Registry.Requirement?, + resolvedTemplatePath: Basics.AbsolutePath + ) throws -> MappablePackageDependency.Kind +} + +/// Default implementation of `PackageDependencyBuilder` that builds a package dependency +/// from a given template source and metadata. +/// +/// This struct is typically used when initializing new packages from templates via SwiftPM. +struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { + /// The source type of the package template (e.g., local file system, Git repository, or registry). + let templateSource: InitTemplatePackage.TemplateSource + + /// The name to assign to the resulting package dependency. + let packageName: String + + /// The URL of the Git repository, if the template source is Git-based. + let templateURL: String? + + /// The registry package identifier, if the template source is registry-based. + let templatePackageID: String? + + /// Constructs a package dependency kind based on the selected template source. + /// + /// - Parameters: + /// - sourceControlRequirement: The requirement for Git-based dependencies. + /// - registryRequirement: The requirement for registry-based dependencies. + /// - resolvedTemplatePath: The local file path for filesystem-based dependencies. + /// + /// - Returns: A `MappablePackageDependency.Kind` representing the dependency. + /// + /// - Throws: A `StringError` if necessary information is missing or mismatched for the selected template source. + func makePackageDependency( + sourceControlRequirement: PackageDependency.SourceControl.Requirement? = nil, + registryRequirement: PackageDependency.Registry.Requirement? = nil, + resolvedTemplatePath: Basics.AbsolutePath + ) throws -> MappablePackageDependency.Kind { + switch self.templateSource { + case .local: + return .fileSystem(name: self.packageName, path: resolvedTemplatePath.asURL.path) + + case .git: + guard let url = templateURL else { + throw StringError("Missing Git url") + } + guard let requirement = sourceControlRequirement else { + throw StringError("Missing Git requirement") + } + return .sourceControl(name: self.packageName, location: url, requirement: requirement) + + case .registry: + guard let id = templatePackageID else { + throw StringError("Missing Package ID") + } + guard let requirement = registryRequirement else { + throw StringError("Missing Registry requirement") + } + return .registry(id: id, requirement: requirement) + } + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index f4aab1d8288..61b2981074a 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -14,24 +14,29 @@ import PackageModel import TSCBasic import TSCUtility -/// A utility for resolving a single, well-formed source control dependency requirement -/// based on mutually exclusive versioning inputs such as `exact`, `branch`, `revision`, -/// or version ranges (`from`, `upToNextMinorFrom`, `to`). -/// -/// This is typically used to translate user-specified version inputs (e.g., from the command line) -/// into a concrete `PackageDependency.SourceControl.Requirement` that SwiftPM can understand. -/// -/// Only one of the following fields should be non-nil: -/// - `exact`: A specific version (e.g., 1.2.3). -/// - `revision`: A specific VCS revision (e.g., commit hash). -/// - `branch`: A named branch (e.g., "main"). -/// - `from`: Lower bound of a version range with an upper bound inferred as the next major version. -/// - `upToNextMinorFrom`: Lower bound of a version range with an upper bound inferred as the next minor version. +/// A protocol defining an interface for resolving package dependency requirements +/// based on a user’s input (such as version, branch, or revision). +protocol DependencyRequirementResolving { + /// Resolves the requirement for the specified dependency type. + /// + /// - Parameter type: The type of dependency (`.sourceControl` or `.registry`) to resolve. + /// - Returns: A resolved requirement (`SourceControl.Requirement` or `Registry.Requirement`) as `Any`. + /// - Throws: `StringError` if resolution fails due to invalid or conflicting input. + func resolve(for type: DependencyType) throws -> Any +} + +/// A utility for resolving a single, well-formed package dependency requirement +/// from mutually exclusive versioning inputs, such as: +/// - `exact`: A specific version (e.g., 1.2.3) +/// - `branch`: A branch name (e.g., "main") +/// - `revision`: A commit hash or VCS revision +/// - `from` / `upToNextMinorFrom`: Lower bounds for version ranges +/// - `to`: An optional upper bound that refines a version range /// -/// Optionally, a `to` value can be specified to manually cap the upper bound of a version range, -/// but it must be combined with `from` or `upToNextMinorFrom`. +/// This resolver ensures only one form of versioning input is specified and validates combinations like `to` with +/// `from`. -struct DependencyRequirementResolver { +struct DependencyRequirementResolver: DependencyRequirementResolving { /// An exact version to use. let exact: Version? @@ -50,51 +55,78 @@ struct DependencyRequirementResolver { /// An optional manual upper bound for the version range. Must be used with `from` or `upToNextMinorFrom`. let to: Version? - /// Resolves the provided requirement fields into a concrete `PackageDependency.SourceControl.Requirement`. + /// Resolves a concrete requirement based on the provided fields and target dependency type. /// - /// - Returns: A valid, single requirement representing a source control constraint. - /// - Throws: A `StringError` if: - /// - More than one requirement type is provided. - /// - None of the requirement fields are set. - /// - A `to` value is provided without a corresponding `from` or `upToNextMinorFrom`. + /// - Parameter type: The dependency type to resolve (`.sourceControl` or `.registry`). + /// - Returns: A resolved requirement object (`PackageDependency.SourceControl.Requirement` or + /// `PackageDependency.Registry.Requirement`). + /// - Throws: `StringError` if the inputs are invalid, ambiguous, or incomplete. func resolve(for type: DependencyType) throws -> Any { - // Resolve all possibilities first - var allGitRequirements: [PackageDependency.SourceControl.Requirement] = [] - if let v = exact { allGitRequirements.append(.exact(v)) } - if let b = branch { allGitRequirements.append(.branch(b)) } - if let r = revision { allGitRequirements.append(.revision(r)) } - if let f = from { allGitRequirements.append(.range(.upToNextMajor(from: f))) } - if let u = upToNextMinorFrom { allGitRequirements.append(.range(.upToNextMinor(from: u))) } - - // For Registry, only exact or range allowed: - var allRegistryRequirements: [PackageDependency.Registry.Requirement] = [] - if let v = exact { allRegistryRequirements.append(.exact(v)) } - switch type { case .sourceControl: - guard allGitRequirements.count == 1, let requirement = allGitRequirements.first else { - throw StringError("Specify exactly one source control version requirement.") - } - if case .range(let range) = requirement, let upper = to { - return PackageDependency.SourceControl.Requirement.range(range.lowerBound ..< upper) - } else if self.to != nil { - throw StringError("--to requires --from or --up-to-next-minor-from") - } - return requirement - + try self.resolveSourceControlRequirement() case .registry: - guard allRegistryRequirements.count == 1, let requirement = allRegistryRequirements.first else { - throw StringError("Specify exactly one registry version requirement.") - } - // Registry does not support `to` separately, so range should already consider upper bound - return requirement + try self.resolveRegistryRequirement() } } -} + /// Internal helper for resolving a source control (Git) requirement. + /// + /// - Returns: A valid `PackageDependency.SourceControl.Requirement`. + /// - Throws: `StringError` if multiple or no input fields are set, or if `to` is used without `from` or + /// `upToNextMinorFrom`. + private func resolveSourceControlRequirement() throws -> PackageDependency.SourceControl.Requirement { + var requirements: [PackageDependency.SourceControl.Requirement] = [] + if let v = exact { requirements.append(.exact(v)) } + if let b = branch { requirements.append(.branch(b)) } + if let r = revision { requirements.append(.revision(r)) } + if let f = from { requirements.append(.range(.upToNextMajor(from: f))) } + if let u = upToNextMinorFrom { requirements.append(.range(.upToNextMinor(from: u))) } + + guard requirements.count == 1, let requirement = requirements.first else { + throw StringError("Specify exactly one source control version requirement.") + } + + if case .range(let range) = requirement, let upper = to { + return .range(range.lowerBound ..< upper) + } else if self.to != nil { + throw StringError("--to requires --from or --up-to-next-minor-from") + } + + return requirement + } + + /// Internal helper for resolving a registry-based requirement. + /// + /// - Returns: A valid `PackageDependency.Registry.Requirement`. + /// - Throws: `StringError` if more than one registry versioning input is provided or if `to` is used without a base + /// range. + private func resolveRegistryRequirement() throws -> PackageDependency.Registry.Requirement { + var requirements: [PackageDependency.Registry.Requirement] = [] + + if let v = exact { requirements.append(.exact(v)) } + if let f = from { requirements.append(.range(.upToNextMajor(from: f))) } + if let u = upToNextMinorFrom { requirements.append(.range(.upToNextMinor(from: u))) } + + guard requirements.count == 1, let requirement = requirements.first else { + throw StringError("Specify exactly one source control version requirement.") + } + + if case .range(let range) = requirement, let upper = to { + return .range(range.lowerBound ..< upper) + } else if self.to != nil { + throw StringError("--to requires --from or --up-to-next-minor-from") + } + + return requirement + } +} +/// Enum representing the type of dependency to resolve. enum DependencyType { + /// A source control dependency, such as a Git repository. case sourceControl + /// A registry dependency, typically resolved from a package registry. case registry } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index 31d336a8b44..be18d97df36 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -10,274 +10,284 @@ // //===----------------------------------------------------------------------===// +import ArgumentParser import Basics +import CoreCommands import Foundation +import PackageFingerprint import PackageModel +import PackageRegistry +import PackageSigning import SourceControl import TSCBasic import TSCUtility import Workspace -import CoreCommands -import PackageRegistry -import ArgumentParser -import PackageFingerprint -import PackageSigning -/// A utility responsible for resolving the path to a package template, -/// based on the provided template type and associated configuration. -/// -/// Supported template types include: -/// - `.local`: A local file system path to a template directory. -/// - `.git`: A remote Git repository containing the template. -/// - `.registry`: (Currently unsupported) +/// A protocol representing a generic package template fetcher. /// -/// Used during package initialization (e.g., via `swift package init --template`). +/// Conforming types encapsulate the logic to retrieve a template from a given source, +/// such as a local path, Git repository, or registry. The template is expected to be +/// returned as an absolute path to its location on the file system. +protocol TemplateFetcher { + func fetch() async throws -> Basics.AbsolutePath +} +/// Resolves the path to a Swift package template based on the specified template source. +/// +/// This struct determines how to obtain the template, whether from: +/// - A local directory (`.local`) +/// - A Git repository (`.git`) +/// - A Swift package registry (`.registry`) +/// +/// It abstracts the underlying fetch logic using a strategy pattern via the `TemplateFetcher` protocol. +/// +/// Usage: +/// ```swift +/// let resolver = try TemplatePathResolver(...) +/// let templatePath = try await resolver.resolve() +/// ``` struct TemplatePathResolver { - /// The source of template to resolve (e.g., local, git, registry). - let templateSource: InitTemplatePackage.TemplateSource? - - /// The local path to a template directory, used for `.local` templates. - let templateDirectory: Basics.AbsolutePath? - - /// The URL of the Git repository containing the template, used for `.git` templates. - let templateURL: String? - - /// The versioning requirement for the Git repository (e.g., exact version, branch, revision, or version range). - let sourceControlRequirement: PackageDependency.SourceControl.Requirement? + let fetcher: TemplateFetcher - /// The versioning requirement for the registry package (e.g., exact version). - let registryRequirement: PackageDependency.Registry.Requirement? - - /// The package identifier of the package in package-registry - let packageIdentity: String? - - /// Resolves the template path by downloading or validating it based on the template type. + /// Initializes a TemplatePathResolver with the given source and options. + /// + /// - Parameters: + /// - source: The type of template source (`local`, `git`, or `registry`). + /// - templateDirectory: Local path if using `.local` source. + /// - templateURL: Git URL if using `.git` source. + /// - sourceControlRequirement: Versioning or branch details for Git. + /// - registryRequirement: Versioning requirement for registry. + /// - packageIdentity: Package name/identity used with registry templates. + /// - swiftCommandState: Command state to access file system and config. /// - /// - Returns: The resolved path to the template directory. - /// - Throws: - /// - `StringError` if required values (e.g., path, URL, requirement) are missing, - /// or if the template type is unsupported or unspecified. - func resolve(swiftCommandState: SwiftCommandState) async throws -> Basics.AbsolutePath { - switch self.templateSource { + /// - Throws: `StringError` if any required parameter is missing. + init( + source: InitTemplatePackage.TemplateSource?, + templateDirectory: Basics.AbsolutePath?, + templateURL: String?, + sourceControlRequirement: PackageDependency.SourceControl.Requirement?, + registryRequirement: PackageDependency.Registry.Requirement?, + packageIdentity: String?, + swiftCommandState: SwiftCommandState + ) throws { + switch source { case .local: guard let path = templateDirectory else { throw StringError("Template path must be specified for local templates.") } - return path + self.fetcher = LocalTemplateFetcher(path: path) case .git: - guard let url = templateURL else { - throw StringError("Missing Git URL for git template.") - } - - guard let requirement = sourceControlRequirement else { - throw StringError("Missing version requirement for git template.") + guard let url = templateURL, let requirement = sourceControlRequirement else { + throw StringError("Missing Git URL or requirement for git template.") } - - return try await GitTemplateFetcher(source: url, requirement: requirement).fetch() + self.fetcher = GitTemplateFetcher(source: url, requirement: requirement) case .registry: - - guard let packageID = packageIdentity else { - throw StringError("Missing package identity for registry template") - } - - guard let requirement = registryRequirement else { - throw StringError("Missing version requirement for registry template.") + guard let identity = packageIdentity, let requirement = registryRequirement else { + throw StringError("Missing registry package identity or requirement.") } - - return try await RegistryTemplateFetcher().fetch(swiftCommandState: swiftCommandState, packageIdentity: packageID, requirement: requirement) + self.fetcher = RegistryTemplateFetcher( + swiftCommandState: swiftCommandState, + packageIdentity: identity, + requirement: requirement + ) case .none: throw StringError("Missing --template-type.") } } - struct RegistryTemplateFetcher { + /// Resolves the template path by executing the underlying fetcher. + /// + /// - Returns: Absolute path to the downloaded or located template directory. + /// - Throws: Any error encountered during fetch. + func resolve() async throws -> Basics.AbsolutePath { + try await self.fetcher.fetch() + } +} +/// Fetcher implementation for local file system templates. +/// +/// Simply returns the provided path as-is, assuming it exists and is valid. +struct LocalTemplateFetcher: TemplateFetcher { + let path: Basics.AbsolutePath - func fetch(swiftCommandState: SwiftCommandState, packageIdentity: String, requirement: PackageDependency.Registry.Requirement) async throws -> Basics.AbsolutePath { + func fetch() async throws -> Basics.AbsolutePath { + self.path + } +} + +/// Fetches a Swift package template from a Git repository based on a specified requirement. +/// +/// Supports: +/// - Checkout by tag (exact version) +/// - Checkout by branch +/// - Checkout by specific revision +/// - Checkout the highest version within a version range +/// +/// The template is cloned into a temporary directory, checked out, and returned. - return try await withTemporaryDirectory(removeTreeOnDeinit: false) { tempDir in +struct GitTemplateFetcher: TemplateFetcher { + /// The Git URL of the remote repository. + let source: String - let configuration = try TemplatePathResolver.RegistryTemplateFetcher.getRegistriesConfig(swiftCommandState, global: true) - let registryConfiguration = configuration.configuration + /// The source control requirement used to determine which version/branch/revision to check out. + let requirement: PackageDependency.SourceControl.Requirement - let authorizationProvider: AuthorizationProvider? - authorizationProvider = try swiftCommandState.getRegistryAuthorizationProvider() + /// Fetches the repository and returns the path to the checked-out working copy. + /// + /// - Returns: A path to the directory containing the fetched template. + /// - Throws: Any error encountered during repository fetch, checkout, or validation. + /// Fetches a bare clone of the Git repository to the specified path. + func fetch() async throws -> Basics.AbsolutePath { + let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in + try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in - let registryClient = RegistryClient( - configuration: registryConfiguration, - fingerprintStorage: .none, - fingerprintCheckingMode: .strict, - skipSignatureValidation: false, - signingEntityStorage: .none, - signingEntityCheckingMode: .strict, - authorizationProvider: authorizationProvider, - delegate: .none, - checksumAlgorithm: SHA256() - ) + let url = SourceControlURL(source) + let repositorySpecifier = RepositorySpecifier(url: url) + let repositoryProvider = GitRepositoryProvider() + let bareCopyPath = tempDir.appending(component: "bare-copy") - let package = PackageIdentity.plain(packageIdentity) + let workingCopyPath = tempDir.appending(component: "working-copy") - switch requirement { - case .exact(let version): - try await registryClient.downloadSourceArchive( - package: package, - version: Version(0, 0, 0), - destinationPath: tempDir.appending(component: packageIdentity), - progressHandler: nil, - timeout: nil, - fileSystem: swiftCommandState.fileSystem, - observabilityScope: swiftCommandState.observabilityScope - ) + try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) - default: fatalError("Unsupported requirement: \(requirement)") + try self.validateDirectory(provider: repositoryProvider, at: bareCopyPath) - } + try FileManager.default.createDirectory( + atPath: workingCopyPath.pathString, + withIntermediateDirectories: true + ) - // Unpack directory and bring it to temp directory level. - let contents = try swiftCommandState.fileSystem.getDirectoryContents(tempDir) - guard let extractedDir = contents.first else { - throw StringError("No directory found after extraction.") - } - let extractedPath = tempDir.appending(component: extractedDir) + let repository = try repositoryProvider.createWorkingCopyFromBare( + repository: repositorySpecifier, + sourcePath: bareCopyPath, + at: workingCopyPath, + editable: true + ) - for item in try swiftCommandState.fileSystem.getDirectoryContents(extractedPath) { - let src = extractedPath.appending(component: item) - let dst = tempDir.appending(component: item) - try swiftCommandState.fileSystem.move(from: src, to: dst) - } + try FileManager.default.removeItem(at: bareCopyPath.asURL) - // Optionally remove the now-empty subdirectory - try swiftCommandState.fileSystem.removeFileTree(extractedPath) + try self.checkout(repository: repository) - return tempDir + return workingCopyPath } } - static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace.Configuration.Registries { - if global { - let sharedRegistriesFile = Workspace.DefaultLocations.registriesConfigurationFile( - at: swiftCommandState.sharedConfigurationDirectory - ) - // Workspace not needed when working with user-level registries config - return try .init( - fileSystem: swiftCommandState.fileSystem, - localRegistriesFile: .none, - sharedRegistriesFile: sharedRegistriesFile - ) - } else { - let workspace = try swiftCommandState.getActiveWorkspace() - return try .init( - fileSystem: swiftCommandState.fileSystem, - localRegistriesFile: workspace.location.localRegistriesConfigurationFile, - sharedRegistriesFile: workspace.location.sharedRegistriesConfigurationFile - ) - } - } - - - + return try await fetchStandalonePackageByURL() } - - /// A helper that fetches a Git-based template repository and checks out the specified version or revision. - struct GitTemplateFetcher { - /// The Git URL of the remote repository. - let source: String - - /// The source control requirement used to determine which version/branch/revision to check out. - let requirement: PackageDependency.SourceControl.Requirement - - /// Fetches the repository and returns the path to the checked-out working copy. - /// - /// - Returns: A path to the directory containing the fetched template. - /// - Throws: Any error encountered during repository fetch, checkout, or validation. - func fetch() async throws -> Basics.AbsolutePath { - let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in - try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in - - let url = SourceControlURL(source) - let repositorySpecifier = RepositorySpecifier(url: url) - let repositoryProvider = GitRepositoryProvider() - - let bareCopyPath = tempDir.appending(component: "bare-copy") - - let workingCopyPath = tempDir.appending(component: "working-copy") - - try self.fetchBareRepository( - provider: repositoryProvider, - specifier: repositorySpecifier, - to: bareCopyPath - ) - try self.validateDirectory(provider: repositoryProvider, at: bareCopyPath) - - try FileManager.default.createDirectory( - atPath: workingCopyPath.pathString, - withIntermediateDirectories: true - ) - - let repository = try repositoryProvider.createWorkingCopyFromBare( - repository: repositorySpecifier, - sourcePath: bareCopyPath, - at: workingCopyPath, - editable: true - ) - - try FileManager.default.removeItem(at: bareCopyPath.asURL) - - try self.checkout(repository: repository) - - return workingCopyPath - } - } - - return try await fetchStandalonePackageByURL() - } - - /// Fetches a bare clone of the Git repository to the specified path. - private func fetchBareRepository( - provider: GitRepositoryProvider, - specifier: RepositorySpecifier, - to path: Basics.AbsolutePath - ) throws { - try provider.fetch(repository: specifier, to: path) + /// Validates that the directory contains a valid Git repository. + private func validateDirectory(provider: GitRepositoryProvider, at path: Basics.AbsolutePath) throws { + guard try provider.isValidDirectory(path) else { + throw InternalError("Invalid directory at \(path)") } + } - /// Validates that the directory contains a valid Git repository. - private func validateDirectory(provider: GitRepositoryProvider, at path: Basics.AbsolutePath) throws { - guard try provider.isValidDirectory(path) else { - throw InternalError("Invalid directory at \(path)") + /// Checks out the desired state (branch, tag, revision) in the working copy based on the requirement. + /// + /// - Throws: An error if no matching version is found in a version range, or if checkout fails. + private func checkout(repository: WorkingCheckout) throws { + switch self.requirement { + case .exact(let version): + try repository.checkout(tag: version.description) + + case .branch(let name): + try repository.checkout(branch: name) + + case .revision(let revision): + try repository.checkout(revision: .init(identifier: revision)) + + case .range(let range): + let tags = try repository.getTags() + let versions = tags.compactMap { Version($0) } + let filteredVersions = versions.filter { range.contains($0) } + guard let latestVersion = filteredVersions.max() else { + throw InternalError("No tags found within the specified version range \(range)") } + try repository.checkout(tag: latestVersion.description) } + } +} - /// Checks out the desired state (branch, tag, revision) in the working copy based on the requirement. - /// - /// - Throws: An error if no matching version is found in a version range, or if checkout fails. - private func checkout(repository: WorkingCheckout) throws { - switch self.requirement { - case .exact(let version): - try repository.checkout(tag: version.description) - - case .branch(let name): - try repository.checkout(branch: name) - - case .revision(let revision): - try repository.checkout(revision: .init(identifier: revision)) - - case .range(let range): - let tags = try repository.getTags() - let versions = tags.compactMap { Version($0) } - let filteredVersions = versions.filter { range.contains($0) } - guard let latestVersion = filteredVersions.max() else { - throw InternalError("No tags found within the specified version range \(range)") - } - try repository.checkout(tag: latestVersion.description) +/// Fetches a Swift package template from a package registry. +/// +/// Downloads the source archive for the specified package and version. +/// Extracts it to a temporary directory and returns the path. +/// +/// Supports: +/// - Exact version +/// - Upper bound of a version range (e.g., latest version within a range) +struct RegistryTemplateFetcher: TemplateFetcher { + /// The swiftCommandState of the current process. + /// Used to get configurations and authentication needed to get package from registry + let swiftCommandState: SwiftCommandState + + /// The package identifier of the package in registry + let packageIdentity: String + /// The registry requirement used to determine which version to fetch. + let requirement: PackageDependency.Registry.Requirement + + /// Performs the registry fetch by downloading and extracting a source archive. + /// + /// - Returns: Absolute path to the extracted template directory. + /// - Throws: If registry configuration is invalid or the download fails. + + func fetch() async throws -> Basics.AbsolutePath { + try await withTemporaryDirectory(removeTreeOnDeinit: false) { tempDir in + let config = try Self.getRegistriesConfig(self.swiftCommandState, global: true) + let auth = try swiftCommandState.getRegistryAuthorizationProvider() + + let registryClient = RegistryClient( + configuration: config.configuration, + fingerprintStorage: .none, + fingerprintCheckingMode: .strict, + skipSignatureValidation: false, + signingEntityStorage: .none, + signingEntityCheckingMode: .strict, + authorizationProvider: auth, + delegate: .none, + checksumAlgorithm: SHA256() + ) + + let identity = PackageIdentity.plain(self.packageIdentity) + + let version: Version = switch self.requirement { + case .exact(let ver): ver + case .range(let range): range.upperBound } + + let dest = tempDir.appending(component: self.packageIdentity) + try await registryClient.downloadSourceArchive( + package: identity, + version: version, + destinationPath: dest, + progressHandler: nil, + timeout: nil, + fileSystem: self.swiftCommandState.fileSystem, + observabilityScope: self.swiftCommandState.observabilityScope + ) + + return dest } } + + /// Resolves the registry configuration from shared SwiftPM configuration. + /// + /// - Returns: Registry configuration to use for fetching packages. + /// - Throws: If configuration files are missing or unreadable. + private static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace + .Configuration.Registries + { + let sharedFile = Workspace.DefaultLocations + .registriesConfigurationFile(at: swiftCommandState.sharedConfigurationDirectory) + return try .init( + fileSystem: swiftCommandState.fileSystem, + localRegistriesFile: .none, + sharedRegistriesFile: sharedFile + ) + } } From 44102563db6a9f1c01afa3489282507c3f9fce2f Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Fri, 13 Jun 2025 13:43:55 -0400 Subject: [PATCH 176/225] Make the template option optional when initializing with a template --- Sources/Commands/PackageCommands/Init.swift | 29 ++++++++++--------- .../PackageCommands/PluginCommand.swift | 9 ++++-- Sources/Workspace/InitTemplatePackage.swift | 9 ++---- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 62c1d24c852..b83c3c0ea88 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -70,11 +70,11 @@ extension SwiftPackageCommand { var createPackagePath = true /// Name of a template to use for package initialization. - @Option(name: .customLong("template"), help: "Name of a template to initialize the package.") - var template: String = "" + @Option(name: .customLong("template"), help: "Name of a template to initialize the package, unspecified if the default template should be used.") + var template: String? /// Returns true if a template is specified. - var useTemplates: Bool { !self.template.isEmpty } + var useTemplates: Bool { self.templateSource != nil } /// The type of template to use: `registry`, `git`, or `local`. @Option(name: .customLong("template-type"), help: "Template type: registry, git, local.") @@ -230,7 +230,6 @@ extension SwiftPackageCommand { let initTemplatePackage = try InitTemplatePackage( name: packageName, - templateName: template, initMode: dependencyKind, templatePath: resolvedTemplatePath, fileSystem: swiftCommandState.fileSystem, @@ -252,8 +251,14 @@ extension SwiftPackageCommand { let packageGraph = try await swiftCommandState.loadPackageGraph() let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: packageGraph, limitedTo: nil) + guard let commandPlugin = matchingPlugins.first else { + guard let template = self.template else { throw ValidationError("No templates were found in \(packageName)") } + + throw ValidationError("No templates were found that match the name \(template)") + } + let output = try await TemplatePluginRunner.run( - plugin: matchingPlugins[0], + plugin: commandPlugin, package: packageGraph.rootPackages.first!, packageGraph: packageGraph, arguments: ["--", "--experimental-dump-help"], @@ -289,18 +294,16 @@ extension SwiftPackageCommand { let products = rootManifest.products let targets = rootManifest.targets - for product in products { - for targetName in product.targets { - if let target = targets.first(where: { $0.name == template }) { - if let options = target.templateInitializationOptions { - if case .packageInit(let templateType, _, _) = options { - return try .init(from: templateType) - } + for _ in products { + if let target = targets.first(where: { template == nil || $0.name == template }) { + if let options = target.templateInitializationOptions { + if case .packageInit(let templateType, _, _) = options { + return try .init(from: templateType) } } } } - throw InternalError("Could not find template \(self.template)") + throw InternalError("Could not find \(self.template != nil ? "template \(self.template!)" : "any templates in the package")") } } } diff --git a/Sources/Commands/PackageCommands/PluginCommand.swift b/Sources/Commands/PackageCommands/PluginCommand.swift index c019de468e0..380e133052c 100644 --- a/Sources/Commands/PackageCommands/PluginCommand.swift +++ b/Sources/Commands/PackageCommands/PluginCommand.swift @@ -424,14 +424,19 @@ struct PluginCommand: AsyncSwiftCommand { } } - static func findPlugins(matching verb: String, in graph: ModulesGraph, limitedTo packageIdentity: String?) -> [ResolvedModule] { + static func findPlugins(matching verb: String?, in graph: ModulesGraph, limitedTo packageIdentity: String?) -> [ResolvedModule] { // Find and return the command plugins that match the command. - Self.availableCommandPlugins(in: graph, limitedTo: packageIdentity).filter { + let plugins = Self.availableCommandPlugins(in: graph, limitedTo: packageIdentity).filter { let plugin = $0.underlying as! PluginModule // Filter out any non-command plugins and any whose verb is different. guard case .command(let intent, _) = plugin.capability else { return false } + + guard let verb else { return true } + return verb == intent.invocationVerb } + + return plugins } } diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 683a088cfec..45262798740 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -36,8 +36,6 @@ public final class InitTemplatePackage { /// The set of testing libraries supported by the generated package. public var supportedTestingLibraries: Set - /// The name of the template to use. - let templateName: String /// The file system abstraction to use for file operations. let fileSystem: FileSystem @@ -51,13 +49,12 @@ public final class InitTemplatePackage { var packageName: String /// The path to the template files. - var templatePath: Basics.AbsolutePath - /// The type of package to create (e.g., library, executable). + /// The type of package to create (e.g., library, executable). let packageType: InitPackage.PackageType - /// Options used to configure package initialization. + /// Options used to configure package initialization. public struct InitPackageOptions { /// The type of package to create. public var packageType: InitPackage.PackageType @@ -112,7 +109,6 @@ public final class InitTemplatePackage { /// - installedSwiftPMConfiguration: Configuration from the SwiftPM toolchain. public init( name: String, - templateName: String, initMode: MappablePackageDependency.Kind, templatePath: Basics.AbsolutePath, fileSystem: FileSystem, @@ -129,7 +125,6 @@ public final class InitTemplatePackage { self.destinationPath = destinationPath self.installedSwiftPMConfiguration = installedSwiftPMConfiguration self.fileSystem = fileSystem - self.templateName = templateName } /// Sets up the package manifest by creating the package structure and From 56057e5e9234bd2349aaef9710b7ce9f0a1a1424 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Fri, 13 Jun 2025 14:09:19 -0400 Subject: [PATCH 177/225] On failure due to multiple available templates provide the list as part of the error message --- Sources/Commands/PackageCommands/Init.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index b83c3c0ea88..5b952c80b88 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -257,6 +257,16 @@ extension SwiftPackageCommand { throw ValidationError("No templates were found that match the name \(template)") } + guard matchingPlugins.count == 1 else { + let templateNames = matchingPlugins.compactMap { module in + let plugin = module.underlying as! PluginModule + guard case .command(let intent, _) = plugin.capability else { return Optional.none } + + return intent.invocationVerb + } + throw ValidationError("More than one template was found in the package. Please use `--template` to select from one of the available templates: \(templateNames.joined(separator: ", "))") + } + let output = try await TemplatePluginRunner.run( plugin: commandPlugin, package: packageGraph.rootPackages.first!, From 37c2785377955e974aa92c9ff05199e2a8c7c1a7 Mon Sep 17 00:00:00 2001 From: Chris McGee Date: Fri, 13 Jun 2025 10:36:58 -0400 Subject: [PATCH 178/225] Add instructions to the SwiftPM readme for trying out templates --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index 1014c6f97c8..d42f138d7d5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,35 @@ # Swift Package Manager Project +## Swift Package Manager Templates + +This branch has an experimental SwiftPM template feature that you can use to experiment. Here's how you can try it out. + +First, you need to build this package and produce SwiftPM binaries with the template support: + +``` +swift build +``` + +Now you can go to an empty directory and use an example template to make a package like this: + +``` +/.build/debug/swift-package init --template PartsService --template-type git --template-url git@github.pie.apple.com:jbute/simple-template-example.git +``` + +There's also a template maker that will help you to write your own template. Here's how you can generate your own template: + +``` +/.build/debug/swift-package init --template TemplateMaker --template-type git --template-url git@github.pie.apple.com:christie-mcgee/template.git +``` + +Once you've customized your template then you can test it from an empty directory: + +``` +/.build/debug/swift-package init --template MyTemplate --template-type local --template-path +``` + +## About SwiftPM + The Swift Package Manager is a tool for managing distribution of source code, aimed at making it easy to share your code and reuse others’ code. The tool directly addresses the challenges of compiling and linking Swift packages, managing dependencies, versioning, and supporting flexible distribution and collaboration models. We’ve designed the system to make it easy to share packages on services like GitHub, but packages are also great for private personal development, sharing code within a team, or at any other granularity. From 5ac1b34185257b2d28959f79ef9c290a42df02e3 Mon Sep 17 00:00:00 2001 From: John Bute Date: Mon, 16 Jun 2025 09:24:04 -0400 Subject: [PATCH 179/225] ergonomics, reducing repetition of template keyword --- Sources/Commands/PackageCommands/Init.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 5b952c80b88..fb8c33fc082 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -81,11 +81,11 @@ extension SwiftPackageCommand { var templateSource: InitTemplatePackage.TemplateSource? /// Path to a local template. - @Option(name: .customLong("template-path"), help: "Path to the local template.", completion: .directory) + @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) var templateDirectory: Basics.AbsolutePath? /// Git URL of the template. - @Option(name: .customLong("template-url"), help: "The git URL of the template.") + @Option(name: .customLong("url"), help: "The git URL of the template.") var templateURL: String? @Option(name: .customLong("package-id"), help: "The package identifier of the template") From 8e83a4a366a6c9f2a9de01868fbb746160d6795d Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 17 Jun 2025 11:26:03 -0400 Subject: [PATCH 180/225] added documentation + predetermined args --- Sources/Commands/PackageCommands/Init.swift | 30 +++- .../PackageCommands/ShowTemplates.swift | 2 +- Sources/Workspace/InitTemplatePackage.swift | 160 +++++++++++++++++- 3 files changed, 180 insertions(+), 12 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index fb8c33fc082..9e8c7d524d5 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -70,7 +70,10 @@ extension SwiftPackageCommand { var createPackagePath = true /// Name of a template to use for package initialization. - @Option(name: .customLong("template"), help: "Name of a template to initialize the package, unspecified if the default template should be used.") + @Option( + name: .customLong("template"), + help: "Name of a template to initialize the package, unspecified if the default template should be used." + ) var template: String? /// Returns true if a template is specified. @@ -88,9 +91,18 @@ extension SwiftPackageCommand { @Option(name: .customLong("url"), help: "The git URL of the template.") var templateURL: String? + /// Package Registry ID of the template. @Option(name: .customLong("package-id"), help: "The package identifier of the template") var templatePackageID: String? + /// Predetermined arguments specified by the consumer. + @Option( + name: [.customLong("args")], + parsing: .unconditional, + help: "Predetermined arguments to pass to the template." + ) + var args: String? + // MARK: - Versioning Options for Remote Git Templates and Registry templates /// The exact version of the remote package to use. @@ -252,7 +264,8 @@ extension SwiftPackageCommand { let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: packageGraph, limitedTo: nil) guard let commandPlugin = matchingPlugins.first else { - guard let template = self.template else { throw ValidationError("No templates were found in \(packageName)") } + guard let template = self.template + else { throw ValidationError("No templates were found in \(packageName)") } throw ValidationError("No templates were found that match the name \(template)") } @@ -260,11 +273,13 @@ extension SwiftPackageCommand { guard matchingPlugins.count == 1 else { let templateNames = matchingPlugins.compactMap { module in let plugin = module.underlying as! PluginModule - guard case .command(let intent, _) = plugin.capability else { return Optional.none } + guard case .command(let intent, _) = plugin.capability else { return String?.none } return intent.invocationVerb } - throw ValidationError("More than one template was found in the package. Please use `--template` to select from one of the available templates: \(templateNames.joined(separator: ", "))") + throw ValidationError( + "More than one template was found in the package. Please use `--template` to select from one of the available templates: \(templateNames.joined(separator: ", "))" + ) } let output = try await TemplatePluginRunner.run( @@ -276,7 +291,8 @@ extension SwiftPackageCommand { ) let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let response = try initTemplatePackage.promptUser(tool: toolInfo) + let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: self.args) + do { let _ = try await TemplatePluginRunner.run( plugin: matchingPlugins[0], @@ -313,7 +329,9 @@ extension SwiftPackageCommand { } } } - throw InternalError("Could not find \(self.template != nil ? "template \(self.template!)" : "any templates in the package")") + throw InternalError( + "Could not find \(self.template != nil ? "template \(self.template!)" : "any templates in the package")" + ) } } } diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 9a25f254470..eabb637b28e 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -34,7 +34,7 @@ struct ShowTemplates: AsyncSwiftCommand { /// The Git URL of the template to list executables from. /// /// If not provided, the command uses the current working directory. - @Option(name: .customLong("template-url"), help: "The git URL of the template.") + @Option(name: .customLong("url"), help: "The git URL of the template.") var templateURL: String? @Option(name: .customLong("package-id"), help: "The package identifier of the template") diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 45262798740..4cabe6e52ea 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -201,14 +201,116 @@ public final class InitTemplatePackage { /// - Returns: An array of strings representing the command line arguments built from user input. /// - Throws: `TemplateError.noArguments` if the tool command has no arguments. - public func promptUser(tool: ToolInfoV0) throws -> [String] { - let arguments = try convertArguments(from: tool.command) + public func promptUser(tool: ToolInfoV0, arguments: String?) throws -> [String] { + let allArgs = try convertArguments(from: tool.command) - let responses = UserPrompter.prompt(for: arguments) + let providedResponses = try arguments.flatMap { + try self.parseAndMatchArguments($0, definedArgs: allArgs) + } ?? [] - let commandLine = self.buildCommandLine(from: responses) + let missingArgs = self.findMissingArguments(from: allArgs, excluding: providedResponses) - return commandLine + let promptedResponses = UserPrompter.prompt(for: missingArgs) + + return self.buildCommandLine(from: providedResponses + promptedResponses) + } + + /// Parses predetermined arguments and validates the arguments + /// + /// This method converts user's predetermined arguments into the ArgumentResponse struct + /// and validates the user's predetermined arguments against the template's available arguments. + /// + /// - Parameter input: The input arguments from the consumer. + /// - parameter definedArgs: the arguments defined by the template + /// - Returns: An array of responses to the tool's arguments + /// - Throws: Invalid values if the value is not within all the possible values allowed by the argument + /// - Throws: Throws an unexpected argument if the user specifies an argument that does not match any arguments + /// defined by the template. + private func parseAndMatchArguments( + _ input: String, + definedArgs: [ArgumentInfoV0] + ) throws -> [ArgumentResponse] { + let parsedTokens = self.parseArgs(input) + var responses: [ArgumentResponse] = [] + var providedMap: [String: [String]] = [:] + var index = 0 + + while index < parsedTokens.count { + let token = parsedTokens[index] + + if token.starts(with: "--") { + let name = String(token.dropFirst(2)) + guard let arg = definedArgs.first(where: { $0.valueName == name }) else { + throw TemplateError.invalidArgument(name: name) + } + + switch arg.kind { + case .flag: + providedMap[name] = ["true"] + case .option: + index += 1 + guard index < parsedTokens.count else { + throw TemplateError.missingValueForOption(name: name) + } + providedMap[name] = [parsedTokens[index]] + default: + throw TemplateError.unexpectedNamedArgument(name: name) + } + } else { + // Positional handling + providedMap["__positional", default: []].append(token) + } + + index += 1 + } + + for arg in definedArgs { + let name = arg.valueName ?? "__positional" + guard let values = providedMap[name] else { + continue + } + + if let allowed = arg.allValues { + let invalid = values.filter { !allowed.contains($0) } + if !invalid.isEmpty { + throw TemplateError.invalidValue( + argument: name, + invalidValues: invalid, + allowed: allowed + ) + } + } + + responses.append(ArgumentResponse(argument: arg, values: values)) + providedMap[name] = nil + } + + for unexpected in providedMap.keys { + throw TemplateError.unexpectedArgument(name: unexpected) + } + + return responses + } + + /// Determines the rest of the arguments that need a user's response + /// + /// This method determines the rest of the responses needed from the user to complete the generation of a template + /// + /// + /// - Parameter all: All the arguments from the template. + /// - parameter excluding: The arguments that do not need prompting + /// - Returns: An array of arguments that need to be prompted for user response + + private func findMissingArguments( + from all: [ArgumentInfoV0], + excluding responses: [ArgumentResponse] + ) -> [ArgumentInfoV0] { + let seen = Set(responses.map { $0.argument.valueName ?? "__positional" }) + + return all.filter { arg in + let name = arg.valueName ?? "__positional" + return !seen.contains(name) + } } /// Converts the command information into an array of argument metadata. @@ -346,6 +448,38 @@ public final class InitTemplatePackage { } } } + + private func parseArgs(_ input: String) -> [String] { + var result: [String] = [] + + var current = "" + var inQuotes = false + var escapeNext = false + + for char in input { + if escapeNext { + current.append(char) + escapeNext = false + } else if char == "\\" { + escapeNext = true + } else if char == "\"" { + inQuotes.toggle() + } else if char == " " && !inQuotes { + if !current.isEmpty { + result.append(current) + current = "" + } + } else { + current.append(char) + } + } + + if !current.isEmpty { + result.append(current) + } + + return result + } } /// An error enum representing various template-related errors. @@ -357,7 +491,13 @@ private enum TemplateError: Swift.Error { case manifestAlreadyExists /// The template has no arguments to prompt for. + case noArguments + case invalidArgument(name: String) + case unexpectedArgument(name: String) + case unexpectedNamedArgument(name: String) + case missingValueForOption(name: String) + case invalidValue(argument: String, invalidValues: [String], allowed: [String]) } extension TemplateError: CustomStringConvertible { @@ -370,6 +510,16 @@ extension TemplateError: CustomStringConvertible { "Path does not exist, or is invalid." case .noArguments: "Template has no arguments" + case .invalidArgument(name: let name): + "Invalid argument name: \(name)" + case .unexpectedArgument(name: let name): + "Unexpected argument: \(name)" + case .unexpectedNamedArgument(name: let name): + "Unexpected named argument: \(name)" + case .missingValueForOption(name: let name): + "Missing value for option: \(name)" + case .invalidValue(argument: let argument, invalidValues: let invalidValues, allowed: let allowed): + "Invalid value \(argument). Valid values are: \(allowed.joined(separator: ", ")). \(invalidValues.isEmpty ? "" : "Also, \(invalidValues.joined(separator: ", ")) are not valid.")" } } } From 5f0a02bd3f3deb954347c243523cf209006b49ba Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 17 Jun 2025 15:00:04 -0400 Subject: [PATCH 181/225] changed args option to instead take any arguments after as predetermined args for templates --- Sources/Commands/PackageCommands/Init.swift | 17 ++++--- Sources/Workspace/InitTemplatePackage.swift | 49 +++------------------ 2 files changed, 15 insertions(+), 51 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 9e8c7d524d5..59f2990e0d3 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -95,14 +95,6 @@ extension SwiftPackageCommand { @Option(name: .customLong("package-id"), help: "The package identifier of the template") var templatePackageID: String? - /// Predetermined arguments specified by the consumer. - @Option( - name: [.customLong("args")], - parsing: .unconditional, - help: "Predetermined arguments to pass to the template." - ) - var args: String? - // MARK: - Versioning Options for Remote Git Templates and Registry templates /// The exact version of the remote package to use. @@ -129,6 +121,13 @@ extension SwiftPackageCommand { @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? + /// Predetermined arguments specified by the consumer. + @Argument( + parsing: .captureForPassthrough, + help: "Predetermined arguments to pass to the template." + ) + var args: [String] = [] + func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") @@ -291,7 +290,7 @@ extension SwiftPackageCommand { ) let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: self.args) + let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: args) do { let _ = try await TemplatePluginRunner.run( diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 4cabe6e52ea..ca3513e60b5 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -201,12 +201,10 @@ public final class InitTemplatePackage { /// - Returns: An array of strings representing the command line arguments built from user input. /// - Throws: `TemplateError.noArguments` if the tool command has no arguments. - public func promptUser(tool: ToolInfoV0, arguments: String?) throws -> [String] { + public func promptUser(tool: ToolInfoV0, arguments: [String]) throws -> [String] { let allArgs = try convertArguments(from: tool.command) - let providedResponses = try arguments.flatMap { - try self.parseAndMatchArguments($0, definedArgs: allArgs) - } ?? [] + let providedResponses = try self.parseAndMatchArguments(arguments, definedArgs: allArgs) let missingArgs = self.findMissingArguments(from: allArgs, excluding: providedResponses) @@ -227,16 +225,15 @@ public final class InitTemplatePackage { /// - Throws: Throws an unexpected argument if the user specifies an argument that does not match any arguments /// defined by the template. private func parseAndMatchArguments( - _ input: String, + _ input: [String], definedArgs: [ArgumentInfoV0] ) throws -> [ArgumentResponse] { - let parsedTokens = self.parseArgs(input) var responses: [ArgumentResponse] = [] var providedMap: [String: [String]] = [:] var index = 0 - while index < parsedTokens.count { - let token = parsedTokens[index] + while index < input.count { + let token = input[index] if token.starts(with: "--") { let name = String(token.dropFirst(2)) @@ -249,10 +246,10 @@ public final class InitTemplatePackage { providedMap[name] = ["true"] case .option: index += 1 - guard index < parsedTokens.count else { + guard index < input.count else { throw TemplateError.missingValueForOption(name: name) } - providedMap[name] = [parsedTokens[index]] + providedMap[name] = [input[index]] default: throw TemplateError.unexpectedNamedArgument(name: name) } @@ -448,38 +445,6 @@ public final class InitTemplatePackage { } } } - - private func parseArgs(_ input: String) -> [String] { - var result: [String] = [] - - var current = "" - var inQuotes = false - var escapeNext = false - - for char in input { - if escapeNext { - current.append(char) - escapeNext = false - } else if char == "\\" { - escapeNext = true - } else if char == "\"" { - inQuotes.toggle() - } else if char == " " && !inQuotes { - if !current.isEmpty { - result.append(current) - current = "" - } - } else { - current.append(char) - } - } - - if !current.isEmpty { - result.append(current) - } - - return result - } } /// An error enum representing various template-related errors. From 4e818cfb3c47dfef4db7e97dea2db71947703a78 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 17 Jun 2025 15:09:11 -0400 Subject: [PATCH 182/225] inferring source of template --- Sources/Commands/PackageCommands/Init.swift | 17 ++++++++++++----- Sources/Workspace/InitTemplatePackage.swift | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 59f2990e0d3..8d41a2e1104 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -77,11 +77,20 @@ extension SwiftPackageCommand { var template: String? /// Returns true if a template is specified. - var useTemplates: Bool { self.templateSource != nil } + var useTemplates: Bool { self.templateURL != nil || self.templatePackageID != nil || self.templateDirectory != nil } /// The type of template to use: `registry`, `git`, or `local`. - @Option(name: .customLong("template-type"), help: "Template type: registry, git, local.") - var templateSource: InitTemplatePackage.TemplateSource? + var templateSource: InitTemplatePackage.TemplateSource? { + if templateDirectory != nil { + .local + } else if templateURL != nil { + .git + } else if templatePackageID != nil { + .registry + } else { + nil + } + } /// Path to a local template. @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) @@ -355,5 +364,3 @@ extension InitPackage.PackageType: ExpressibleByArgument { } } } - -extension InitTemplatePackage.TemplateSource: ExpressibleByArgument {} diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index ca3513e60b5..a44e0d68d61 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -85,7 +85,7 @@ public final class InitTemplatePackage { } /// The type of template source. - public enum TemplateSource: String, CustomStringConvertible { + public enum TemplateSource: String, CustomStringConvertible, Decodable { case local case git case registry From 78c25c4a502f654cfd6ea4ed9039c47eba91b7b8 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 18 Jun 2025 14:54:24 -0400 Subject: [PATCH 183/225] fixing error regarding passing args to template --- Sources/Commands/PackageCommands/Init.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 8d41a2e1104..3bafed4dd22 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -132,7 +132,6 @@ extension SwiftPackageCommand { /// Predetermined arguments specified by the consumer. @Argument( - parsing: .captureForPassthrough, help: "Predetermined arguments to pass to the template." ) var args: [String] = [] From 9f00ac50858514ccb0f226a50969ea40e284683a Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 18 Jun 2025 16:01:54 -0400 Subject: [PATCH 184/225] args checking + file existing checks if local template option picked --- Sources/Commands/PackageCommands/Init.swift | 47 +++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 3bafed4dd22..fa02b5da05a 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -141,6 +141,10 @@ extension SwiftPackageCommand { throw InternalError("Could not find the current working directory") } + if case let .failure(errors) = validateArgs(swiftCommandState: swiftCommandState) { + throw ValidationError(errors.joined(separator: "\n")) + } + let packageName = self.packageName ?? cwd.basename if self.useTemplates { @@ -213,6 +217,10 @@ extension SwiftPackageCommand { swiftCommandState: swiftCommandState ).resolve() + if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { + throw ValidationError("The specified template path does not exist: \(dir.pathString)") + } + let templateInitType = try await swiftCommandState .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in try await self.checkConditions(swiftCommandState) @@ -363,3 +371,42 @@ extension InitPackage.PackageType: ExpressibleByArgument { } } } + +extension SwiftPackageCommand.Init { + enum ValidationResult { + case success + case failure([String]) + } + + func validateArgs(swiftCommandState: SwiftCommandState) -> ValidationResult { + var errors: [String] = [] + + // 1. Validate consistency of template-related arguments + let isUsingTemplate = self.useTemplates + + if isUsingTemplate { + + let templateSources: [Any?] = [templateDirectory, templateURL, templatePackageID] + let nonNilCount = templateSources.compactMap { $0 }.count + + if nonNilCount > 1{ + errors.append("Only one of --path, --url, or --package-id may be specified.") + } + + if (self.exact != nil || self.from != nil || self.upToNextMinorFrom != nil || self.branch != nil || self.revision != nil || self.to != nil) && self.templateSource == .local { + errors.append("Cannot specify a version requirement alongside a local template") + } + + } else { + // 2. In non-template mode, template-related flags should not be used + if template != nil { + errors.append("The --template option can only be used with a specified template source (--path, --url, or --package-id).") + } + + if !args.isEmpty { + errors.append("Template arguments are only supported when initializing from a template.") + } + } + return errors.isEmpty ? .success : .failure(errors) + } +} From b7ce8a34df51f13c4dfa4a0e542251d2671a4ae4 Mon Sep 17 00:00:00 2001 From: John Bute Date: Fri, 20 Jun 2025 12:18:06 -0400 Subject: [PATCH 185/225] made description, permissions, and initial type top level --- Sources/Commands/PackageCommands/Init.swift | 2 +- Sources/PackageDescription/Target.swift | 114 ++++++++++---------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index fa02b5da05a..21832d3f7f2 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -336,7 +336,7 @@ extension SwiftPackageCommand { let targets = rootManifest.targets for _ in products { - if let target = targets.first(where: { template == nil || $0.name == template }) { + if let target: TargetDescription = targets.first(where: { template == nil || $0.name == template }) { if let options = target.templateInitializationOptions { if case .packageInit(let templateType, _, _) = options { return try .init(from: templateType) diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index 75b260577ec..a306035ea62 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -1272,7 +1272,7 @@ public final class Target { public extension [Target] { @available(_PackageDescription, introduced: 999.0.0) - public static func template( + static func template( name: String, dependencies: [Target.Dependency] = [], path: String? = nil, @@ -1286,74 +1286,74 @@ public extension [Target] { swiftSettings: [SwiftSetting]? = nil, linkerSettings: [LinkerSetting]? = nil, plugins: [Target.PluginUsage]? = nil, - templateInitializationOptions: Target.TemplateInitializationOptions, + initialType: Target.TemplateType, + templatePermissions: [TemplatePermissions]? = nil, + description: String ) -> [Target] { let templatePluginName = "\(name)Plugin" let templateExecutableName = "\(name)" - let (verb, description): (String, String) - switch templateInitializationOptions { - case .packageInit(_, _, let desc): - verb = templateExecutableName - description = desc - } let permissions: [PluginPermission] = { - switch templateInitializationOptions { - case .packageInit(_, let templatePermissions, _): - return templatePermissions?.compactMap { permission in - switch permission { - case .allowNetworkConnections(let scope, let reason): - // Map from TemplateNetworkPermissionScope to PluginNetworkPermissionScope - let pluginScope: PluginNetworkPermissionScope - switch scope { - case .none: - pluginScope = .none - case .local(let ports): - pluginScope = .local(ports: ports) - case .all(let ports): - pluginScope = .all(ports: ports) - case .docker: - pluginScope = .docker - case .unixDomainSocket: - pluginScope = .unixDomainSocket - } - return .allowNetworkConnections(scope: pluginScope, reason: reason) + return templatePermissions?.compactMap { permission in + switch permission { + case .allowNetworkConnections(let scope, let reason): + // Map from TemplateNetworkPermissionScope to PluginNetworkPermissionScope + let pluginScope: PluginNetworkPermissionScope + switch scope { + case .none: + pluginScope = .none + case .local(let ports): + pluginScope = .local(ports: ports) + case .all(let ports): + pluginScope = .all(ports: ports) + case .docker: + pluginScope = .docker + case .unixDomainSocket: + pluginScope = .unixDomainSocket } - } ?? [] - } + return .allowNetworkConnections(scope: pluginScope, reason: reason) + } + } ?? [] }() - let templateTarget = Target( - name: templateExecutableName, - dependencies: dependencies, - path: path, - exclude: exclude, - sources: sources, - resources: resources, - publicHeadersPath: publicHeadersPath, - type: .executable, - packageAccess: packageAccess, - cSettings: cSettings, - cxxSettings: cxxSettings, - swiftSettings: swiftSettings, - linkerSettings: linkerSettings, - plugins: plugins, - templateInitializationOptions: templateInitializationOptions - ) - // Plugin target that depends on the template - let pluginTarget = Target.plugin( - name: templatePluginName, - capability: .command( - intent: .custom(verb: verb, description: description), - permissions: permissions - ), - dependencies: [Target.Dependency.target(name: templateExecutableName, condition: nil)] - ) + let templateInitializationOptions = Target.TemplateInitializationOptions.packageInit( + templateType: initialType, + templatePermissions: templatePermissions, + description: description + ) + + let templateTarget = Target( + name: templateExecutableName, + dependencies: dependencies, + path: path, + exclude: exclude, + sources: sources, + resources: resources, + publicHeadersPath: publicHeadersPath, + type: .executable, + packageAccess: packageAccess, + cSettings: cSettings, + cxxSettings: cxxSettings, + swiftSettings: swiftSettings, + linkerSettings: linkerSettings, + plugins: plugins, + templateInitializationOptions: templateInitializationOptions + ) + + // Plugin target that depends on the template + let pluginTarget = Target.plugin( + name: templatePluginName, + capability: .command( + intent: .custom(verb: templateExecutableName, description: description), + permissions: permissions + ), + dependencies: [Target.Dependency.target(name: templateExecutableName, condition: nil)] + ) - return [templateTarget, pluginTarget] + return [templateTarget, pluginTarget] } } From 443ccb3f741c1ad350297c8d9ed5635b9e16322b Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 24 Jun 2025 11:08:08 -0400 Subject: [PATCH 186/225] made scaffolding top level command --- Package.swift | 6 + Sources/Commands/PackageCommands/Init.swift | 370 ++---------------- Sources/Commands/SwiftScaffold.swift | 313 +++++++++++++++ .../TestingLibrarySupport.swift | 53 --- Sources/swift-scaffold/CMakeLists.txt | 18 + Sources/swift-scaffold/Entrypoint.swift | 20 + 6 files changed, 382 insertions(+), 398 deletions(-) create mode 100644 Sources/Commands/SwiftScaffold.swift delete mode 100644 Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift create mode 100644 Sources/swift-scaffold/CMakeLists.txt create mode 100644 Sources/swift-scaffold/Entrypoint.swift diff --git a/Package.swift b/Package.swift index bb9328c002b..e9b1cbab786 100644 --- a/Package.swift +++ b/Package.swift @@ -743,6 +743,12 @@ let package = Package( dependencies: ["Commands"], exclude: ["CMakeLists.txt"] ), + .executableTarget( + /** Scaffolds a package */ + name: "swift-scaffold", + dependencies: ["Commands"], + exclude: ["CMakeLists.txt"] + ), .executableTarget( /** Interacts with package collections */ name: "swift-package-collection", diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 21832d3f7f2..d93600002b1 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -17,154 +17,61 @@ import Basics import CoreCommands import PackageModel -import SPMBuildCore -import TSCUtility import Workspace - -import Foundation -import PackageGraph -import SourceControl import SPMBuildCore -import TSCBasic -import XCBuildSupport - -import ArgumentParserToolInfo extension SwiftPackageCommand { - struct Init: AsyncSwiftCommand { + struct Init: SwiftCommand { public static let configuration = CommandConfiguration( - abstract: "Initialize a new package.", - ) + abstract: "Initialize a new package.") @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions - @OptionGroup(visibility: .hidden) - var buildOptions: BuildCommandOptions - @Option( name: .customLong("type"), help: ArgumentHelp("Package type:", discussion: """ - library - A package with a library. - executable - A package with an executable. - tool - A package with an executable that uses - Swift Argument Parser. Use this template if you - plan to have a rich set of command-line arguments. - build-tool-plugin - A package that vends a build tool plugin. - command-plugin - A package that vends a command plugin. - macro - A package that vends a macro. - empty - An empty package with a Package.swift manifest. - """) - ) + library - A package with a library. + executable - A package with an executable. + tool - A package with an executable that uses + Swift Argument Parser. Use this template if you + plan to have a rich set of command-line arguments. + build-tool-plugin - A package that vends a build tool plugin. + command-plugin - A package that vends a command plugin. + macro - A package that vends a macro. + empty - An empty package with a Package.swift manifest. + """)) var initMode: InitPackage.PackageType = .library /// Which testing libraries to use (and any related options.) @OptionGroup() var testLibraryOptions: TestLibraryOptions - /// A custom name for the package. Defaults to the current directory name. @Option(name: .customLong("name"), help: "Provide custom package name.") var packageName: String? // This command should support creating the supplied --package-path if it isn't created. var createPackagePath = true - /// Name of a template to use for package initialization. - @Option( - name: .customLong("template"), - help: "Name of a template to initialize the package, unspecified if the default template should be used." - ) - var template: String? - - /// Returns true if a template is specified. - var useTemplates: Bool { self.templateURL != nil || self.templatePackageID != nil || self.templateDirectory != nil } - - /// The type of template to use: `registry`, `git`, or `local`. - var templateSource: InitTemplatePackage.TemplateSource? { - if templateDirectory != nil { - .local - } else if templateURL != nil { - .git - } else if templatePackageID != nil { - .registry - } else { - nil - } - } - - /// Path to a local template. - @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) - var templateDirectory: Basics.AbsolutePath? - - /// Git URL of the template. - @Option(name: .customLong("url"), help: "The git URL of the template.") - var templateURL: String? - - /// Package Registry ID of the template. - @Option(name: .customLong("package-id"), help: "The package identifier of the template") - var templatePackageID: String? - - // MARK: - Versioning Options for Remote Git Templates and Registry templates - - /// The exact version of the remote package to use. - @Option(help: "The exact package version to depend on.") - var exact: Version? - - /// Specific revision to use (for Git templates). - @Option(help: "The specific package revision to depend on.") - var revision: String? - - /// Branch name to use (for Git templates). - @Option(help: "The branch of the package to depend on.") - var branch: String? - - /// Version to depend on, up to the next major version. - @Option(help: "The package version to depend on (up to the next major version).") - var from: Version? - - /// Version to depend on, up to the next minor version. - @Option(help: "The package version to depend on (up to the next minor version).") - var upToNextMinorFrom: Version? - - /// Upper bound on the version range (exclusive). - @Option(help: "Specify upper bound on the package version range (exclusive).") - var to: Version? - - /// Predetermined arguments specified by the consumer. - @Argument( - help: "Predetermined arguments to pass to the template." - ) - var args: [String] = [] - - func run(_ swiftCommandState: SwiftCommandState) async throws { + func run(_ swiftCommandState: SwiftCommandState) throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") } - if case let .failure(errors) = validateArgs(swiftCommandState: swiftCommandState) { - throw ValidationError(errors.joined(separator: "\n")) - } - let packageName = self.packageName ?? cwd.basename - if self.useTemplates { - try await self.runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) - } else { - try self.runPackageInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) + // Testing is on by default, with XCTest only enabled explicitly. + // For macros this is reversed, since we don't support testing + // macros with Swift Testing yet. + var supportedTestingLibraries = Set() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.swiftTesting) } - } - - /// Runs the standard package initialization (non-template). - private func runPackageInit( - swiftCommandState: SwiftCommandState, - packageName: String, - cwd: Basics.AbsolutePath - ) throws { - let supportedTestingLibraries = computeSupportedTestingLibraries( - for: testLibraryOptions, - initMode: initMode, - swiftCommandState: swiftCommandState - ) let initPackage = try InitPackage( name: packageName, @@ -174,239 +81,12 @@ extension SwiftPackageCommand { installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, fileSystem: swiftCommandState.fileSystem ) - initPackage.progressReporter = { message in print(message) } - try initPackage.writePackageStructure() } - - /// Runs the package initialization using an author-defined template. - private func runTemplateInit( - swiftCommandState: SwiftCommandState, - packageName: String, - cwd: Basics.AbsolutePath - ) async throws { - guard let source = templateSource else { - throw ValidationError("No template source specified.") - } - - let requirementResolver = DependencyRequirementResolver( - exact: exact, - revision: revision, - branch: branch, - from: from, - upToNextMinorFrom: upToNextMinorFrom, - to: to - ) - - let registryRequirement: PackageDependency.Registry.Requirement? = - try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement - - let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = - try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement - - let resolvedTemplatePath = try await TemplatePathResolver( - source: templateSource, - templateDirectory: templateDirectory, - templateURL: templateURL, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - packageIdentity: templatePackageID, - swiftCommandState: swiftCommandState - ).resolve() - - if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { - throw ValidationError("The specified template path does not exist: \(dir.pathString)") - } - - let templateInitType = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in - try await self.checkConditions(swiftCommandState) - } - - // Clean up downloaded package after execution. - defer { - if templateSource == .git { - try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) - } else if templateSource == .registry { - let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL - try? FileManager.default.removeItem(at: parentDirectoryURL) - } - } - - let supportedTemplateTestingLibraries = computeSupportedTestingLibraries( - for: testLibraryOptions, - initMode: templateInitType, - swiftCommandState: swiftCommandState - ) - - let builder = DefaultPackageDependencyBuilder( - templateSource: source, - packageName: packageName, - templateURL: self.templateURL, - templatePackageID: self.templatePackageID - ) - - let dependencyKind = try builder.makePackageDependency( - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - resolvedTemplatePath: resolvedTemplatePath - ) - - let initTemplatePackage = try InitTemplatePackage( - name: packageName, - initMode: dependencyKind, - templatePath: resolvedTemplatePath, - fileSystem: swiftCommandState.fileSystem, - packageType: templateInitType, - supportedTestingLibraries: supportedTemplateTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration - ) - - try initTemplatePackage.setupTemplateManifest() - - try await TemplateBuildSupport.build( - swiftCommandState: swiftCommandState, - buildOptions: self.buildOptions, - globalOptions: self.globalOptions, - cwd: cwd - ) - - let packageGraph = try await swiftCommandState.loadPackageGraph() - let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: packageGraph, limitedTo: nil) - - guard let commandPlugin = matchingPlugins.first else { - guard let template = self.template - else { throw ValidationError("No templates were found in \(packageName)") } - - throw ValidationError("No templates were found that match the name \(template)") - } - - guard matchingPlugins.count == 1 else { - let templateNames = matchingPlugins.compactMap { module in - let plugin = module.underlying as! PluginModule - guard case .command(let intent, _) = plugin.capability else { return String?.none } - - return intent.invocationVerb - } - throw ValidationError( - "More than one template was found in the package. Please use `--template` to select from one of the available templates: \(templateNames.joined(separator: ", "))" - ) - } - - let output = try await TemplatePluginRunner.run( - plugin: commandPlugin, - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: ["--", "--experimental-dump-help"], - swiftCommandState: swiftCommandState - ) - - let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: args) - - do { - let _ = try await TemplatePluginRunner.run( - plugin: matchingPlugins[0], - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: response, - swiftCommandState: swiftCommandState - ) - } - } - - /// Validates the loaded manifest to determine package type. - private func checkConditions(_ swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType { - let workspace = try swiftCommandState.getActiveWorkspace() - let root = try swiftCommandState.getWorkspaceRoot() - - let rootManifests = try await workspace.loadRootManifests( - packages: root.packages, - observabilityScope: swiftCommandState.observabilityScope - ) - guard let rootManifest = rootManifests.values.first else { - throw InternalError("invalid manifests at \(root.packages)") - } - - let products = rootManifest.products - let targets = rootManifest.targets - - for _ in products { - if let target: TargetDescription = targets.first(where: { template == nil || $0.name == template }) { - if let options = target.templateInitializationOptions { - if case .packageInit(let templateType, _, _) = options { - return try .init(from: templateType) - } - } - } - } - throw InternalError( - "Could not find \(self.template != nil ? "template \(self.template!)" : "any templates in the package")" - ) - } - } -} - -extension InitPackage.PackageType: ExpressibleByArgument { - init(from templateType: TargetDescription.TemplateType) throws { - switch templateType { - case .executable: - self = .executable - case .library: - self = .library - case .tool: - self = .tool - case .macro: - self = .macro - case .buildToolPlugin: - self = .buildToolPlugin - case .commandPlugin: - self = .commandPlugin - case .empty: - self = .empty - } } } -extension SwiftPackageCommand.Init { - enum ValidationResult { - case success - case failure([String]) - } - - func validateArgs(swiftCommandState: SwiftCommandState) -> ValidationResult { - var errors: [String] = [] - - // 1. Validate consistency of template-related arguments - let isUsingTemplate = self.useTemplates - - if isUsingTemplate { - - let templateSources: [Any?] = [templateDirectory, templateURL, templatePackageID] - let nonNilCount = templateSources.compactMap { $0 }.count - - if nonNilCount > 1{ - errors.append("Only one of --path, --url, or --package-id may be specified.") - } - - if (self.exact != nil || self.from != nil || self.upToNextMinorFrom != nil || self.branch != nil || self.revision != nil || self.to != nil) && self.templateSource == .local { - errors.append("Cannot specify a version requirement alongside a local template") - } - - } else { - // 2. In non-template mode, template-related flags should not be used - if template != nil { - errors.append("The --template option can only be used with a specified template source (--path, --url, or --package-id).") - } - - if !args.isEmpty { - errors.append("Template arguments are only supported when initializing from a template.") - } - } - return errors.isEmpty ? .success : .failure(errors) - } -} +extension InitPackage.PackageType: ExpressibleByArgument {} diff --git a/Sources/Commands/SwiftScaffold.swift b/Sources/Commands/SwiftScaffold.swift new file mode 100644 index 00000000000..9e449f481b8 --- /dev/null +++ b/Sources/Commands/SwiftScaffold.swift @@ -0,0 +1,313 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +@_spi(SwiftPMInternal) +import CoreCommands + +import PackageModel +import SPMBuildCore +import TSCUtility +import Workspace + +import Foundation +import PackageGraph +import SourceControl +import SPMBuildCore +import TSCBasic +import XCBuildSupport + +import ArgumentParserToolInfo + +public struct SwiftScaffoldCommand: AsyncSwiftCommand { + public static var configuration = CommandConfiguration( + commandName: "scaffold", + _superCommandName: "swift", + abstract: "Scaffold packages from templates.", + discussion: "SEE ALSO: swift run, swift package, swift test", + version: SwiftVersion.current.completeDisplayString, + helpNames: [.short, .long, .customLong("help", withSingleDash: true)]) + + + @OptionGroup(visibility: .hidden) + public var globalOptions: GlobalOptions + + @OptionGroup(visibility: .hidden) + var buildOptions: BuildCommandOptions + + @Option(name: .customLong("name"), help: "Provide custom package name.") + var packageName: String? + + /// Name of a template to use for package initialization. + @Option( + name: .customLong("template"), + help: "Name of a template to initialize the package, unspecified if the default template should be used." + ) + var template: String? + + /// The type of template to use: `registry`, `git`, or `local`. + var templateSource: InitTemplatePackage.TemplateSource? { + if templateDirectory != nil { + .local + } else if templateURL != nil { + .git + } else if templatePackageID != nil { + .registry + } else { + nil + } + } + + /// Path to a local template. + @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) + var templateDirectory: Basics.AbsolutePath? + + /// Git URL of the template. + @Option(name: .customLong("url"), help: "The git URL of the template.") + var templateURL: String? + + /// Package Registry ID of the template. + @Option(name: .customLong("package-id"), help: "The package identifier of the template") + var templatePackageID: String? + + // MARK: - Versioning Options for Remote Git Templates and Registry templates + + /// The exact version of the remote package to use. + @Option(help: "The exact package version to depend on.") + var exact: Version? + + /// Specific revision to use (for Git templates). + @Option(help: "The specific package revision to depend on.") + var revision: String? + + /// Branch name to use (for Git templates). + @Option(help: "The branch of the package to depend on.") + var branch: String? + + /// Version to depend on, up to the next major version. + @Option(help: "The package version to depend on (up to the next major version).") + var from: Version? + + /// Version to depend on, up to the next minor version. + @Option(help: "The package version to depend on (up to the next minor version).") + var upToNextMinorFrom: Version? + + /// Upper bound on the version range (exclusive). + @Option(help: "Specify upper bound on the package version range (exclusive).") + var to: Version? + + /// Predetermined arguments specified by the consumer. + @Argument( + help: "Predetermined arguments to pass to the template." + ) + var args: [String] = [] + + public func run(_ swiftCommandState: SwiftCommandState) async throws { + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") + } + + let packageName = self.packageName ?? cwd.basename + + try await self.runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) + + } + + /// Runs the package initialization using an author-defined template. + private func runTemplateInit( + swiftCommandState: SwiftCommandState, + packageName: String, + cwd: Basics.AbsolutePath + ) async throws { + guard let source = templateSource else { + throw ValidationError("No template source specified.") + } + + let requirementResolver = DependencyRequirementResolver( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ) + + let registryRequirement: PackageDependency.Registry.Requirement? = + try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement + + let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = + try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + + let resolvedTemplatePath = try await TemplatePathResolver( + source: templateSource, + templateDirectory: templateDirectory, + templateURL: templateURL, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() + + if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { + throw ValidationError("The specified template path does not exist: \(dir.pathString)") + } + + let templateInitType = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in + try await self.checkConditions(swiftCommandState) + } + + // Clean up downloaded package after execution. + defer { + if templateSource == .git { + try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) + } else if templateSource == .registry { + let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL + try? FileManager.default.removeItem(at: parentDirectoryURL) + } + } + + let supportedTemplateTestingLibraries: Set = .init() + + let builder = DefaultPackageDependencyBuilder( + templateSource: source, + packageName: packageName, + templateURL: self.templateURL, + templatePackageID: self.templatePackageID + ) + + let dependencyKind = try builder.makePackageDependency( + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ) + + let initTemplatePackage = try InitTemplatePackage( + name: packageName, + initMode: dependencyKind, + templatePath: resolvedTemplatePath, + fileSystem: swiftCommandState.fileSystem, + packageType: templateInitType, + supportedTestingLibraries: supportedTemplateTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration + ) + + try initTemplatePackage.setupTemplateManifest() + + try await TemplateBuildSupport.build( + swiftCommandState: swiftCommandState, + buildOptions: self.buildOptions, + globalOptions: self.globalOptions, + cwd: cwd + ) + + let packageGraph = try await swiftCommandState.loadPackageGraph() + let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: packageGraph, limitedTo: nil) + + guard let commandPlugin = matchingPlugins.first else { + guard let template = self.template + else { throw ValidationError("No templates were found in \(packageName)") } + + throw ValidationError("No templates were found that match the name \(template)") + } + + guard matchingPlugins.count == 1 else { + let templateNames = matchingPlugins.compactMap { module in + let plugin = module.underlying as! PluginModule + guard case .command(let intent, _) = plugin.capability else { return String?.none } + + return intent.invocationVerb + } + throw ValidationError( + "More than one template was found in the package. Please use `--template` to select from one of the available templates: \(templateNames.joined(separator: ", "))" + ) + } + + let output = try await TemplatePluginRunner.run( + plugin: commandPlugin, + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: ["--", "--experimental-dump-help"], + swiftCommandState: swiftCommandState + ) + + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: args) + + do { + let _ = try await TemplatePluginRunner.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: response, + swiftCommandState: swiftCommandState + ) + } + } + + /// Validates the loaded manifest to determine package type. + private func checkConditions(_ swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType { + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() + + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope + ) + guard let rootManifest = rootManifests.values.first else { + throw InternalError("invalid manifests at \(root.packages)") + } + + let products = rootManifest.products + let targets = rootManifest.targets + + for _ in products { + if let target: TargetDescription = targets.first(where: { template == nil || $0.name == template }) { + if let options = target.templateInitializationOptions { + if case .packageInit(let templateType, _, _) = options { + return try .init(from: templateType) + } + } + } + } + throw InternalError( + "Could not find \(self.template != nil ? "template \(self.template!)" : "any templates in the package")" + ) + } + public init() {} + + } + + +extension InitPackage.PackageType { + init(from templateType: TargetDescription.TemplateType) throws { + switch templateType { + case .executable: + self = .executable + case .library: + self = .library + case .tool: + self = .tool + case .macro: + self = .macro + case .buildToolPlugin: + self = .buildToolPlugin + case .commandPlugin: + self = .commandPlugin + case .empty: + self = .empty + } + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift b/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift deleted file mode 100644 index c802e4ac71b..00000000000 --- a/Sources/Commands/Utilities/_InternalInitSupport/TestingLibrarySupport.swift +++ /dev/null @@ -1,53 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 -@_spi(SwiftPMInternal) -import CoreCommands -import Workspace - -/// Computes the set of supported testing libraries to be included in a package template -/// based on the user's specified testing options, the type of package being initialized, -/// and the Swift command state. -/// -/// This function takes into account whether the testing libraries were explicitly requested -/// (via command-line flags or configuration) or implicitly enabled based on package type. -/// -/// - Parameters: -/// - testLibraryOptions: The testing library preferences specified by the user. -/// - initMode: The type of package being initialized (e.g., executable, library, macro). -/// - swiftCommandState: The command state which includes environment and context information. -/// -/// - Returns: A set of `TestingLibrary` values that should be included in the generated template. -func computeSupportedTestingLibraries( - for testLibraryOptions: TestLibraryOptions, - initMode: InitPackage.PackageType, - swiftCommandState: SwiftCommandState -) -> Set { - var supportedTemplateTestingLibraries: Set = .init() - - // XCTest is enabled either explicitly, or implicitly for macro packages. - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) - { - supportedTemplateTestingLibraries.insert(.xctest) - } - - // Swift Testing is enabled either explicitly, or implicitly for non-macro packages. - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) - { - supportedTemplateTestingLibraries.insert(.swiftTesting) - } - - return supportedTemplateTestingLibraries -} diff --git a/Sources/swift-scaffold/CMakeLists.txt b/Sources/swift-scaffold/CMakeLists.txt new file mode 100644 index 00000000000..94f49824fbd --- /dev/null +++ b/Sources/swift-scaffold/CMakeLists.txt @@ -0,0 +1,18 @@ +# This source file is part of the Swift open source project +# +# Copyright (c) 2014 - 2019 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 Swift project authors + +add_executable(swift-scaffold + Entrypoint.swift) +target_link_libraries(swift-run PRIVATE + Commands) + +target_compile_options(swift-scaffold PRIVATE + -parse-as-library) + +install(TARGETS swift-scaffold + RUNTIME DESTINATION bin) diff --git a/Sources/swift-scaffold/Entrypoint.swift b/Sources/swift-scaffold/Entrypoint.swift new file mode 100644 index 00000000000..0f79e310eab --- /dev/null +++ b/Sources/swift-scaffold/Entrypoint.swift @@ -0,0 +1,20 @@ +//===----------------------------------------------------------------------===// +// +// 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 Commands + +@main +struct Entrypoint { + static func main() async { + await SwiftScaffoldCommand.main() + } +} From d298d1be2be3bf8fecc1bde01e3282d0fcba3fb0 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 24 Jun 2025 11:27:36 -0400 Subject: [PATCH 187/225] added ability to view description of templates to show-templates --- .../PackageCommands/ShowTemplates.swift | 37 ++++++++++++++++++- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index eabb637b28e..782da198123 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -151,6 +151,7 @@ struct ShowTemplates: AsyncSwiftCommand { } } + // Load the package graph. let packageGraph = try await swiftCommandState .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in @@ -172,10 +173,13 @@ struct ShowTemplates: AsyncSwiftCommand { switch self.format { case .flatlist: for template in templates.sorted(by: { $0.name < $1.name }) { + let description = try await swiftCommandState.withTemporaryWorkspace(switchingTo: resolvedTemplatePath) {_, _ in + try await getDescription(swiftCommandState, template: template.name) + } if let package = template.package { - print("\(template.name) (\(package))") + print("\(template.name) (\(package)) : \(description)") } else { - print(template.name) + print("\(template.name) : \(description)") } } @@ -188,6 +192,35 @@ struct ShowTemplates: AsyncSwiftCommand { } } + private func getDescription(_ swiftCommandState: SwiftCommandState, template: String) async throws -> String { + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() + + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope + ) + guard let rootManifest = rootManifests.values.first else { + throw InternalError("invalid manifests at \(root.packages)") + } + + let products = rootManifest.products + let targets = rootManifest.targets + + for _ in products { + if let target: TargetDescription = targets.first(where: { $0.name == template }) { + if let options = target.templateInitializationOptions { + if case .packageInit(_, _, let description) = options { + return description + } + } + } + } + throw InternalError( + "Could not find template \(template)" + ) + } + /// Represents a discovered template. struct Template: Codable { /// Optional name of the external package, if the template comes from one. From c35b5c1b2a6eefb3bdb1bc212effc258c6b8a7f0 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 24 Jun 2025 14:53:41 -0400 Subject: [PATCH 188/225] new command swift package scaffold --- Package.swift | 6 ------ .../Scaffold.swift} | 19 +++++++----------- .../PackageCommands/ShowTemplates.swift | 2 +- .../PackageCommands/SwiftPackageCommand.swift | 1 + Sources/swift-scaffold/CMakeLists.txt | 18 ----------------- Sources/swift-scaffold/Entrypoint.swift | 20 ------------------- 6 files changed, 9 insertions(+), 57 deletions(-) rename Sources/Commands/{SwiftScaffold.swift => PackageCommands/Scaffold.swift} (94%) delete mode 100644 Sources/swift-scaffold/CMakeLists.txt delete mode 100644 Sources/swift-scaffold/Entrypoint.swift diff --git a/Package.swift b/Package.swift index e9b1cbab786..bb9328c002b 100644 --- a/Package.swift +++ b/Package.swift @@ -743,12 +743,6 @@ let package = Package( dependencies: ["Commands"], exclude: ["CMakeLists.txt"] ), - .executableTarget( - /** Scaffolds a package */ - name: "swift-scaffold", - dependencies: ["Commands"], - exclude: ["CMakeLists.txt"] - ), .executableTarget( /** Interacts with package collections */ name: "swift-package-collection", diff --git a/Sources/Commands/SwiftScaffold.swift b/Sources/Commands/PackageCommands/Scaffold.swift similarity index 94% rename from Sources/Commands/SwiftScaffold.swift rename to Sources/Commands/PackageCommands/Scaffold.swift index 9e449f481b8..83f8fa7d678 100644 --- a/Sources/Commands/SwiftScaffold.swift +++ b/Sources/Commands/PackageCommands/Scaffold.swift @@ -30,15 +30,10 @@ import XCBuildSupport import ArgumentParserToolInfo -public struct SwiftScaffoldCommand: AsyncSwiftCommand { - public static var configuration = CommandConfiguration( - commandName: "scaffold", - _superCommandName: "swift", - abstract: "Scaffold packages from templates.", - discussion: "SEE ALSO: swift run, swift package, swift test", - version: SwiftVersion.current.completeDisplayString, - helpNames: [.short, .long, .customLong("help", withSingleDash: true)]) - +extension SwiftPackageCommand { + public struct Scaffold: AsyncSwiftCommand { + public static let configuration = CommandConfiguration( + abstract: "Generate a new Swift package from a template.") @OptionGroup(visibility: .hidden) public var globalOptions: GlobalOptions @@ -144,10 +139,10 @@ public struct SwiftScaffoldCommand: AsyncSwiftCommand { ) let registryRequirement: PackageDependency.Registry.Requirement? = - try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement + try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = - try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement let resolvedTemplatePath = try await TemplatePathResolver( source: templateSource, @@ -289,7 +284,7 @@ public struct SwiftScaffoldCommand: AsyncSwiftCommand { public init() {} } - +} extension InitPackage.PackageType { init(from templateType: TargetDescription.TemplateType) throws { diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 782da198123..442161a08bd 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -156,7 +156,7 @@ struct ShowTemplates: AsyncSwiftCommand { let packageGraph = try await swiftCommandState .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in try await swiftCommandState.loadPackageGraph() - } + } let rootPackages = packageGraph.rootPackages.map(\.identity) diff --git a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift index 6aed2971cc8..99e05fb24c4 100644 --- a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift +++ b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift @@ -45,6 +45,7 @@ public struct SwiftPackageCommand: AsyncParsableCommand { Update.self, Describe.self, Init.self, + Scaffold.self, Format.self, Migrate.self, diff --git a/Sources/swift-scaffold/CMakeLists.txt b/Sources/swift-scaffold/CMakeLists.txt deleted file mode 100644 index 94f49824fbd..00000000000 --- a/Sources/swift-scaffold/CMakeLists.txt +++ /dev/null @@ -1,18 +0,0 @@ -# This source file is part of the Swift open source project -# -# Copyright (c) 2014 - 2019 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 Swift project authors - -add_executable(swift-scaffold - Entrypoint.swift) -target_link_libraries(swift-run PRIVATE - Commands) - -target_compile_options(swift-scaffold PRIVATE - -parse-as-library) - -install(TARGETS swift-scaffold - RUNTIME DESTINATION bin) diff --git a/Sources/swift-scaffold/Entrypoint.swift b/Sources/swift-scaffold/Entrypoint.swift deleted file mode 100644 index 0f79e310eab..00000000000 --- a/Sources/swift-scaffold/Entrypoint.swift +++ /dev/null @@ -1,20 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 Commands - -@main -struct Entrypoint { - static func main() async { - await SwiftScaffoldCommand.main() - } -} From cf65c84cc00dec54ae8510763bf7d07da9eb303c Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 24 Jun 2025 14:56:42 -0400 Subject: [PATCH 189/225] added more clarity to .template parameter initialType --- Sources/PackageDescription/Target.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index a306035ea62..54e22b32f75 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -1286,7 +1286,7 @@ public extension [Target] { swiftSettings: [SwiftSetting]? = nil, linkerSettings: [LinkerSetting]? = nil, plugins: [Target.PluginUsage]? = nil, - initialType: Target.TemplateType, + initialPackageType: Target.TemplateType = .empty, templatePermissions: [TemplatePermissions]? = nil, description: String ) -> [Target] { @@ -1320,7 +1320,7 @@ public extension [Target] { let templateInitializationOptions = Target.TemplateInitializationOptions.packageInit( - templateType: initialType, + templateType: initialPackageType, templatePermissions: templatePermissions, description: description ) From 2d1d4be6baffb3399c8525d187b6eb98932a47a2 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 8 Jul 2025 15:18:58 -0400 Subject: [PATCH 190/225] registry stuff, might revert later, but for now we can keep --- Sources/Basics/SourceControlURL.swift | 4 + Sources/PackageMetadata/PackageMetadata.swift | 41 ++++- Sources/PackageRegistry/RegistryClient.swift | 68 +++++++- .../PackageRegistryCommand+Discover.swift | 101 +++++++++++ .../PackageRegistryCommand+Get.swift | 164 ++++++++++++++++++ .../PackageRegistryCommand.swift | 8 + .../Workspace/Workspace+Dependencies.swift | 88 +++++++++- 7 files changed, 469 insertions(+), 5 deletions(-) create mode 100644 Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift create mode 100644 Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift diff --git a/Sources/Basics/SourceControlURL.swift b/Sources/Basics/SourceControlURL.swift index 398c2f89ddf..70bb1df9cee 100644 --- a/Sources/Basics/SourceControlURL.swift +++ b/Sources/Basics/SourceControlURL.swift @@ -23,6 +23,10 @@ public struct SourceControlURL: Codable, Equatable, Hashable, Sendable { self.urlString = urlString } + public init(argument: String) { + self.urlString = argument + } + public init(_ url: URL) { self.urlString = url.absoluteString } diff --git a/Sources/PackageMetadata/PackageMetadata.swift b/Sources/PackageMetadata/PackageMetadata.swift index 8622c1181d7..6bb0d8c38d3 100644 --- a/Sources/PackageMetadata/PackageMetadata.swift +++ b/Sources/PackageMetadata/PackageMetadata.swift @@ -24,6 +24,20 @@ import struct Foundation.URL import struct TSCUtility.Version public struct Package { + + public struct Template: Sendable { + public let name: String + public let description: String? + //public let permissions: [String]? TODO ADD + public let arguments: [TemplateArguments]? + } + + public struct TemplateArguments: Sendable { + public let name: String + public let description: String? + public let isRequired: Bool? + } + public enum Source { case indexAndCollections(collections: [PackageCollectionsModel.CollectionIdentifier], indexes: [URL]) case registry(url: URL) @@ -74,6 +88,7 @@ public struct Package { public let publishedAt: Date? public let signingEntity: SigningEntity? public let latestVersion: Version? + public let templates: [Template]? fileprivate init( identity: PackageIdentity, @@ -89,7 +104,8 @@ public struct Package { publishedAt: Date? = nil, signingEntity: SigningEntity? = nil, latestVersion: Version? = nil, - source: Source + source: Source, + templates: [Template]? = nil ) { self.identity = identity self.location = location @@ -105,6 +121,7 @@ public struct Package { self.signingEntity = signingEntity self.latestVersion = latestVersion self.source = source + self.templates = templates } } @@ -164,6 +181,7 @@ public struct PackageSearchClient { public let description: String? public let publishedAt: Date? public let signingEntity: SigningEntity? + public let templates: [Package.Template]? } private func getVersionMetadata( @@ -185,7 +203,8 @@ public struct PackageSearchClient { author: metadata.author.map { .init($0) }, description: metadata.description, publishedAt: metadata.publishedAt, - signingEntity: metadata.sourceArchive?.signingEntity + signingEntity: metadata.sourceArchive?.signingEntity, + templates: metadata.templates?.map { .init($0) } ) } @@ -424,6 +443,24 @@ extension Package.Organization { } } +extension Package.Template { + fileprivate init(_ template: RegistryClient.PackageVersionMetadata.Template) { + self.init( + name: template.name, description: template.description, arguments: template.arguments?.map { .init($0) } + ) + } +} + +extension Package.TemplateArguments { + fileprivate init(_ argument: RegistryClient.PackageVersionMetadata.TemplateArguments) { + self.init( + name: argument.name, + description: argument.description, + isRequired: argument.isRequired + ) + } +} + extension SigningEntity { fileprivate init(signer: PackageCollectionsModel.Signer) { // All package collection signers are "recognized" diff --git a/Sources/PackageRegistry/RegistryClient.swift b/Sources/PackageRegistry/RegistryClient.swift index 320297a0c30..bd858d7c493 100644 --- a/Sources/PackageRegistry/RegistryClient.swift +++ b/Sources/PackageRegistry/RegistryClient.swift @@ -363,7 +363,20 @@ public final class RegistryClient: AsyncCancellable { ) }, description: versionMetadata.metadata?.description, - publishedAt: versionMetadata.metadata?.originalPublicationTime ?? versionMetadata.publishedAt + publishedAt: versionMetadata.metadata?.originalPublicationTime ?? versionMetadata.publishedAt, + templates: versionMetadata.metadata?.templates?.compactMap { template in + PackageVersionMetadata.Template( + name: template.name, + description: template.description, + arguments: template.arguments?.map { arg in + PackageVersionMetadata.TemplateArguments( + name: arg.name, + description: arg.description, + isRequired: arg.isRequired + ) + } + ) + } ) return packageVersionMetadata @@ -1725,11 +1738,45 @@ extension RegistryClient { public let author: Author? public let description: String? public let publishedAt: Date? + public let templates: [Template]? public var sourceArchive: Resource? { self.resources.first(where: { $0.name == "source-archive" }) } + public struct Template: Sendable { + public let name: String + public let description: String? + //public let permissions: [String]? TODO ADD + public let arguments: [TemplateArguments]? + + public init( + name: String, + description: String? = nil, + arguments: [TemplateArguments]? = nil + ) { + self.name = name + self.description = description + self.arguments = arguments + } + } + + public struct TemplateArguments: Sendable { + public let name: String + public let description: String? + public let isRequired: Bool? + + public init( + name: String, + description: String? = nil, + isRequired: Bool? = nil + ) { + self.name = name + self.description = description + self.isRequired = isRequired + } + } + public struct Resource: Sendable { public let name: String public let type: String @@ -2150,6 +2197,7 @@ extension RegistryClient { public let readmeURL: String? public let repositoryURLs: [String]? public let originalPublicationTime: Date? + public let templates: [Template]? public init( author: Author? = nil, @@ -2157,7 +2205,8 @@ extension RegistryClient { licenseURL: String? = nil, readmeURL: String? = nil, repositoryURLs: [String]? = nil, - originalPublicationTime: Date? = nil + originalPublicationTime: Date? = nil, + templates: [Template]? = nil ) { self.author = author self.description = description @@ -2165,9 +2214,24 @@ extension RegistryClient { self.readmeURL = readmeURL self.repositoryURLs = repositoryURLs self.originalPublicationTime = originalPublicationTime + self.templates = templates } } + public struct Template: Codable { + public let name: String + public let description: String? + //public let permissions: [String]? TODO ADD + public let arguments: [TemplateArguments]? + } + + public struct TemplateArguments: Codable { + public let name: String + public let description: String? + public let isRequired: Bool? + } + + public struct Author: Codable { public let name: String public let email: String? diff --git a/Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift b/Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift new file mode 100644 index 00000000000..f6f0137342e --- /dev/null +++ b/Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2023 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 Commands +import CoreCommands +import Foundation +import PackageModel +import PackageFingerprint +import PackageRegistry +import PackageSigning +import Workspace + +#if USE_IMPL_ONLY_IMPORTS +@_implementationOnly import X509 // FIXME: need this import or else SwiftSigningIdentity initializer fails +#else +import X509 +#endif + +import struct TSCBasic.ByteString +import struct TSCBasic.RegEx +import struct TSCBasic.SHA256 + +import struct TSCUtility.Version + +extension PackageRegistryCommand { + struct Discover: AsyncSwiftCommand { + static let configuration = CommandConfiguration( + abstract: "Get a package registry entry." + ) + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + @Argument(help: .init("URL pointing towards package identifiers", valueName: "scm-url")) + var url: SourceControlURL + + @Flag(name: .customLong("allow-insecure-http"), help: "Allow using a non-HTTPS registry URL.") + var allowInsecureHTTP: Bool = false + + @Option(name: [.customLong("url"), .customLong("registry-url")], help: "Override registry URL.") + var registryURL: URL? + + func run(_ swiftCommandState: SwiftCommandState) async throws { + let packageDirectory = try resolvePackageDirectory(swiftCommandState) + let authorizationProvider = try resolveAuthorizationProvider(swiftCommandState) + + let registryClient = RegistryClient( + configuration: try getRegistriesConfig(swiftCommandState, global: false).configuration, + fingerprintStorage: .none, + fingerprintCheckingMode: .strict, + skipSignatureValidation: false, + signingEntityStorage: .none, + signingEntityCheckingMode: .strict, + authorizationProvider: authorizationProvider, + delegate: .none, + checksumAlgorithm: SHA256() + ) + + let set = try await registryClient.lookupIdentities(scmURL: url, observabilityScope: swiftCommandState.observabilityScope) + + if set.isEmpty { + throw ValidationError.invalidLookupURL(url) + } + + print(set) + } + + private func resolvePackageDirectory(_ swiftCommandState: SwiftCommandState) throws -> AbsolutePath { + let directory = try self.globalOptions.locations.packageDirectory + ?? swiftCommandState.getPackageRoot() + + guard localFileSystem.isDirectory(directory) else { + throw StringError("No package found at '\(directory)'.") + } + + return directory + } + + private func resolveAuthorizationProvider(_ swiftCommandState: SwiftCommandState) throws -> AuthorizationProvider { + guard let provider = try swiftCommandState.getRegistryAuthorizationProvider() else { + throw ValidationError.unknownCredentialStore + } + + return provider + } + } +} + + +extension SourceControlURL: ExpressibleByArgument {} diff --git a/Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift b/Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift new file mode 100644 index 00000000000..a785075ef4e --- /dev/null +++ b/Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift @@ -0,0 +1,164 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2023 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 Commands +import CoreCommands +import Foundation +import PackageModel +import PackageFingerprint +import PackageRegistry +import PackageSigning +import Workspace + +#if USE_IMPL_ONLY_IMPORTS +@_implementationOnly import X509 // FIXME: need this import or else SwiftSigningIdentity initializer fails +#else +import X509 +#endif + +import struct TSCBasic.ByteString +import struct TSCBasic.RegEx +import struct TSCBasic.SHA256 + +import struct TSCUtility.Version + +extension PackageRegistryCommand { + struct Get: AsyncSwiftCommand { + static let configuration = CommandConfiguration( + abstract: "Get a package registry entry." + ) + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + @Argument(help: .init("The package identifier.", valueName: "package-id")) + var packageIdentity: PackageIdentity + + @Option(help: .init("The package release version being queried.", valueName: "package-version")) + var packageVersion: Version? + + @Flag(help: .init("Fetch the Package.swift manifest of the registry entry", valueName: "manifest")) + var manifest: Bool = false + + @Option(help: .init("Swift tools version of the manifest", valueName: "custom-tools-version")) + var customToolsVersion: String? + + @Flag(name: .customLong("allow-insecure-http"), help: "Allow using a non-HTTPS registry URL.") + var allowInsecureHTTP: Bool = false + + @Option(name: [.customLong("url"), .customLong("registry-url")], help: "Override registry URL.") + var registryURL: URL? + + func run(_ swiftCommandState: SwiftCommandState) async throws { + let packageDirectory = try resolvePackageDirectory(swiftCommandState) + let registryURL = try resolveRegistryURL(swiftCommandState) + let authorizationProvider = try resolveAuthorizationProvider(swiftCommandState) + + let registryClient = RegistryClient( + configuration: try getRegistriesConfig(swiftCommandState, global: false).configuration, + fingerprintStorage: .none, + fingerprintCheckingMode: .strict, + skipSignatureValidation: false, + signingEntityStorage: .none, + signingEntityCheckingMode: .strict, + authorizationProvider: authorizationProvider, + delegate: .none, + checksumAlgorithm: SHA256() + ) + + try await fetchRegistryData(using: registryClient, swiftCommandState: swiftCommandState) + } + + private func resolvePackageDirectory(_ swiftCommandState: SwiftCommandState) throws -> AbsolutePath { + let directory = try self.globalOptions.locations.packageDirectory + ?? swiftCommandState.getPackageRoot() + + guard localFileSystem.isDirectory(directory) else { + throw StringError("No package found at '\(directory)'.") + } + + return directory + } + + private func resolveRegistryURL(_ swiftCommandState: SwiftCommandState) throws -> URL { + let config = try getRegistriesConfig(swiftCommandState, global: false).configuration + guard let identity = self.packageIdentity.registry else { + throw ValidationError.invalidPackageIdentity(self.packageIdentity) + } + + guard let url = self.registryURL ?? config.registry(for: identity.scope)?.url else { + throw ValidationError.unknownRegistry + } + + let allowHTTP = try self.allowInsecureHTTP && (config.authentication(for: url) == nil) + try url.validateRegistryURL(allowHTTP: allowHTTP) + + return url + } + + private func resolveAuthorizationProvider(_ swiftCommandState: SwiftCommandState) throws -> AuthorizationProvider { + guard let provider = try swiftCommandState.getRegistryAuthorizationProvider() else { + throw ValidationError.unknownCredentialStore + } + + return provider + } + + private func fetchToolsVersion() -> ToolsVersion? { + return customToolsVersion.flatMap { ToolsVersion(string: $0) } + } + + private func fetchRegistryData( + using client: RegistryClient, + swiftCommandState: SwiftCommandState + ) async throws { + let scope = swiftCommandState.observabilityScope + + if manifest { + guard let version = packageVersion else { + throw ValidationError.noPackageVersion(packageIdentity) + } + + let toolsVersion = fetchToolsVersion() + let content = try await client.getManifestContent( + package: self.packageIdentity, + version: version, + customToolsVersion: toolsVersion, + observabilityScope: scope + ) + + print(content) + return + } + + if let version = packageVersion { + let metadata = try await client.getPackageVersionMetadata( + package: self.packageIdentity, + version: version, + fileSystem: localFileSystem, + observabilityScope: scope + ) + + print(metadata) + } else { + let metadata = try await client.getPackageMetadata( + package: self.packageIdentity, + observabilityScope: scope + ) + + print(metadata) + } + } + } +} diff --git a/Sources/PackageRegistryCommand/PackageRegistryCommand.swift b/Sources/PackageRegistryCommand/PackageRegistryCommand.swift index f41004fcd8e..516ed6947a4 100644 --- a/Sources/PackageRegistryCommand/PackageRegistryCommand.swift +++ b/Sources/PackageRegistryCommand/PackageRegistryCommand.swift @@ -32,6 +32,8 @@ public struct PackageRegistryCommand: AsyncParsableCommand { Login.self, Logout.self, Publish.self, + Get.self, + Discover.self ], helpNames: [.short, .long, .customLong("help", withSingleDash: true)] ) @@ -141,6 +143,8 @@ public struct PackageRegistryCommand: AsyncParsableCommand { case unknownCredentialStore case invalidCredentialStore(Error) case credentialLengthLimitExceeded(Int) + case noPackageVersion(PackageIdentity) + case invalidLookupURL(SourceControlURL) } static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace.Configuration.Registries { @@ -199,6 +203,10 @@ extension PackageRegistryCommand.ValidationError: CustomStringConvertible { return "credential store is invalid: \(error.interpolationDescription)" case .credentialLengthLimitExceeded(let limit): return "password or access token must be \(limit) characters or less" + case .noPackageVersion(let identity): + return "no package version found for '\(identity)'" + case .invalidLookupURL(let url): + return "no package identifier was found in URL: \(url)" } } } diff --git a/Sources/Workspace/Workspace+Dependencies.swift b/Sources/Workspace/Workspace+Dependencies.swift index 64eb37227b8..80c1410928a 100644 --- a/Sources/Workspace/Workspace+Dependencies.swift +++ b/Sources/Workspace/Workspace+Dependencies.swift @@ -40,6 +40,7 @@ import struct PackageGraph.Term import class PackageLoading.ManifestLoader import enum PackageModel.PackageDependency import struct PackageModel.PackageIdentity +import struct Basics.SourceControlURL import struct PackageModel.PackageReference import enum PackageModel.ProductFilter import struct PackageModel.ToolsVersion @@ -50,7 +51,7 @@ import struct PackageModel.TraitDescription import enum PackageModel.TraitConfiguration import class PackageModel.Manifest -extension Workspace { +public extension Workspace { enum ResolvedFileStrategy { case lockFile case update(forceResolution: Bool) @@ -818,6 +819,91 @@ extension Workspace { } } + /* + func resolveTemplatePackage( + templateDirectory: AbsolutePath? = nil, + + templateURL: SourceControlURL? = nil, + templatePackageID: PackageIdentity? = nil, + observabilityScope: ObservabilityScope, + revisionParsed: String?, + branchParsed: String?, + exactVersion: Version?, + fromParsed: String?, + toParsed: String?, + upToNextMinorParsed: String? + + ) async throws -> AbsolutePath { + if let path = templateDirectory { + // Local filesystem path + let packageRef = PackageReference.root(identity: .init(path: path), path: path) + let dependency = try ManagedDependency.fileSystem(packageRef: packageRef) + + guard case .fileSystem(let resolvedPath) = dependency.state else { + throw InternalError("invalid file system package state") + } + + await self.state.add(dependency: dependency) + try await self.state.save() + return resolvedPath + + } else if let url = templateURL { + // Git URL + let packageRef = PackageReference.remoteSourceControl(identity: PackageIdentity(url: url), url: url) + + let requirement: PackageStateChange.Requirement + if let revision = revisionParsed { + if let branch = branchParsed { + requirement = .revision(.init(identifier:revision), branch: branch) + } else { + requirement = .revision(.init(identifier: revision), branch: nil) + } + } else if let version = exactVersion { + requirement = .version(version) + } else { + throw InternalError("No usable Git version/revision/branch provided") + } + + return try await self.updateDependency( + package: packageRef, + requirement: requirement, + productFilter: .everything, + observabilityScope: observabilityScope + ) + + } else if let packageID = templatePackageID { + // Registry package + let identity = packageID + let packageRef = PackageReference.registry(identity: identity) + + let requirement: PackageStateChange.Requirement + if let exact = exactVersion { + requirement = .version(exact) + } else if let from = fromParsed, let to = toParsed { + // Not supported in updateDependency – adjust logic if needed + throw InternalError("Version range constraints are not supported here") + } else if let upToMinor = upToNextMinorParsed { + // SwiftPM normally supports this – you may need to expand updateDependency to support it + throw InternalError("upToNextMinorFrom not currently supported") + } else { + throw InternalError("No usable Registry version provided") + } + + return try await self.updateDependency( + package: packageRef, + requirement: requirement, + productFilter: .everything, + observabilityScope: observabilityScope + ) + + } else { + throw InternalError("No template source provided (path, url, or package-id)") + } + } + + */ + + public enum ResolutionPrecomputationResult: Equatable { case required(reason: WorkspaceResolveReason) case notRequired From 367c7080d292465cacfca4d7eb0de8154408bcd2 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 8 Jul 2025 16:46:09 -0400 Subject: [PATCH 191/225] added back to init templates --- Sources/Commands/PackageCommands/Init.swift | 336 ++++++++++++++++-- .../Commands/PackageCommands/Scaffold.swift | 308 ---------------- .../PackageCommands/SwiftPackageCommand.swift | 1 - Sources/Workspace/InitPackage.swift | 1 - 4 files changed, 314 insertions(+), 332 deletions(-) delete mode 100644 Sources/Commands/PackageCommands/Scaffold.swift diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index d93600002b1..b4b56ea3826 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -11,6 +11,8 @@ //===----------------------------------------------------------------------===// import ArgumentParser +import ArgumentParserToolInfo + import Basics @_spi(SwiftPMInternal) @@ -19,9 +21,14 @@ import CoreCommands import PackageModel import Workspace import SPMBuildCore +import TSCBasic +import TSCUtility +import Foundation +import PackageGraph + extension SwiftPackageCommand { - struct Init: SwiftCommand { + struct Init: AsyncSwiftCommand { public static let configuration = CommandConfiguration( abstract: "Initialize a new package.") @@ -41,50 +48,335 @@ extension SwiftPackageCommand { macro - A package that vends a macro. empty - An empty package with a Package.swift manifest. """)) - var initMode: InitPackage.PackageType = .library + var initMode: String? + + //if --type is mentioned with one of the seven above, then normal initialization + // if --type is mentioned along with a templateSource, its a template (no matter what) + // if-type is not mentioned with no templatesoURCE, then defaults to library + // if --type is not mentioned and templateSource is not nil, then there is only one template in package /// Which testing libraries to use (and any related options.) - @OptionGroup() + @OptionGroup(visibility: .hidden) var testLibraryOptions: TestLibraryOptions @Option(name: .customLong("name"), help: "Provide custom package name.") var packageName: String? + @OptionGroup(visibility: .hidden) + var buildOptions: BuildCommandOptions + + /// The type of template to use: `registry`, `git`, or `local`. + var templateSource: InitTemplatePackage.TemplateSource? { + if templateDirectory != nil { + .local + } else if templateURL != nil { + .git + } else if templatePackageID != nil { + .registry + } else { + nil + } + } + + + // + // + // + // + /// Path to a local template. + @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) + var templateDirectory: Basics.AbsolutePath? + + /// Git URL of the template. + @Option(name: .customLong("url"), help: "The git URL of the template.") + var templateURL: String? + + /// Package Registry ID of the template. + @Option(name: .customLong("package-id"), help: "The package identifier of the template") + var templatePackageID: String? + + // MARK: - Versioning Options for Remote Git Templates and Registry templates + + /// The exact version of the remote package to use. + @Option(help: "The exact package version to depend on.") + var exact: Version? + + /// Specific revision to use (for Git templates). + @Option(help: "The specific package revision to depend on.") + var revision: String? + + /// Branch name to use (for Git templates). + @Option(help: "The branch of the package to depend on.") + var branch: String? + + /// Version to depend on, up to the next major version. + @Option(help: "The package version to depend on (up to the next major version).") + var from: Version? + + /// Version to depend on, up to the next minor version. + @Option(help: "The package version to depend on (up to the next minor version).") + var upToNextMinorFrom: Version? + + /// Upper bound on the version range (exclusive). + @Option(help: "Specify upper bound on the package version range (exclusive).") + var to: Version? + + /// Predetermined arguments specified by the consumer. + @Argument( + help: "Predetermined arguments to pass to the template." + ) + var args: [String] = [] + // This command should support creating the supplied --package-path if it isn't created. var createPackagePath = true - func run(_ swiftCommandState: SwiftCommandState) throws { + func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") } let packageName = self.packageName ?? cwd.basename - // Testing is on by default, with XCTest only enabled explicitly. - // For macros this is reversed, since we don't support testing - // macros with Swift Testing yet. - var supportedTestingLibraries = Set() - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (initMode == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.xctest) + // Check for template init path + if let _ = templateSource { + // When a template source is provided: + // - If the user gives a known type, it's probably a misuse + // - If the user gives an unknown value for --type, treat it as the name of the template + // - If --type is missing entirely, assume the package has a single template + try await initTemplate(swiftCommandState) + return + } else { + guard let initModeString = self.initMode else { + throw ValidationError("Specify a package type using the --type option.") + } + guard let knownType = InitPackage.PackageType(rawValue: initModeString) else { + throw ValidationError("Package type \(initModeString) not supported") + } + // Configure testing libraries + var supportedTestingLibraries = Set() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (knownType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (knownType != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.swiftTesting) + } + + let initPackage = try InitPackage( + name: packageName, + packageType: knownType, + supportedTestingLibraries: supportedTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, + fileSystem: swiftCommandState.fileSystem + ) + initPackage.progressReporter = { message in print(message) } + try initPackage.writePackageStructure() + + + } + + } + + + public func initTemplate(_ swiftCommandState: SwiftCommandState) async throws { + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.swiftTesting) + + let packageName = self.packageName ?? cwd.basename + + try await self.runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) + + } + + /// Runs the package initialization using an author-defined template. + private func runTemplateInit( + swiftCommandState: SwiftCommandState, + packageName: String, + cwd: Basics.AbsolutePath + ) async throws { + + let template = initMode + guard let source = templateSource else { + throw ValidationError("No template source specified.") } - let initPackage = try InitPackage( + let requirementResolver = DependencyRequirementResolver( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ) + + let registryRequirement: PackageDependency.Registry.Requirement? = + try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement + + let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = + try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + + let resolvedTemplatePath = try await TemplatePathResolver( + source: templateSource, + templateDirectory: templateDirectory, + templateURL: templateURL, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() + + if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { + throw ValidationError("The specified template path does not exist: \(dir.pathString)") + } + + let templateInitType = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in + try await self.checkConditions(swiftCommandState, template: template) + } + + // Clean up downloaded package after execution. + defer { + if templateSource == .git { + try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) + } else if templateSource == .registry { + let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL + try? FileManager.default.removeItem(at: parentDirectoryURL) + } + } + + let supportedTemplateTestingLibraries: Set = .init() + + let builder = DefaultPackageDependencyBuilder( + templateSource: source, + packageName: packageName, + templateURL: self.templateURL, + templatePackageID: self.templatePackageID + ) + + let dependencyKind = try builder.makePackageDependency( + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ) + + let initTemplatePackage = try InitTemplatePackage( name: packageName, - packageType: initMode, - supportedTestingLibraries: supportedTestingLibraries, + initMode: dependencyKind, + templatePath: resolvedTemplatePath, + fileSystem: swiftCommandState.fileSystem, + packageType: templateInitType, + supportedTestingLibraries: supportedTemplateTestingLibraries, destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, - fileSystem: swiftCommandState.fileSystem + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration + ) + + try initTemplatePackage.setupTemplateManifest() + + try await TemplateBuildSupport.build( + swiftCommandState: swiftCommandState, + buildOptions: self.buildOptions, + globalOptions: self.globalOptions, + cwd: cwd + ) + + let packageGraph = try await swiftCommandState.loadPackageGraph() + let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) + + guard let commandPlugin = matchingPlugins.first else { + guard let template = template + else { throw ValidationError("No templates were found in \(packageName)") } + + throw ValidationError("No templates were found that match the name \(template)") + } + + guard matchingPlugins.count == 1 else { + let templateNames = matchingPlugins.compactMap { module in + let plugin = module.underlying as! PluginModule + guard case .command(let intent, _) = plugin.capability else { return String?.none } + + return intent.invocationVerb + } + throw ValidationError( + "More than one template was found in the package. Please use `--type` along with one of the available templates: \(templateNames.joined(separator: ", "))" + ) + } + + let output = try await TemplatePluginRunner.run( + plugin: commandPlugin, + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: ["--", "--experimental-dump-help"], + swiftCommandState: swiftCommandState + ) + + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: args) + + do { + let _ = try await TemplatePluginRunner.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: response, + swiftCommandState: swiftCommandState + ) + } + } + + /// Validates the loaded manifest to determine package type. + private func checkConditions(_ swiftCommandState: SwiftCommandState, template: String?) async throws -> InitPackage.PackageType { + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() + + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope ) - initPackage.progressReporter = { message in - print(message) + guard let rootManifest = rootManifests.values.first else { + throw InternalError("invalid manifests at \(root.packages)") } - try initPackage.writePackageStructure() + + let products = rootManifest.products + let targets = rootManifest.targets + + for _ in products { + if let target: TargetDescription = targets.first(where: { template == nil || $0.name == template }) { + if let options = target.templateInitializationOptions { + if case .packageInit(let templateType, _, _) = options { + return try .init(from: templateType) + } + } + } + } + throw ValidationError( + "Could not find \(template != nil ? "template \(template!)" : "any templates in the package")" + ) + } + public init() {} + + } +} + +extension InitPackage.PackageType { + init(from templateType: TargetDescription.TemplateType) throws { + switch templateType { + case .executable: + self = .executable + case .library: + self = .library + case .tool: + self = .tool + case .macro: + self = .macro + case .buildToolPlugin: + self = .buildToolPlugin + case .commandPlugin: + self = .commandPlugin + case .empty: + self = .empty } } } diff --git a/Sources/Commands/PackageCommands/Scaffold.swift b/Sources/Commands/PackageCommands/Scaffold.swift deleted file mode 100644 index 83f8fa7d678..00000000000 --- a/Sources/Commands/PackageCommands/Scaffold.swift +++ /dev/null @@ -1,308 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 - -@_spi(SwiftPMInternal) -import CoreCommands - -import PackageModel -import SPMBuildCore -import TSCUtility -import Workspace - -import Foundation -import PackageGraph -import SourceControl -import SPMBuildCore -import TSCBasic -import XCBuildSupport - -import ArgumentParserToolInfo - -extension SwiftPackageCommand { - public struct Scaffold: AsyncSwiftCommand { - public static let configuration = CommandConfiguration( - abstract: "Generate a new Swift package from a template.") - - @OptionGroup(visibility: .hidden) - public var globalOptions: GlobalOptions - - @OptionGroup(visibility: .hidden) - var buildOptions: BuildCommandOptions - - @Option(name: .customLong("name"), help: "Provide custom package name.") - var packageName: String? - - /// Name of a template to use for package initialization. - @Option( - name: .customLong("template"), - help: "Name of a template to initialize the package, unspecified if the default template should be used." - ) - var template: String? - - /// The type of template to use: `registry`, `git`, or `local`. - var templateSource: InitTemplatePackage.TemplateSource? { - if templateDirectory != nil { - .local - } else if templateURL != nil { - .git - } else if templatePackageID != nil { - .registry - } else { - nil - } - } - - /// Path to a local template. - @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) - var templateDirectory: Basics.AbsolutePath? - - /// Git URL of the template. - @Option(name: .customLong("url"), help: "The git URL of the template.") - var templateURL: String? - - /// Package Registry ID of the template. - @Option(name: .customLong("package-id"), help: "The package identifier of the template") - var templatePackageID: String? - - // MARK: - Versioning Options for Remote Git Templates and Registry templates - - /// The exact version of the remote package to use. - @Option(help: "The exact package version to depend on.") - var exact: Version? - - /// Specific revision to use (for Git templates). - @Option(help: "The specific package revision to depend on.") - var revision: String? - - /// Branch name to use (for Git templates). - @Option(help: "The branch of the package to depend on.") - var branch: String? - - /// Version to depend on, up to the next major version. - @Option(help: "The package version to depend on (up to the next major version).") - var from: Version? - - /// Version to depend on, up to the next minor version. - @Option(help: "The package version to depend on (up to the next minor version).") - var upToNextMinorFrom: Version? - - /// Upper bound on the version range (exclusive). - @Option(help: "Specify upper bound on the package version range (exclusive).") - var to: Version? - - /// Predetermined arguments specified by the consumer. - @Argument( - help: "Predetermined arguments to pass to the template." - ) - var args: [String] = [] - - public func run(_ swiftCommandState: SwiftCommandState) async throws { - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") - } - - let packageName = self.packageName ?? cwd.basename - - try await self.runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) - - } - - /// Runs the package initialization using an author-defined template. - private func runTemplateInit( - swiftCommandState: SwiftCommandState, - packageName: String, - cwd: Basics.AbsolutePath - ) async throws { - guard let source = templateSource else { - throw ValidationError("No template source specified.") - } - - let requirementResolver = DependencyRequirementResolver( - exact: exact, - revision: revision, - branch: branch, - from: from, - upToNextMinorFrom: upToNextMinorFrom, - to: to - ) - - let registryRequirement: PackageDependency.Registry.Requirement? = - try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement - - let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = - try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement - - let resolvedTemplatePath = try await TemplatePathResolver( - source: templateSource, - templateDirectory: templateDirectory, - templateURL: templateURL, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - packageIdentity: templatePackageID, - swiftCommandState: swiftCommandState - ).resolve() - - if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { - throw ValidationError("The specified template path does not exist: \(dir.pathString)") - } - - let templateInitType = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in - try await self.checkConditions(swiftCommandState) - } - - // Clean up downloaded package after execution. - defer { - if templateSource == .git { - try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) - } else if templateSource == .registry { - let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL - try? FileManager.default.removeItem(at: parentDirectoryURL) - } - } - - let supportedTemplateTestingLibraries: Set = .init() - - let builder = DefaultPackageDependencyBuilder( - templateSource: source, - packageName: packageName, - templateURL: self.templateURL, - templatePackageID: self.templatePackageID - ) - - let dependencyKind = try builder.makePackageDependency( - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - resolvedTemplatePath: resolvedTemplatePath - ) - - let initTemplatePackage = try InitTemplatePackage( - name: packageName, - initMode: dependencyKind, - templatePath: resolvedTemplatePath, - fileSystem: swiftCommandState.fileSystem, - packageType: templateInitType, - supportedTestingLibraries: supportedTemplateTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration - ) - - try initTemplatePackage.setupTemplateManifest() - - try await TemplateBuildSupport.build( - swiftCommandState: swiftCommandState, - buildOptions: self.buildOptions, - globalOptions: self.globalOptions, - cwd: cwd - ) - - let packageGraph = try await swiftCommandState.loadPackageGraph() - let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: packageGraph, limitedTo: nil) - - guard let commandPlugin = matchingPlugins.first else { - guard let template = self.template - else { throw ValidationError("No templates were found in \(packageName)") } - - throw ValidationError("No templates were found that match the name \(template)") - } - - guard matchingPlugins.count == 1 else { - let templateNames = matchingPlugins.compactMap { module in - let plugin = module.underlying as! PluginModule - guard case .command(let intent, _) = plugin.capability else { return String?.none } - - return intent.invocationVerb - } - throw ValidationError( - "More than one template was found in the package. Please use `--template` to select from one of the available templates: \(templateNames.joined(separator: ", "))" - ) - } - - let output = try await TemplatePluginRunner.run( - plugin: commandPlugin, - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: ["--", "--experimental-dump-help"], - swiftCommandState: swiftCommandState - ) - - let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: args) - - do { - let _ = try await TemplatePluginRunner.run( - plugin: matchingPlugins[0], - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: response, - swiftCommandState: swiftCommandState - ) - } - } - - /// Validates the loaded manifest to determine package type. - private func checkConditions(_ swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType { - let workspace = try swiftCommandState.getActiveWorkspace() - let root = try swiftCommandState.getWorkspaceRoot() - - let rootManifests = try await workspace.loadRootManifests( - packages: root.packages, - observabilityScope: swiftCommandState.observabilityScope - ) - guard let rootManifest = rootManifests.values.first else { - throw InternalError("invalid manifests at \(root.packages)") - } - - let products = rootManifest.products - let targets = rootManifest.targets - - for _ in products { - if let target: TargetDescription = targets.first(where: { template == nil || $0.name == template }) { - if let options = target.templateInitializationOptions { - if case .packageInit(let templateType, _, _) = options { - return try .init(from: templateType) - } - } - } - } - throw InternalError( - "Could not find \(self.template != nil ? "template \(self.template!)" : "any templates in the package")" - ) - } - public init() {} - - } -} - -extension InitPackage.PackageType { - init(from templateType: TargetDescription.TemplateType) throws { - switch templateType { - case .executable: - self = .executable - case .library: - self = .library - case .tool: - self = .tool - case .macro: - self = .macro - case .buildToolPlugin: - self = .buildToolPlugin - case .commandPlugin: - self = .commandPlugin - case .empty: - self = .empty - } - } -} diff --git a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift index 99e05fb24c4..6aed2971cc8 100644 --- a/Sources/Commands/PackageCommands/SwiftPackageCommand.swift +++ b/Sources/Commands/PackageCommands/SwiftPackageCommand.swift @@ -45,7 +45,6 @@ public struct SwiftPackageCommand: AsyncParsableCommand { Update.self, Describe.self, Init.self, - Scaffold.self, Format.self, Migrate.self, diff --git a/Sources/Workspace/InitPackage.swift b/Sources/Workspace/InitPackage.swift index 07224aeb17f..ea5576f5c9a 100644 --- a/Sources/Workspace/InitPackage.swift +++ b/Sources/Workspace/InitPackage.swift @@ -55,7 +55,6 @@ public final class InitPackage { case buildToolPlugin = "build-tool-plugin" case commandPlugin = "command-plugin" case macro = "macro" - public var description: String { return rawValue } From 017423b30590f4016aaa1115d76728a1613c0168 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 15 Jul 2025 16:35:56 -0400 Subject: [PATCH 192/225] prompting improvements --- Sources/Commands/PackageCommands/Init.swift | 32 ++-- Sources/Workspace/InitTemplatePackage.swift | 188 +++++++++++++++++--- 2 files changed, 177 insertions(+), 43 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index b4b56ea3826..7ab40e2dbbb 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -78,11 +78,6 @@ extension SwiftPackageCommand { } } - - // - // - // - // /// Path to a local template. @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) var templateDirectory: Basics.AbsolutePath? @@ -313,18 +308,21 @@ extension SwiftPackageCommand { ) let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let response = try initTemplatePackage.promptUser(tool: toolInfo, arguments: args) - - do { - let _ = try await TemplatePluginRunner.run( - plugin: matchingPlugins[0], - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: response, - swiftCommandState: swiftCommandState - ) - } - } + let cliResponses = try initTemplatePackage.promptUser(command: toolInfo.command, arguments: args) + + for response in cliResponses { + print(response) + do { + let _ = try await TemplatePluginRunner.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: response, + swiftCommandState: swiftCommandState + ) + } + }} + /// Validates the loaded manifest to determine package type. private func checkConditions(_ swiftCommandState: SwiftCommandState, template: String?) async throws -> InitPackage.PackageType { diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index a44e0d68d61..53e7887ed1d 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -192,27 +192,160 @@ public final class InitTemplatePackage { ) } - /// Prompts the user for input based on the given tool information. + /// Prompts the user for input based on the given command definition and arguments. /// - /// This method converts the command arguments of the tool into prompt questions, - /// collects user input, and builds a command line argument array from the responses. + /// This method collects responses for a command's arguments by first validating any user-provided + /// arguments (`arguments`) against the command's defined parameters. Any required arguments that are + /// missing will be interactively prompted from the user. /// - /// - Parameter tool: The tool information containing command and argument metadata. - /// - Returns: An array of strings representing the command line arguments built from user input. - /// - Throws: `TemplateError.noArguments` if the tool command has no arguments. + /// If the command has subcommands, the method will attempt to detect a subcommand from any leftover + /// arguments. If no subcommand is found, the user is interactively prompted to select one. This process + /// is recursive: each subcommand is treated as a new command and processed accordingly. + /// + /// When building each CLI command line, only arguments defined for the current command level are included— + /// inherited arguments from previous levels are excluded to avoid duplication. + /// + /// - Parameters: + /// - command: The top-level or current `CommandInfoV0` to prompt for. + /// - arguments: The list of pre-supplied command-line arguments to match against defined arguments. + /// - subcommandTrail: An internal list of command names to build the final CLI path (used recursively). + /// - inheritedResponses: Argument responses collected from parent commands that should be passed down. + /// + /// - Returns: A list of command line invocations (`[[String]]`), each representing a full CLI command. + /// Each entry includes only arguments relevant to the specific command or subcommand level. + /// + /// - Throws: An error if argument parsing or user prompting fails. - public func promptUser(tool: ToolInfoV0, arguments: [String]) throws -> [String] { - let allArgs = try convertArguments(from: tool.command) + public func promptUser(command: CommandInfoV0, arguments: [String], subcommandTrail: [String] = [], inheritedResponses: [ArgumentResponse] = []) throws -> [[String]] { - let providedResponses = try self.parseAndMatchArguments(arguments, definedArgs: allArgs) + var commandLines = [[String]]() - let missingArgs = self.findMissingArguments(from: allArgs, excluding: providedResponses) + let allArgs = try convertArguments(from: command) + let (providedResponses, leftoverArgs) = try self.parseAndMatchArgumentsWithLeftovers(arguments, definedArgs: allArgs) + let missingArgs = self.findMissingArguments(from: allArgs, excluding: providedResponses) let promptedResponses = UserPrompter.prompt(for: missingArgs) - return self.buildCommandLine(from: providedResponses + promptedResponses) + // Combine all inherited + current-level responses + let allCurrentResponses = inheritedResponses + providedResponses + promptedResponses + + let currentArgNames = Set(allArgs.map { $0.valueName }) + let currentCommandResponses = allCurrentResponses.filter { currentArgNames.contains($0.argument.valueName) } + + let currentArgs = self.buildCommandLine(from: currentCommandResponses) + let fullCommand = subcommandTrail + currentArgs + + commandLines.append(fullCommand) + + + if let subCommands = getSubCommand(from: command) { + // Try to auto-detect a subcommand from leftover args + if let (index, matchedSubcommand) = leftoverArgs + .enumerated() + .compactMap({ (i, token) -> (Int, CommandInfoV0)? in + if let match = subCommands.first(where: { $0.commandName == token }) { + print("Detected subcommand '\(match.commandName)' from user input.") + return (i, match) + } + return nil + }) + .first { + + var newTrail = subcommandTrail + newTrail.append(matchedSubcommand.commandName) + + var newArgs = leftoverArgs + newArgs.remove(at: index) + + let subCommandLines = try self.promptUser( + command: matchedSubcommand, + arguments: newArgs, + subcommandTrail: newTrail, + inheritedResponses: allCurrentResponses + ) + + commandLines.append(contentsOf: subCommandLines) + } else { + // Fall back to interactive prompt + let chosenSubcommand = try self.promptUserForSubcommand(for: subCommands) + + var newTrail = subcommandTrail + newTrail.append(chosenSubcommand.commandName) + + let subCommandLines = try self.promptUser( + command: chosenSubcommand, + arguments: leftoverArgs, + subcommandTrail: newTrail, + inheritedResponses: allCurrentResponses + ) + + commandLines.append(contentsOf: subCommandLines) + } + } + + return commandLines } + /// Prompts the user to select a subcommand from a list of available options. + /// + /// This method prints a list of available subcommands, including their names and brief descriptions. + /// It then interactively prompts the user to enter the name of a subcommand. If the entered name + /// matches one of the available subcommands, that subcommand is returned. Otherwise, the user is + /// repeatedly prompted until a valid subcommand name is provided. + /// + /// - Parameter commands: An array of `CommandInfoV0` representing the available subcommands. + /// + /// - Returns: The `CommandInfoV0` instance corresponding to the subcommand selected by the user. + /// + /// - Throws: This method does not throw directly, but may propagate errors thrown by downstream callers. + + private func promptUserForSubcommand(for commands: [CommandInfoV0]) throws -> CommandInfoV0 { + + print("Available subcommands:\n") + + for command in commands { + print(""" + • \(command.commandName) + Name: \(command.commandName) + About: \(command.abstract ?? "") + + """) + } + + print("Type the name of the subcommand you'd like to use:") + while true { + if let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), !input.isEmpty { + if let match = commands.first(where: { $0.commandName == input }) { + return match + } else { + print("No subcommand found with name '\(input)'. Please try again:") + } + } else { + print("Please enter a valid subcommand name:") + } + } + } + + /// Retrieves the list of subcommands for a given command, excluding common utility commands. + /// + /// This method checks whether the given command contains any subcommands. If so, it filters + /// out the `"help"` subcommand (often auto-generated or reserved), and returns the remaining + /// subcommands. + /// + /// - Parameter command: The `CommandInfoV0` instance representing the current command. + /// + /// - Returns: An array of `CommandInfoV0` representing valid subcommands, or `nil` if no subcommands exist. + private func getSubCommand(from command: CommandInfoV0) -> [CommandInfoV0]? { + guard let subcommands = command.subcommands else { return nil } + + let filteredSubcommands = subcommands.filter { $0.commandName.lowercased() != "help" } + + for sub in filteredSubcommands { + print(sub.commandName) + } + + return filteredSubcommands + } /// Parses predetermined arguments and validates the arguments /// /// This method converts user's predetermined arguments into the ArgumentResponse struct @@ -224,12 +357,13 @@ public final class InitTemplatePackage { /// - Throws: Invalid values if the value is not within all the possible values allowed by the argument /// - Throws: Throws an unexpected argument if the user specifies an argument that does not match any arguments /// defined by the template. - private func parseAndMatchArguments( + private func parseAndMatchArgumentsWithLeftovers( _ input: [String], definedArgs: [ArgumentInfoV0] - ) throws -> [ArgumentResponse] { + ) throws -> ([ArgumentResponse], [String]) { var responses: [ArgumentResponse] = [] var providedMap: [String: [String]] = [:] + var leftover: [String] = [] var index = 0 while index < input.count { @@ -238,7 +372,14 @@ public final class InitTemplatePackage { if token.starts(with: "--") { let name = String(token.dropFirst(2)) guard let arg = definedArgs.first(where: { $0.valueName == name }) else { - throw TemplateError.invalidArgument(name: name) + // Unknown — defer for potential subcommand + leftover.append(token) + index += 1 + if index < input.count && !input[index].starts(with: "--") { + leftover.append(input[index]) + index += 1 + } + continue } switch arg.kind { @@ -254,8 +395,8 @@ public final class InitTemplatePackage { throw TemplateError.unexpectedNamedArgument(name: name) } } else { - // Positional handling - providedMap["__positional", default: []].append(token) + // Positional, include it anyway + leftover.append(token) } index += 1 @@ -282,11 +423,7 @@ public final class InitTemplatePackage { providedMap[name] = nil } - for unexpected in providedMap.keys { - throw TemplateError.unexpectedArgument(name: unexpected) - } - - return responses + return (responses, leftover) } /// Determines the rest of the arguments that need a user's response @@ -379,7 +516,7 @@ public final class InitTemplatePackage { } else if let def = arg.defaultValue { values = [def] } else if arg.isOptional == false { - fatalError("Required argument '\(arg.valueName)' not provided.") + fatalError("Required argument '\(arg.valueName ?? "")' not provided.") } } } @@ -421,15 +558,14 @@ public final class InitTemplatePackage { /// Represents a user's response to an argument prompt. - struct ArgumentResponse { + public struct ArgumentResponse { /// The argument metadata. - let argument: ArgumentInfoV0 - /// The values provided by the user. + /// The values provided by the user. let values: [String] - /// Returns the command line fragments representing this argument and its values. + /// Returns the command line fragments representing this argument and its values. var commandLineFragments: [String] { guard let name = argument.valueName else { return self.values From b08394883479099fd8bc4bb22c0e94b724447806 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 22 Jul 2025 13:59:06 -0400 Subject: [PATCH 193/225] added validate package, first-class testing support, temporary packages --- Sources/Commands/PackageCommands/Init.swift | 90 +++- Sources/Commands/SwiftTestCommand.swift | 391 +++++++++++++++++- .../_InternalInitSupport/TemplateBuild.swift | 59 ++- Sources/Workspace/InitPackage.swift | 10 +- Sources/Workspace/InitTemplatePackage.swift | 43 +- 5 files changed, 553 insertions(+), 40 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 7ab40e2dbbb..43695e19e27 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -37,7 +37,9 @@ extension SwiftPackageCommand { @Option( name: .customLong("type"), - help: ArgumentHelp("Package type:", discussion: """ + help: ArgumentHelp("Specifies the package type or template.", discussion: """ + Valid values include: + library - A package with a library. executable - A package with an executable. tool - A package with an executable that uses @@ -47,6 +49,9 @@ extension SwiftPackageCommand { command-plugin - A package that vends a command plugin. macro - A package that vends a macro. empty - An empty package with a Package.swift manifest. + - When used with --path, --url, or --package-id, + this resolves to a template from the specified + package or location. """)) var initMode: String? @@ -116,6 +121,10 @@ extension SwiftPackageCommand { @Option(help: "Specify upper bound on the package version range (exclusive).") var to: Version? + /// Validation step to build package post generation and run if package is of type executable + @Flag(name: .customLong("validate-package"), help: "Run 'swift build' after package generation to validate the template.") + var validatePackage: Bool = false + /// Predetermined arguments specified by the consumer. @Argument( help: "Predetermined arguments to pass to the template." @@ -192,12 +201,23 @@ extension SwiftPackageCommand { packageName: String, cwd: Basics.AbsolutePath ) async throws { - - let template = initMode guard let source = templateSource else { throw ValidationError("No template source specified.") } + let manifest = cwd.appending(component: Manifest.filename) + guard swiftCommandState.fileSystem.exists(manifest) == false else { + throw InitError.manifestAlreadyExists + } + + + let contents = try swiftCommandState.fileSystem.getDirectoryContents(cwd) + + guard contents.isEmpty else { + throw InitError.nonEmptyDirectory(contents) + } + + let template = initMode let requirementResolver = DependencyRequirementResolver( exact: exact, revision: revision, @@ -227,6 +247,17 @@ extension SwiftPackageCommand { throw ValidationError("The specified template path does not exist: \(dir.pathString)") } + // Use a transitive staging directory + let tempDir = try swiftCommandState.fileSystem.tempDirectory.appending(component: UUID().uuidString) + let stagingPackagePath = tempDir.appending(component: "generated-package") + let buildDir = tempDir.appending(component: ".build") + + try swiftCommandState.fileSystem.createDirectory(tempDir) + defer { + try? swiftCommandState.fileSystem.removeFileTree(tempDir) + } + + // Determine the type by loading the resolved template let templateInitType = try await swiftCommandState .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in try await self.checkConditions(swiftCommandState, template: template) @@ -264,20 +295,29 @@ extension SwiftPackageCommand { fileSystem: swiftCommandState.fileSystem, packageType: templateInitType, supportedTestingLibraries: supportedTemplateTestingLibraries, - destinationPath: cwd, + destinationPath: stagingPackagePath, installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration ) + try swiftCommandState.fileSystem.createDirectory(stagingPackagePath, recursive: true) + try initTemplatePackage.setupTemplateManifest() + // Build once inside the transitive folder try await TemplateBuildSupport.build( swiftCommandState: swiftCommandState, buildOptions: self.buildOptions, globalOptions: self.globalOptions, - cwd: cwd + cwd: stagingPackagePath, + transitiveFolder: stagingPackagePath ) - let packageGraph = try await swiftCommandState.loadPackageGraph() + let packageGraph = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: stagingPackagePath) { _, _ in + try await swiftCommandState.loadPackageGraph() + } + + let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) guard let commandPlugin = matchingPlugins.first else { @@ -311,17 +351,33 @@ extension SwiftPackageCommand { let cliResponses = try initTemplatePackage.promptUser(command: toolInfo.command, arguments: args) for response in cliResponses { - print(response) - do { - let _ = try await TemplatePluginRunner.run( - plugin: matchingPlugins[0], - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: response, - swiftCommandState: swiftCommandState - ) - } - }} + _ = try await TemplatePluginRunner.run( + plugin: matchingPlugins[0], + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: response, + swiftCommandState: swiftCommandState + ) + } + + // Move finalized package to target cwd + if swiftCommandState.fileSystem.exists(cwd) { + try swiftCommandState.fileSystem.removeFileTree(cwd) + } + try swiftCommandState.fileSystem.copy(from: stagingPackagePath, to: cwd) + + // Restore cwd for build + + if validatePackage { + try await TemplateBuildSupport.build( + swiftCommandState: swiftCommandState, + buildOptions: self.buildOptions, + globalOptions: self.globalOptions, + cwd: cwd + ) + } + } + /// Validates the loaded manifest to determine package type. diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index 669f04dab3b..2ce2ed30935 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -11,6 +11,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser +import ArgumentParserToolInfo @_spi(SwiftPMInternal) import Basics @@ -261,7 +262,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand { discussion: "SEE ALSO: swift build, swift run, swift package", version: SwiftVersion.current.completeDisplayString, subcommands: [ - List.self, Last.self + List.self, Last.self, Template.self ], helpNames: [.short, .long, .customLong("help", withSingleDash: true)]) @@ -709,6 +710,143 @@ extension SwiftTestCommand { } } +final class ArgumentTreeNode { + let command: CommandInfoV0 + var children: [ArgumentTreeNode] = [] + + var arguments: [String: InitTemplatePackage.ArgumentResponse] = [:] + + init(command: CommandInfoV0) { + self.command = command + } + + static func build(from command: CommandInfoV0) -> ArgumentTreeNode { + let node = ArgumentTreeNode(command: command) + if let subcommands = command.subcommands { + node.children = subcommands.map { build(from: $0) } + } + return node + } + + func collectUniqueArguments() -> [String: ArgumentInfoV0] { + var dict: [String: ArgumentInfoV0] = [:] + if let args = command.arguments { + for arg in args { + let key = arg.preferredName?.name ?? arg.valueName ?? UUID().uuidString + dict[key] = arg + } + } + for child in children { + let childDict = child.collectUniqueArguments() + for (key, arg) in childDict { + dict[key] = arg + } + } + return dict + } + + + static func promptForUniqueArguments( + uniqueArguments: [String: ArgumentInfoV0] + ) -> [String: InitTemplatePackage.ArgumentResponse] { + var collected: [String: InitTemplatePackage.ArgumentResponse] = [:] + let argsToPrompt = Array(uniqueArguments.values) + + // Prompt for all unique arguments at once + _ = InitTemplatePackage.UserPrompter.prompt(for: argsToPrompt, collected: &collected) + + return collected + } + + //Fill node arguments by assigning the prompted values for keys it requires + func fillArguments(with responses: [String: InitTemplatePackage.ArgumentResponse]) { + if let args = command.arguments { + for arg in args { + if let resp = responses[arg.valueName ?? ""] { + arguments[arg.valueName ?? ""] = resp + } + } + } + // Recurse + for child in children { + child.fillArguments(with: responses) + } + } + + func printTree(level: Int = 0) { + let indent = String(repeating: " ", count: level) + print("\(indent)- Command: \(command.commandName)") + for (key, response) in arguments { + print("\(indent) - \(key): \(response.values)") + } + for child in children { + child.printTree(level: level + 1) + } + } + + func createCLITree(root: ArgumentTreeNode) -> [[ArgumentTreeNode]] { + // Base case: If it's a leaf node, return a path with only itself + if root.children.isEmpty { + return [[root]] + } + + var result: [[ArgumentTreeNode]] = [] + + // Recurse into children and prepend the current root to each path + for child in root.children { + let childPaths = createCLITree(root: child) + for path in childPaths { + result.append([root] + path) + } + } + + return result + } +} + +extension ArgumentTreeNode { + /// Traverses all command paths and returns CLI paths along with their arguments + func collectCommandPaths( + currentPath: [String] = [], + currentArguments: [String: InitTemplatePackage.ArgumentResponse] = [:] + ) -> [([String], [String: InitTemplatePackage.ArgumentResponse])] { + var newPath = currentPath + [command.commandName] + + var combinedArguments = currentArguments + for (key, value) in arguments { + combinedArguments[key] = value + } + + if children.isEmpty { + return [(newPath, combinedArguments)] + } + + var results: [([String], [String: InitTemplatePackage.ArgumentResponse])] = [] + for child in children { + results += child.collectCommandPaths( + currentPath: newPath, + currentArguments: combinedArguments + ) + } + + return results + } +} + +extension DispatchTimeInterval { + var seconds: TimeInterval { + switch self { + case .seconds(let s): return TimeInterval(s) + case .milliseconds(let ms): return TimeInterval(Double(ms) / 1000) + case .microseconds(let us): return TimeInterval(Double(us) / 1_000_000) + case .nanoseconds(let ns): return TimeInterval(Double(ns) / 1_000_000_000) + case .never: return 0 + @unknown default: return 0 + } + } +} + + extension SwiftTestCommand { struct Last: SwiftCommand { @OptionGroup(visibility: .hidden) @@ -722,6 +860,257 @@ extension SwiftTestCommand { } } + struct Template: AsyncSwiftCommand { + static let configuration = CommandConfiguration( + abstract: "Test the various outputs of a template" + ) + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + @OptionGroup() + var sharedOptions: SharedOptions + + @Option(help: "Specify name of the template") + var templateName: String? + + @Option( + name: .customLong("output-path"), + help: "Specify the output path of the created templates.", + completion: .directory + ) + public var outputDirectory: AbsolutePath + + @OptionGroup(visibility: .hidden) + var buildOptions: BuildCommandOptions + + /// Predetermined arguments specified by the consumer. + @Argument( + help: "Predetermined arguments to pass to the template." + ) + var args: [String] = [] + + @Flag(help: "Dry-run to display argument tree") + var dryRun: Bool = false + + + func run(_ swiftCommandState: SwiftCommandState) async throws { + let manifest = outputDirectory.appending(component: Manifest.filename) + let fileSystem = swiftCommandState.fileSystem + let directoryExists = fileSystem.exists(outputDirectory) + + if !directoryExists { + try FileManager.default.createDirectory( + at: outputDirectory.asURL, + withIntermediateDirectories: true + ) + } else { + if fileSystem.exists(manifest) { + throw ValidationError("Package.swift was found in \(outputDirectory).") + } + } + + // Load Package Graph + let packageGraph = try await swiftCommandState.loadPackageGraph() + + // Find matching plugin + let matchingPlugins = PluginCommand.findPlugins(matching: templateName, in: packageGraph, limitedTo: nil) + guard let commandPlugin = matchingPlugins.first else { + throw ValidationError("No templates were found that match the name \(templateName ?? "")") + } + + guard matchingPlugins.count == 1 else { + let templateNames = matchingPlugins.compactMap { module in + let plugin = module.underlying as! PluginModule + guard case .command(let intent, _) = plugin.capability else { return String?.none } + + return intent.invocationVerb + } + throw ValidationError( + "More than one template was found in the package. Please use `--type` along with one of the available templates: \(templateNames.joined(separator: ", "))" + ) + } + + let output = try await TemplatePluginRunner.run( + plugin: commandPlugin, + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: ["--", "--experimental-dump-help"], + swiftCommandState: swiftCommandState + ) + + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + let root = ArgumentTreeNode.build(from: toolInfo.command) + + let uniqueArguments = root.collectUniqueArguments() + let responses = ArgumentTreeNode.promptForUniqueArguments(uniqueArguments: uniqueArguments) + root.fillArguments(with: responses) + + if dryRun { + root.printTree() + return + } + + let cliArgumentPaths = root.createCLITree(root: root) // [[ArgumentTreeNode]] + let commandPaths = root.collectCommandPaths() + + func checkConditions(_ swiftCommandState: SwiftCommandState, template: String?) async throws -> InitPackage.PackageType { + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() + + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope + ) + guard let rootManifest = rootManifests.values.first else { + throw InternalError("Invalid manifests at \(root.packages)") + } + + let targets = rootManifest.targets + for target in targets { + if template == nil || target.name == template, + let options = target.templateInitializationOptions, + case .packageInit(let templateType, _, _) = options { + return try .init(from: templateType) + } + } + + throw ValidationError("Could not find \(template != nil ? "template \(template!)" : "any templates")") + } + + let initialPackageType: InitPackage.PackageType = try await checkConditions(swiftCommandState, template: templateName) + + var buildMatrix: [String: BuildInfo] = [:] + + for path in cliArgumentPaths { + let commandNames = path.map { $0.command.commandName } + let folderName = commandNames.joined(separator: "-") + let destinationAbsolutePath = outputDirectory.appending(component: folderName) + let destinationURL = destinationAbsolutePath.asURL + + print("\n Generating for: \(folderName)") + try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + let initTemplatePackage = try InitTemplatePackage( + name: folderName, + initMode: .fileSystem(name: templateName, path: swiftCommandState.originalWorkingDirectory.pathString), + templatePath: swiftCommandState.originalWorkingDirectory, + fileSystem: swiftCommandState.fileSystem, + packageType: initialPackageType, + supportedTestingLibraries: [], + destinationPath: destinationAbsolutePath, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration + ) + + try initTemplatePackage.setupTemplateManifest() + + + let generatedGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in + try await swiftCommandState.loadPackageGraph() + } + + try await TemplateBuildSupport.buildForTesting( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + testingFolder: destinationAbsolutePath + ) + + for (index, node) in path.enumerated() { + let currentPath = index == 0 ? [] : path[1...index].map { $0.command.commandName } + let currentArgs = node.arguments.values.flatMap { $0.commandLineFragments } + let fullCommand = currentPath + currentArgs + let commandString = fullCommand.joined(separator: " ") + + print("Running: \(commandString)") + + do { + try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath, perform: {_,_ in print(String(data: try await TemplatePluginRunner.run( + plugin: commandPlugin, + package: generatedGraph.rootPackages.first!, + packageGraph: generatedGraph, + arguments: fullCommand, + swiftCommandState: swiftCommandState + ), encoding: .utf8) ?? "") + + }) + + print("Success: \(commandString)") + } catch { + print("Failed: \(commandString) — error: \(error)") + } + } + + //Test for building + + let buildInfo = await buildGeneratedTemplate(swiftCommandState: swiftCommandState, buildOptions: buildOptions, testingFolder: destinationAbsolutePath) + buildMatrix[folderName] = buildInfo + + + } + + printBuildMatrix(buildMatrix) + + func printBuildMatrix(_ matrix: [String: BuildInfo]) { + // Print header with manual padding + let header = [ + "Argument Branch".padding(toLength: 30, withPad: " ", startingAt: 0), + "Success".padding(toLength: 10, withPad: " ", startingAt: 0), + "Time(s)".padding(toLength: 10, withPad: " ", startingAt: 0), + "Error" + ] + print(header.joined(separator: " ")) + + for (folder, info) in matrix { + let duration = String(format: "%.2f", info.duration.seconds) + let status = info.success ? "true" : "false" + let errorText = info.error?.localizedDescription ?? "-" + + let row = [ + folder.padding(toLength: 30, withPad: " ", startingAt: 0), + status.padding(toLength: 10, withPad: " ", startingAt: 0), + duration.padding(toLength: 10, withPad: " ", startingAt: 0), + errorText + ] + print(row.joined(separator: " ")) + } + } + } + + + struct BuildInfo { + var startTime: DispatchTime + var duration: DispatchTimeInterval + var success: Bool + var error: Error? + } + + private func buildGeneratedTemplate(swiftCommandState: SwiftCommandState, buildOptions: BuildCommandOptions, testingFolder: AbsolutePath) async -> BuildInfo { + var buildInfo: BuildInfo + + let start = DispatchTime.now() + + do { + try await + TemplateBuildSupport.buildForTesting( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + testingFolder: testingFolder + ) + + let duration = start.distance(to: .now()) + buildInfo = BuildInfo(startTime: start, duration: duration, success: true, error: nil) + } catch { + let duration = start.distance(to: .now()) + + buildInfo = BuildInfo(startTime: start, duration: duration, success: false, error: error) + } + + return buildInfo + } + } + + + struct List: AsyncSwiftCommand { static let configuration = CommandConfiguration( abstract: "Lists test methods in specifier format" diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift index e8b8c4212d9..043a89dd856 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -46,10 +46,14 @@ enum TemplateBuildSupport { swiftCommandState: SwiftCommandState, buildOptions: BuildCommandOptions, globalOptions: GlobalOptions, - cwd: Basics.AbsolutePath + cwd: Basics.AbsolutePath, + transitiveFolder: Basics.AbsolutePath? = nil ) async throws { + + let buildSystem = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + .withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in + try await swiftCommandState.createBuildSystem( explicitProduct: buildOptions.product, traitConfiguration: .init(traitOptions: buildOptions.traits), @@ -64,8 +68,10 @@ enum TemplateBuildSupport { throw ExitCode.failure } + + try await swiftCommandState - .withTemporaryWorkspace(switchingTo: globalOptions.locations.packageDirectory ?? cwd) { _, _ in + .withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in do { try await buildSystem.build(subset: subset) } catch _ as Diagnostics { @@ -73,4 +79,51 @@ enum TemplateBuildSupport { } } } + + static func buildForTesting( + swiftCommandState: SwiftCommandState, + buildOptions: BuildCommandOptions, + testingFolder: Basics.AbsolutePath + ) async throws { + + var productsBuildParameters = try swiftCommandState.productsBuildParameters + var toolsBuildParameters = try swiftCommandState.toolsBuildParameters + + if buildOptions.enableCodeCoverage { + productsBuildParameters.testingParameters.enableCodeCoverage = true + toolsBuildParameters.testingParameters.enableCodeCoverage = true + } + + if buildOptions.printPIFManifestGraphviz { + productsBuildParameters.printPIFManifestGraphviz = true + toolsBuildParameters.printPIFManifestGraphviz = true + } + + + let buildSystem = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: testingFolder) { _, _ in + try await swiftCommandState.createBuildSystem( + explicitProduct: buildOptions.product, + traitConfiguration: .init(traitOptions: buildOptions.traits), + shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: swiftCommandState.toolsBuildParameters, + outputStream: TSCBasic.stdoutStream + ) + } + + guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { + throw ExitCode.failure + } + + try await swiftCommandState + .withTemporaryWorkspace(switchingTo: testingFolder) { _, _ in + do { + try await buildSystem.build(subset: subset) + } catch _ as Diagnostics { + throw ExitCode.failure + } + } + } + } diff --git a/Sources/Workspace/InitPackage.swift b/Sources/Workspace/InitPackage.swift index ea5576f5c9a..22b34551efd 100644 --- a/Sources/Workspace/InitPackage.swift +++ b/Sources/Workspace/InitPackage.swift @@ -20,7 +20,8 @@ import protocol TSCBasic.OutputByteStream /// Create an initial template package. public final class InitPackage { /// The tool version to be used for new packages. - public static let newPackageToolsVersion = ToolsVersion.current + public static let newPackageToolsVersion = ToolsVersion.v6_1 //TODO: JOHN CHANGE ME BACK TO: + // - public static let newPackageToolsVersion = ToolsVersion.current /// Options for the template package. public struct InitPackageOptions { @@ -913,18 +914,21 @@ public final class InitPackage { // Private helpers -private enum InitError: Swift.Error { +public enum InitError: Swift.Error { case manifestAlreadyExists case unsupportedTestingLibraryForPackageType(_ testingLibrary: TestingLibrary, _ packageType: InitPackage.PackageType) + case nonEmptyDirectory(_ content: [String]) } extension InitError: CustomStringConvertible { - var description: String { + public var description: String { switch self { case .manifestAlreadyExists: return "a manifest file already exists in this directory" case let .unsupportedTestingLibraryForPackageType(library, packageType): return "\(library) cannot be used when initializing a \(packageType) package" + case let .nonEmptyDirectory(content): + return "directory is not empty: \(content.joined(separator: ", "))" } } } diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 53e7887ed1d..8c4898dec6e 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -224,7 +224,9 @@ public final class InitTemplatePackage { let (providedResponses, leftoverArgs) = try self.parseAndMatchArgumentsWithLeftovers(arguments, definedArgs: allArgs) let missingArgs = self.findMissingArguments(from: allArgs, excluding: providedResponses) - let promptedResponses = UserPrompter.prompt(for: missingArgs) + + var collectedResponses: [String: ArgumentResponse] = [:] + let promptedResponses = UserPrompter.prompt(for: missingArgs, collected: &collectedResponses) // Combine all inherited + current-level responses let allCurrentResponses = inheritedResponses + providedResponses + promptedResponses @@ -462,19 +464,29 @@ public final class InitTemplatePackage { /// A helper struct to prompt the user for input values for command arguments. - private enum UserPrompter { + public enum UserPrompter { /// Prompts the user for input for each argument, handling flags, options, and positional arguments. /// /// - Parameter arguments: The list of argument metadata to prompt for. /// - Returns: An array of `ArgumentResponse` representing the user's input. - static func prompt(for arguments: [ArgumentInfoV0]) -> [ArgumentResponse] { - arguments - .filter { $0.valueName != "help" && $0.shouldDisplay != false } - .map { arg in + public static func prompt( + for arguments: [ArgumentInfoV0], + collected: inout [String: ArgumentResponse] + ) -> [ArgumentResponse] { + return arguments + .filter { $0.valueName != "help" && $0.shouldDisplay } + .compactMap { arg in + let key = arg.preferredName?.name ?? arg.valueName ?? UUID().uuidString + + if let existing = collected[key] { + print("Using previous value for '\(key)': \(existing.values.joined(separator: ", "))") + return existing + } + let defaultText = arg.defaultValue.map { " (default: \($0))" } ?? "" let allValuesText = (arg.allValues?.isEmpty == false) ? - " [\(arg.allValues!.joined(separator: ", "))]" : "" + " [\(arg.allValues!.joined(separator: ", "))]" : "" let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(defaultText):" var values: [String] = [] @@ -493,9 +505,7 @@ public final class InitTemplatePackage { if arg.isRepeating { while let input = readLine(), !input.isEmpty { if let allowed = arg.allValues, !allowed.contains(input) { - print( - "Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))" - ) + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") continue } values.append(input) @@ -507,9 +517,7 @@ public final class InitTemplatePackage { let input = readLine() if let input, !input.isEmpty { if let allowed = arg.allValues, !allowed.contains(input) { - print( - "Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))" - ) + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") exit(1) } values = [input] @@ -521,8 +529,11 @@ public final class InitTemplatePackage { } } - return ArgumentResponse(argument: arg, values: values) + let response = ArgumentResponse(argument: arg, values: values) + collected[key] = response + return response } + } } @@ -563,10 +574,10 @@ public final class InitTemplatePackage { let argument: ArgumentInfoV0 /// The values provided by the user. - let values: [String] + public let values: [String] /// Returns the command line fragments representing this argument and its values. - var commandLineFragments: [String] { + public var commandLineFragments: [String] { guard let name = argument.valueName else { return self.values } From 2e66044fa4a804016b2ee8a75bf9b5d5c5e6a8e0 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 23 Jul 2025 14:23:14 -0400 Subject: [PATCH 194/225] added testing logs + revamped testing system for templates --- Sources/Commands/SwiftTestCommand.swift | 305 +++++++++++++----- .../Commands/Utilities/PluginDelegate.swift | 4 + .../TemplatePluginRunner.swift | 14 +- 3 files changed, 245 insertions(+), 78 deletions(-) diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index 2ce2ed30935..15cdc11ebd2 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -914,9 +914,9 @@ extension SwiftTestCommand { let packageGraph = try await swiftCommandState.loadPackageGraph() // Find matching plugin - let matchingPlugins = PluginCommand.findPlugins(matching: templateName, in: packageGraph, limitedTo: nil) + let matchingPlugins = PluginCommand.findPlugins(matching: self.templateName, in: packageGraph, limitedTo: nil) guard let commandPlugin = matchingPlugins.first else { - throw ValidationError("No templates were found that match the name \(templateName ?? "")") + throw ValidationError("No templates were found that match the name \(self.templateName ?? "")") } guard matchingPlugins.count == 1 else { @@ -951,7 +951,7 @@ extension SwiftTestCommand { return } - let cliArgumentPaths = root.createCLITree(root: root) // [[ArgumentTreeNode]] + let cliArgumentPaths = root.createCLITree(root: root) let commandPaths = root.collectCommandPaths() func checkConditions(_ swiftCommandState: SwiftCommandState, template: String?) async throws -> InitPackage.PackageType { @@ -988,11 +988,98 @@ extension SwiftTestCommand { let destinationAbsolutePath = outputDirectory.appending(component: folderName) let destinationURL = destinationAbsolutePath.asURL - print("\n Generating for: \(folderName)") + print("\nGenerating \(folderName)") try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true) + + let buildInfo = try await testTemplateIntialization( + commandPlugin: commandPlugin, + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + destinationAbsolutePath: destinationAbsolutePath, + testingFolderName: folderName, + argumentPath: path, + initialPackageType: initialPackageType + ) + + buildMatrix[folderName] = buildInfo + + + } + + printBuildMatrix(buildMatrix) + + func printBuildMatrix(_ matrix: [String: BuildInfo]) { + let header = [ + "Argument Branch".padding(toLength: 30, withPad: " ", startingAt: 0), + "Gen Success".padding(toLength: 12, withPad: " ", startingAt: 0), + "Gen Time(s)".padding(toLength: 12, withPad: " ", startingAt: 0), + "Build Success".padding(toLength: 14, withPad: " ", startingAt: 0), + "Build Time(s)".padding(toLength: 14, withPad: " ", startingAt: 0), + "Log File" + ] + print(header.joined(separator: " ")) + + for (folder, info) in matrix { + let row = [ + folder.padding(toLength: 30, withPad: " ", startingAt: 0), + String(info.generationSuccess).padding(toLength: 12, withPad: " ", startingAt: 0), + String(format: "%.2f", info.generationDuration.seconds).padding(toLength: 12, withPad: " ", startingAt: 0), + String(info.buildSuccess).padding(toLength: 14, withPad: " ", startingAt: 0), + String(format: "%.2f", info.buildDuration.seconds).padding(toLength: 14, withPad: " ", startingAt: 0), + info.logFilePath ?? "-" + ] + print(row.joined(separator: " ")) + } + } + } + + struct BuildInfo { + var generationDuration: DispatchTimeInterval + var buildDuration: DispatchTimeInterval + var generationSuccess: Bool + var buildSuccess: Bool + var generationError: Error? + var buildError: Error? + var logFilePath: String? + } + + private func testTemplateIntialization( + commandPlugin: ResolvedModule, + swiftCommandState: SwiftCommandState, + buildOptions: BuildCommandOptions, + destinationAbsolutePath: AbsolutePath, + testingFolderName: String, + argumentPath: [ArgumentTreeNode], + initialPackageType: InitPackage.PackageType + ) async throws -> BuildInfo { + + let generationStart = DispatchTime.now() + var generationDuration: DispatchTimeInterval = .never + var buildDuration: DispatchTimeInterval = .never + var generationSuccess = false + var buildSuccess = false + var generationError: Error? + var buildError: Error? + var logFilePath: String? = nil + + + var pluginOutput: String = "" + + do { + + let logPath = destinationAbsolutePath.appending("generation-output.log").pathString + + // Redirect stdout/stderr to file before starting generation + let (origOut, origErr) = try redirectStdoutAndStderr(to: logPath) + + defer { + restoreStdoutAndStderr(originalStdout: origOut, originalStderr: origErr) + } + + let initTemplatePackage = try InitTemplatePackage( - name: folderName, + name: testingFolderName, initMode: .fileSystem(name: templateName, path: swiftCommandState.originalWorkingDirectory.pathString), templatePath: swiftCommandState.originalWorkingDirectory, fileSystem: swiftCommandState.fileSystem, @@ -1004,113 +1091,179 @@ extension SwiftTestCommand { try initTemplatePackage.setupTemplateManifest() - let generatedGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in try await swiftCommandState.loadPackageGraph() } try await TemplateBuildSupport.buildForTesting( - swiftCommandState: swiftCommandState, - buildOptions: buildOptions, - testingFolder: destinationAbsolutePath + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + testingFolder: destinationAbsolutePath ) - for (index, node) in path.enumerated() { - let currentPath = index == 0 ? [] : path[1...index].map { $0.command.commandName } + for (index, node) in argumentPath.enumerated() { + let currentPath = index == 0 ? [] : argumentPath[1...index].map { $0.command.commandName } let currentArgs = node.arguments.values.flatMap { $0.commandLineFragments } let fullCommand = currentPath + currentArgs - let commandString = fullCommand.joined(separator: " ") - - print("Running: \(commandString)") - do { - try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath, perform: {_,_ in print(String(data: try await TemplatePluginRunner.run( - plugin: commandPlugin, - package: generatedGraph.rootPackages.first!, - packageGraph: generatedGraph, - arguments: fullCommand, - swiftCommandState: swiftCommandState - ), encoding: .utf8) ?? "") + try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in + do { + let outputData = try await TemplatePluginRunner.run( + plugin: commandPlugin, + package: generatedGraph.rootPackages.first!, + packageGraph: generatedGraph, + arguments: fullCommand, + swiftCommandState: swiftCommandState + ) + pluginOutput = String(data: outputData, encoding: .utf8) ?? "[Invalid UTF-8 output]" + print(pluginOutput) + } + } + } - }) + generationDuration = generationStart.distance(to: .now()) + generationSuccess = true - print("Success: \(commandString)") - } catch { - print("Failed: \(commandString) — error: \(error)") - } + if generationSuccess { + try FileManager.default.removeItem(atPath: logPath) } - //Test for building + } catch { + generationDuration = generationStart.distance(to: .now()) + generationError = error + generationSuccess = false - let buildInfo = await buildGeneratedTemplate(swiftCommandState: swiftCommandState, buildOptions: buildOptions, testingFolder: destinationAbsolutePath) - buildMatrix[folderName] = buildInfo - + let logPath = destinationAbsolutePath.appending("generation-output.log") + let outputPath = logPath.pathString + let capturedOutput = (try? String(contentsOfFile: outputPath)) ?? "" + + let unifiedLog = """ + Error: + -------------------------------- + \(error.localizedDescription) + + Plugin Output (before failure): + -------------------------------- + \(capturedOutput) + """ + + try? unifiedLog.write(to: logPath.asURL, atomically: true, encoding: .utf8) + logFilePath = logPath.pathString + } + // Only start the build step if generation was successful + if generationSuccess { + let buildStart = DispatchTime.now() + do { - printBuildMatrix(buildMatrix) + let logPath = destinationAbsolutePath.appending("build-output.log").pathString - func printBuildMatrix(_ matrix: [String: BuildInfo]) { - // Print header with manual padding - let header = [ - "Argument Branch".padding(toLength: 30, withPad: " ", startingAt: 0), - "Success".padding(toLength: 10, withPad: " ", startingAt: 0), - "Time(s)".padding(toLength: 10, withPad: " ", startingAt: 0), - "Error" - ] - print(header.joined(separator: " ")) + // Redirect stdout/stderr to file before starting build + let (origOut, origErr) = try redirectStdoutAndStderr(to: logPath) - for (folder, info) in matrix { - let duration = String(format: "%.2f", info.duration.seconds) - let status = info.success ? "true" : "false" - let errorText = info.error?.localizedDescription ?? "-" + defer { + restoreStdoutAndStderr(originalStdout: origOut, originalStderr: origErr) + } + + + try await TemplateBuildSupport.buildForTesting( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + testingFolder: destinationAbsolutePath + ) + + buildDuration = buildStart.distance(to: .now()) + buildSuccess = true + + + if buildSuccess { + try FileManager.default.removeItem(atPath: logPath) + } + + + } catch { + buildDuration = buildStart.distance(to: .now()) + buildError = error + buildSuccess = false + + let logPath = destinationAbsolutePath.appending("build-output.log") + let outputPath = logPath.pathString + let capturedOutput = (try? String(contentsOfFile: outputPath)) ?? "" + + let unifiedLog = """ + Error: + -------------------------------- + \(error.localizedDescription) + + Build Output (before failure): + -------------------------------- + \(capturedOutput) + """ + + try? unifiedLog.write(to: logPath.asURL, atomically: true, encoding: .utf8) + logFilePath = logPath.pathString - let row = [ - folder.padding(toLength: 30, withPad: " ", startingAt: 0), - status.padding(toLength: 10, withPad: " ", startingAt: 0), - duration.padding(toLength: 10, withPad: " ", startingAt: 0), - errorText - ] - print(row.joined(separator: " ")) } } + + + return BuildInfo( + generationDuration: generationDuration, + buildDuration: buildDuration, + generationSuccess: generationSuccess, + buildSuccess: buildSuccess, + generationError: generationError, + buildError: buildError, + logFilePath: logFilePath + ) } - - struct BuildInfo { - var startTime: DispatchTime - var duration: DispatchTimeInterval - var success: Bool - var error: Error? + func writeLogToFile(_ content: String, to directory: AbsolutePath, named fileName: String) throws { + let fileURL = URL(fileURLWithPath: directory.pathString).appendingPathComponent(fileName) + try content.write(to: fileURL, atomically: true, encoding: .utf8) } - private func buildGeneratedTemplate(swiftCommandState: SwiftCommandState, buildOptions: BuildCommandOptions, testingFolder: AbsolutePath) async -> BuildInfo { - var buildInfo: BuildInfo + func redirectStdoutAndStderr(to path: String) throws -> (originalStdout: Int32, originalStderr: Int32) { + // Open file for writing (create/truncate) + guard let file = fopen(path, "w") else { + throw NSError(domain: "RedirectError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot open file for writing"]) + } - let start = DispatchTime.now() + let originalStdout = dup(STDOUT_FILENO) + let originalStderr = dup(STDERR_FILENO) - do { - try await - TemplateBuildSupport.buildForTesting( - swiftCommandState: swiftCommandState, - buildOptions: buildOptions, - testingFolder: testingFolder - ) + dup2(fileno(file), STDOUT_FILENO) + dup2(fileno(file), STDERR_FILENO) - let duration = start.distance(to: .now()) - buildInfo = BuildInfo(startTime: start, duration: duration, success: true, error: nil) - } catch { - let duration = start.distance(to: .now()) + fclose(file) + + return (originalStdout, originalStderr) + } + + func restoreStdoutAndStderr(originalStdout: Int32, originalStderr: Int32) { + fflush(stdout) + fflush(stderr) - buildInfo = BuildInfo(startTime: start, duration: duration, success: false, error: error) + if dup2(originalStdout, STDOUT_FILENO) == -1 { + perror("dup2 stdout restore failed") } + if dup2(originalStderr, STDERR_FILENO) == -1 { + perror("dup2 stderr restore failed") + } + + fflush(stdout) + fflush(stderr) - return buildInfo + if close(originalStdout) == -1 { + perror("close originalStdout failed") + } + if close(originalStderr) == -1 { + perror("close originalStderr failed") + } } } - - struct List: AsyncSwiftCommand { static let configuration = CommandConfiguration( abstract: "Lists test methods in specifier format" diff --git a/Sources/Commands/Utilities/PluginDelegate.swift b/Sources/Commands/Utilities/PluginDelegate.swift index 24117d49698..39f5b8e7ebc 100644 --- a/Sources/Commands/Utilities/PluginDelegate.swift +++ b/Sources/Commands/Utilities/PluginDelegate.swift @@ -29,6 +29,7 @@ final class PluginDelegate: PluginInvocationDelegate { let plugin: PluginModule var lineBufferedOutput: Data let echoOutput: Bool + var diagnostics: [Basics.Diagnostic] = [] init(swiftCommandState: SwiftCommandState, buildSystem: BuildSystemProvider.Kind, plugin: PluginModule, echoOutput: Bool = true) { self.swiftCommandState = swiftCommandState @@ -61,6 +62,9 @@ final class PluginDelegate: PluginInvocationDelegate { func pluginEmittedDiagnostic(_ diagnostic: Basics.Diagnostic) { swiftCommandState.observabilityScope.emit(diagnostic) + if diagnostic.severity == .error { + diagnostics.append(diagnostic) + } } func pluginEmittedProgress(_ message: String) { diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift index 891418903c9..6bd4329d261 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift @@ -123,7 +123,7 @@ enum TemplatePluginRunner { ?? swiftCommandState.fileSystem.currentWorkingDirectory ?? { throw InternalError("Could not determine working directory") }() - let _ = try await pluginTarget.invoke( + let success = try await pluginTarget.invoke( action: .performCommand(package: package, arguments: arguments), buildEnvironment: buildParams.buildEnvironment, scriptRunner: pluginScriptRunner, @@ -142,7 +142,17 @@ enum TemplatePluginRunner { callbackQueue: DispatchQueue(label: "plugin-invocation"), delegate: delegate ) - + + guard success else { + let stringError = delegate.diagnostics + .map { $0.message } + .joined(separator: "\n") + + throw DefaultPluginScriptRunnerError.invocationFailed( + error: StringError(stringError), + command: arguments + ) + } return delegate.lineBufferedOutput } From 175e335f4e26129cef19c27b81ec548af9eac7bf Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 23 Jul 2025 14:23:33 -0400 Subject: [PATCH 195/225] reverted package-metadata changes --- Sources/PackageMetadata/PackageMetadata.swift | 25 +------ Sources/PackageRegistry/RegistryClient.swift | 66 +------------------ 2 files changed, 2 insertions(+), 89 deletions(-) diff --git a/Sources/PackageMetadata/PackageMetadata.swift b/Sources/PackageMetadata/PackageMetadata.swift index 6bb0d8c38d3..5745ac608cd 100644 --- a/Sources/PackageMetadata/PackageMetadata.swift +++ b/Sources/PackageMetadata/PackageMetadata.swift @@ -88,7 +88,6 @@ public struct Package { public let publishedAt: Date? public let signingEntity: SigningEntity? public let latestVersion: Version? - public let templates: [Template]? fileprivate init( identity: PackageIdentity, @@ -105,7 +104,6 @@ public struct Package { signingEntity: SigningEntity? = nil, latestVersion: Version? = nil, source: Source, - templates: [Template]? = nil ) { self.identity = identity self.location = location @@ -121,7 +119,6 @@ public struct Package { self.signingEntity = signingEntity self.latestVersion = latestVersion self.source = source - self.templates = templates } } @@ -181,7 +178,6 @@ public struct PackageSearchClient { public let description: String? public let publishedAt: Date? public let signingEntity: SigningEntity? - public let templates: [Package.Template]? } private func getVersionMetadata( @@ -203,8 +199,7 @@ public struct PackageSearchClient { author: metadata.author.map { .init($0) }, description: metadata.description, publishedAt: metadata.publishedAt, - signingEntity: metadata.sourceArchive?.signingEntity, - templates: metadata.templates?.map { .init($0) } + signingEntity: metadata.sourceArchive?.signingEntity ) } @@ -443,24 +438,6 @@ extension Package.Organization { } } -extension Package.Template { - fileprivate init(_ template: RegistryClient.PackageVersionMetadata.Template) { - self.init( - name: template.name, description: template.description, arguments: template.arguments?.map { .init($0) } - ) - } -} - -extension Package.TemplateArguments { - fileprivate init(_ argument: RegistryClient.PackageVersionMetadata.TemplateArguments) { - self.init( - name: argument.name, - description: argument.description, - isRequired: argument.isRequired - ) - } -} - extension SigningEntity { fileprivate init(signer: PackageCollectionsModel.Signer) { // All package collection signers are "recognized" diff --git a/Sources/PackageRegistry/RegistryClient.swift b/Sources/PackageRegistry/RegistryClient.swift index bd858d7c493..88a29cbec99 100644 --- a/Sources/PackageRegistry/RegistryClient.swift +++ b/Sources/PackageRegistry/RegistryClient.swift @@ -363,20 +363,7 @@ public final class RegistryClient: AsyncCancellable { ) }, description: versionMetadata.metadata?.description, - publishedAt: versionMetadata.metadata?.originalPublicationTime ?? versionMetadata.publishedAt, - templates: versionMetadata.metadata?.templates?.compactMap { template in - PackageVersionMetadata.Template( - name: template.name, - description: template.description, - arguments: template.arguments?.map { arg in - PackageVersionMetadata.TemplateArguments( - name: arg.name, - description: arg.description, - isRequired: arg.isRequired - ) - } - ) - } + publishedAt: versionMetadata.metadata?.originalPublicationTime ?? versionMetadata.publishedAt ) return packageVersionMetadata @@ -1738,45 +1725,11 @@ extension RegistryClient { public let author: Author? public let description: String? public let publishedAt: Date? - public let templates: [Template]? public var sourceArchive: Resource? { self.resources.first(where: { $0.name == "source-archive" }) } - public struct Template: Sendable { - public let name: String - public let description: String? - //public let permissions: [String]? TODO ADD - public let arguments: [TemplateArguments]? - - public init( - name: String, - description: String? = nil, - arguments: [TemplateArguments]? = nil - ) { - self.name = name - self.description = description - self.arguments = arguments - } - } - - public struct TemplateArguments: Sendable { - public let name: String - public let description: String? - public let isRequired: Bool? - - public init( - name: String, - description: String? = nil, - isRequired: Bool? = nil - ) { - self.name = name - self.description = description - self.isRequired = isRequired - } - } - public struct Resource: Sendable { public let name: String public let type: String @@ -2197,7 +2150,6 @@ extension RegistryClient { public let readmeURL: String? public let repositoryURLs: [String]? public let originalPublicationTime: Date? - public let templates: [Template]? public init( author: Author? = nil, @@ -2206,7 +2158,6 @@ extension RegistryClient { readmeURL: String? = nil, repositoryURLs: [String]? = nil, originalPublicationTime: Date? = nil, - templates: [Template]? = nil ) { self.author = author self.description = description @@ -2214,24 +2165,9 @@ extension RegistryClient { self.readmeURL = readmeURL self.repositoryURLs = repositoryURLs self.originalPublicationTime = originalPublicationTime - self.templates = templates } } - public struct Template: Codable { - public let name: String - public let description: String? - //public let permissions: [String]? TODO ADD - public let arguments: [TemplateArguments]? - } - - public struct TemplateArguments: Codable { - public let name: String - public let description: String? - public let isRequired: Bool? - } - - public struct Author: Codable { public let name: String public let email: String? From 63cb48cda1371ba64be8a1173e3ec877d42beeae Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 23 Jul 2025 14:23:56 -0400 Subject: [PATCH 196/225] fixed dummyRepositorymanager to conform to protocol --- Tests/SourceControlTests/RepositoryManagerTests.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Tests/SourceControlTests/RepositoryManagerTests.swift b/Tests/SourceControlTests/RepositoryManagerTests.swift index 10b354b451f..b2ecb2e3db5 100644 --- a/Tests/SourceControlTests/RepositoryManagerTests.swift +++ b/Tests/SourceControlTests/RepositoryManagerTests.swift @@ -837,6 +837,10 @@ private class DummyRepositoryProvider: RepositoryProvider, @unchecked Sendable { fatalError("not implemented") } + func checkout(branch: String) throws { + fatalError("not implemented") + } + func isAlternateObjectStoreValid(expected: AbsolutePath) -> Bool { fatalError("not implemented") } From 93a19b2c1e3bbc7a7849dcb492c745fd0fbe8139 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 23 Jul 2025 15:03:35 -0400 Subject: [PATCH 197/225] possibility to edit format of test result to json too --- Sources/Commands/SwiftTestCommand.swift | 108 +++++++++++++++++++++--- 1 file changed, 95 insertions(+), 13 deletions(-) diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index 15cdc11ebd2..92e68b555bb 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -810,7 +810,7 @@ extension ArgumentTreeNode { currentPath: [String] = [], currentArguments: [String: InitTemplatePackage.ArgumentResponse] = [:] ) -> [([String], [String: InitTemplatePackage.ArgumentResponse])] { - var newPath = currentPath + [command.commandName] + let newPath = currentPath + [command.commandName] var combinedArguments = currentArguments for (key, value) in arguments { @@ -893,6 +893,11 @@ extension SwiftTestCommand { @Flag(help: "Dry-run to display argument tree") var dryRun: Bool = false + /// Output format for the templates result. + /// + /// Can be either `.matrix` (default) or `.json`. + @Option(help: "Set the output format.") + var format: ShowTestTemplateOutput = .matrix func run(_ swiftCommandState: SwiftCommandState) async throws { let manifest = outputDirectory.appending(component: Manifest.filename) @@ -952,7 +957,6 @@ extension SwiftTestCommand { } let cliArgumentPaths = root.createCLITree(root: root) - let commandPaths = root.collectCommandPaths() func checkConditions(_ swiftCommandState: SwiftCommandState, template: String?) async throws -> InitPackage.PackageType { let workspace = try swiftCommandState.getActiveWorkspace() @@ -1007,7 +1011,21 @@ extension SwiftTestCommand { } - printBuildMatrix(buildMatrix) + switch self.format { + case .matrix: + printBuildMatrix(buildMatrix) + case .json: + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + do { + let data = try encoder.encode(buildMatrix) + if let output = String(data: data, encoding: .utf8) { + print(output) + } + } catch { + print("Failed to encode JSON: \(error)") + } + } func printBuildMatrix(_ matrix: [String: BuildInfo]) { let header = [ @@ -1034,14 +1052,85 @@ extension SwiftTestCommand { } } - struct BuildInfo { + /// Output format modes for the `ShowTemplates` command. + enum ShowTestTemplateOutput: String, RawRepresentable, CustomStringConvertible, ExpressibleByArgument, CaseIterable { + /// Output as a matrix. + case matrix + /// Output as a JSON array of template along with fields. + case json + + public init?(rawValue: String) { + switch rawValue.lowercased() { + case "matrix": + self = .matrix + case "json": + self = .json + default: + return nil + } + } + + public var description: String { + switch self { + case .matrix: "matrix" + case .json: "json" + } + } + } + + struct BuildInfo: Encodable { var generationDuration: DispatchTimeInterval var buildDuration: DispatchTimeInterval var generationSuccess: Bool var buildSuccess: Bool - var generationError: Error? - var buildError: Error? var logFilePath: String? + + + public init(generationDuration: DispatchTimeInterval, buildDuration: DispatchTimeInterval, generationSuccess: Bool, buildSuccess: Bool, logFilePath: String? = nil) { + self.generationDuration = generationDuration + self.buildDuration = buildDuration + self.generationSuccess = generationSuccess + self.buildSuccess = buildSuccess + self.logFilePath = logFilePath + } + enum CodingKeys: String, CodingKey { + case generationDuration + case buildDuration + case generationSuccess + case buildSuccess + case logFilePath + } + + // Encoding + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(Self.dispatchTimeIntervalToSeconds(generationDuration), forKey: .generationDuration) + try container.encode(Self.dispatchTimeIntervalToSeconds(buildDuration), forKey: .buildDuration) + try container.encode(generationSuccess, forKey: .generationSuccess) + try container.encode(buildSuccess, forKey: .buildSuccess) + if logFilePath == nil { + try container.encodeNil(forKey: .logFilePath) + } else { + try container.encodeIfPresent(logFilePath, forKey: .logFilePath) + } + } + + // Helpers + private static func dispatchTimeIntervalToSeconds(_ interval: DispatchTimeInterval) -> Double { + switch interval { + case .seconds(let s): return Double(s) + case .milliseconds(let ms): return Double(ms) / 1000 + case .microseconds(let us): return Double(us) / 1_000_000 + case .nanoseconds(let ns): return Double(ns) / 1_000_000_000 + case .never: return -1 // or some sentinel value + @unknown default: return -1 + } + } + + private static func secondsToDispatchTimeInterval(_ seconds: Double) -> DispatchTimeInterval { + return .milliseconds(Int(seconds * 1000)) + } + } private func testTemplateIntialization( @@ -1059,8 +1148,6 @@ extension SwiftTestCommand { var buildDuration: DispatchTimeInterval = .never var generationSuccess = false var buildSuccess = false - var generationError: Error? - var buildError: Error? var logFilePath: String? = nil @@ -1130,7 +1217,6 @@ extension SwiftTestCommand { } catch { generationDuration = generationStart.distance(to: .now()) - generationError = error generationSuccess = false @@ -1184,7 +1270,6 @@ extension SwiftTestCommand { } catch { buildDuration = buildStart.distance(to: .now()) - buildError = error buildSuccess = false let logPath = destinationAbsolutePath.appending("build-output.log") @@ -1207,14 +1292,11 @@ extension SwiftTestCommand { } } - return BuildInfo( generationDuration: generationDuration, buildDuration: buildDuration, generationSuccess: generationSuccess, buildSuccess: buildSuccess, - generationError: generationError, - buildError: buildError, logFilePath: logFilePath ) } From a7176f580f08b5e3a8494e589b440a3657fce863 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 23 Jul 2025 16:28:40 -0400 Subject: [PATCH 198/225] bug fix where subcommand was prompted for when there was no valid subcommand to choose from --- Sources/Workspace/InitTemplatePackage.swift | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 8c4898dec6e..73ea8c272bb 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -303,7 +303,7 @@ public final class InitTemplatePackage { private func promptUserForSubcommand(for commands: [CommandInfoV0]) throws -> CommandInfoV0 { - print("Available subcommands:\n") + print("Choose from the following:\n") for command in commands { print(""" @@ -314,16 +314,16 @@ public final class InitTemplatePackage { """) } - print("Type the name of the subcommand you'd like to use:") + print("Type the name of the option:") while true { if let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), !input.isEmpty { if let match = commands.first(where: { $0.commandName == input }) { return match } else { - print("No subcommand found with name '\(input)'. Please try again:") + print("No option found with name '\(input)'. Please try again:") } } else { - print("Please enter a valid subcommand name:") + print("Please enter a valid option name:") } } } @@ -342,9 +342,7 @@ public final class InitTemplatePackage { let filteredSubcommands = subcommands.filter { $0.commandName.lowercased() != "help" } - for sub in filteredSubcommands { - print(sub.commandName) - } + guard !filteredSubcommands.isEmpty else { return nil } return filteredSubcommands } From 8f7b535fdeb35d1446920a8a23940354b083b4ca Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 6 Aug 2025 10:41:56 -0400 Subject: [PATCH 199/225] run package clean after generating package --- Sources/Commands/PackageCommands/Init.swift | 21 ++++++---- .../_InternalInitSupport/TemplateBuild.swift | 38 +++++++++---------- Sources/Workspace/InitTemplatePackage.swift | 2 - 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 43695e19e27..0f0f8d2202e 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -38,8 +38,6 @@ extension SwiftPackageCommand { @Option( name: .customLong("type"), help: ArgumentHelp("Specifies the package type or template.", discussion: """ - Valid values include: - library - A package with a library. executable - A package with an executable. tool - A package with an executable that uses @@ -49,7 +47,7 @@ extension SwiftPackageCommand { command-plugin - A package that vends a command plugin. macro - A package that vends a macro. empty - An empty package with a Package.swift manifest. - - When used with --path, --url, or --package-id, + custom - When used with --path, --url, or --package-id, this resolves to a template from the specified package or location. """)) @@ -247,10 +245,12 @@ extension SwiftPackageCommand { throw ValidationError("The specified template path does not exist: \(dir.pathString)") } - // Use a transitive staging directory + // Use a transitive staging directory for building let tempDir = try swiftCommandState.fileSystem.tempDirectory.appending(component: UUID().uuidString) let stagingPackagePath = tempDir.appending(component: "generated-package") - let buildDir = tempDir.appending(component: ".build") + + // Use a directory for cleaning dependencies post build + let cleanUpPath = tempDir.appending(component: "clean-up") try swiftCommandState.fileSystem.createDirectory(tempDir) defer { @@ -364,10 +364,17 @@ extension SwiftPackageCommand { if swiftCommandState.fileSystem.exists(cwd) { try swiftCommandState.fileSystem.removeFileTree(cwd) } - try swiftCommandState.fileSystem.copy(from: stagingPackagePath, to: cwd) - // Restore cwd for build + try swiftCommandState.fileSystem.copy(from: stagingPackagePath, to: cleanUpPath) + let _ = try await swiftCommandState + .withTemporaryWorkspace(switchingTo: cleanUpPath) { _, _ in + try swiftCommandState.getActiveWorkspace().clean(observabilityScope: swiftCommandState.observabilityScope) + } + + try swiftCommandState.fileSystem.copy(from: cleanUpPath, to: cwd) + + // Restore cwd for build if validatePackage { try await TemplateBuildSupport.build( swiftCommandState: swiftCommandState, diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift index 043a89dd856..314a9cfd7a4 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -51,33 +51,29 @@ enum TemplateBuildSupport { ) async throws { - let buildSystem = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in - - try await swiftCommandState.createBuildSystem( - explicitProduct: buildOptions.product, - traitConfiguration: .init(traitOptions: buildOptions.traits), - shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: swiftCommandState.toolsBuildParameters, - outputStream: TSCBasic.stdoutStream - ) - } + let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in + + try await swiftCommandState.createBuildSystem( + explicitProduct: buildOptions.product, + traitConfiguration: .init(traitOptions: buildOptions.traits), + shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: swiftCommandState.toolsBuildParameters, + outputStream: TSCBasic.stdoutStream + ) + } guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { throw ExitCode.failure } - - - try await swiftCommandState - .withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in - do { - try await buildSystem.build(subset: subset) - } catch _ as Diagnostics { - throw ExitCode.failure - } + try await swiftCommandState.withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in + do { + try await buildSystem.build(subset: subset) + } catch _ as Diagnostics { + throw ExitCode.failure } + } } static func buildForTesting( diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 73ea8c272bb..1dc1421ddcc 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -307,10 +307,8 @@ public final class InitTemplatePackage { for command in commands { print(""" - • \(command.commandName) Name: \(command.commandName) About: \(command.abstract ?? "") - """) } From 2ffd1cef5530cefbed3a638e752616e81a987e63 Mon Sep 17 00:00:00 2001 From: John Bute Date: Fri, 8 Aug 2025 11:55:25 -0400 Subject: [PATCH 200/225] refactoring --- Sources/Commands/PackageCommands/Init.swift | 347 ++++-------------- .../PackageCommands/ShowTemplates.swift | 6 +- .../PackageDependencyBuilder.swift | 18 +- ...ackageInitializationDirectoryManager.swift | 50 +++ .../PackageInitializer.swift | 198 ++++++++++ .../RequirementResolver.swift | 33 +- .../_InternalInitSupport/TemplateBuild.swift | 129 ++++--- .../TemplatePathResolver.swift | 52 ++- .../TemplatePluginManager.swift | 91 +++++ Sources/Workspace/InitTemplatePackage.swift | 2 +- 10 files changed, 521 insertions(+), 405 deletions(-) create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 0f0f8d2202e..e1eddb1770d 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -29,6 +29,7 @@ import PackageGraph extension SwiftPackageCommand { struct Init: AsyncSwiftCommand { + public static let configuration = CommandConfiguration( abstract: "Initialize a new package.") @@ -68,19 +69,6 @@ extension SwiftPackageCommand { @OptionGroup(visibility: .hidden) var buildOptions: BuildCommandOptions - /// The type of template to use: `registry`, `git`, or `local`. - var templateSource: InitTemplatePackage.TemplateSource? { - if templateDirectory != nil { - .local - } else if templateURL != nil { - .git - } else if templatePackageID != nil { - .registry - } else { - nil - } - } - /// Path to a local template. @Option(name: .customLong("path"), help: "Path to the local template.", completion: .directory) var templateDirectory: Basics.AbsolutePath? @@ -133,290 +121,68 @@ extension SwiftPackageCommand { var createPackagePath = true func run(_ swiftCommandState: SwiftCommandState) async throws { + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") - } - - let packageName = self.packageName ?? cwd.basename - - // Check for template init path - if let _ = templateSource { - // When a template source is provided: - // - If the user gives a known type, it's probably a misuse - // - If the user gives an unknown value for --type, treat it as the name of the template - // - If --type is missing entirely, assume the package has a single template - try await initTemplate(swiftCommandState) - return - } else { - guard let initModeString = self.initMode else { - throw ValidationError("Specify a package type using the --type option.") - } - guard let knownType = InitPackage.PackageType(rawValue: initModeString) else { - throw ValidationError("Package type \(initModeString) not supported") - } - // Configure testing libraries - var supportedTestingLibraries = Set() - if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || - (knownType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.xctest) - } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (knownType != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.swiftTesting) - } - - let initPackage = try InitPackage( - name: packageName, - packageType: knownType, - supportedTestingLibraries: supportedTestingLibraries, - destinationPath: cwd, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, - fileSystem: swiftCommandState.fileSystem - ) - initPackage.progressReporter = { message in print(message) } - try initPackage.writePackageStructure() - - - } - - } - - - public func initTemplate(_ swiftCommandState: SwiftCommandState) async throws { - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") - } - - let packageName = self.packageName ?? cwd.basename - - try await self.runTemplateInit(swiftCommandState: swiftCommandState, packageName: packageName, cwd: cwd) - - } - - /// Runs the package initialization using an author-defined template. - private func runTemplateInit( - swiftCommandState: SwiftCommandState, - packageName: String, - cwd: Basics.AbsolutePath - ) async throws { - guard let source = templateSource else { - throw ValidationError("No template source specified.") - } - - let manifest = cwd.appending(component: Manifest.filename) - guard swiftCommandState.fileSystem.exists(manifest) == false else { - throw InitError.manifestAlreadyExists - } - - - let contents = try swiftCommandState.fileSystem.getDirectoryContents(cwd) - - guard contents.isEmpty else { - throw InitError.nonEmptyDirectory(contents) - } - - let template = initMode - let requirementResolver = DependencyRequirementResolver( - exact: exact, - revision: revision, - branch: branch, - from: from, - upToNextMinorFrom: upToNextMinorFrom, - to: to - ) - - let registryRequirement: PackageDependency.Registry.Requirement? = - try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement - - let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = - try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement - - let resolvedTemplatePath = try await TemplatePathResolver( - source: templateSource, - templateDirectory: templateDirectory, - templateURL: templateURL, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - packageIdentity: templatePackageID, - swiftCommandState: swiftCommandState - ).resolve() - - if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { - throw ValidationError("The specified template path does not exist: \(dir.pathString)") - } - - // Use a transitive staging directory for building - let tempDir = try swiftCommandState.fileSystem.tempDirectory.appending(component: UUID().uuidString) - let stagingPackagePath = tempDir.appending(component: "generated-package") - - // Use a directory for cleaning dependencies post build - let cleanUpPath = tempDir.appending(component: "clean-up") - - try swiftCommandState.fileSystem.createDirectory(tempDir) - defer { - try? swiftCommandState.fileSystem.removeFileTree(tempDir) - } - - // Determine the type by loading the resolved template - let templateInitType = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in - try await self.checkConditions(swiftCommandState, template: template) - } - - // Clean up downloaded package after execution. - defer { - if templateSource == .git { - try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) - } else if templateSource == .registry { - let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL - try? FileManager.default.removeItem(at: parentDirectoryURL) - } - } - - let supportedTemplateTestingLibraries: Set = .init() - - let builder = DefaultPackageDependencyBuilder( - templateSource: source, - packageName: packageName, - templateURL: self.templateURL, - templatePackageID: self.templatePackageID - ) - - let dependencyKind = try builder.makePackageDependency( - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - resolvedTemplatePath: resolvedTemplatePath - ) - - let initTemplatePackage = try InitTemplatePackage( - name: packageName, - initMode: dependencyKind, - templatePath: resolvedTemplatePath, - fileSystem: swiftCommandState.fileSystem, - packageType: templateInitType, - supportedTestingLibraries: supportedTemplateTestingLibraries, - destinationPath: stagingPackagePath, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration - ) - - try swiftCommandState.fileSystem.createDirectory(stagingPackagePath, recursive: true) + } //Should this be refactored? + + let name = packageName ?? cwd.basename + - try initTemplatePackage.setupTemplateManifest() + var templateSourceResolver: TemplateSourceResolver = DefaultTemplateSourceResolver() - // Build once inside the transitive folder - try await TemplateBuildSupport.build( - swiftCommandState: swiftCommandState, - buildOptions: self.buildOptions, - globalOptions: self.globalOptions, - cwd: stagingPackagePath, - transitiveFolder: stagingPackagePath + let templateSource = templateSourceResolver.resolveSource( + directory: templateDirectory, + url: templateURL, + packageID: templatePackageID ) - let packageGraph = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: stagingPackagePath) { _, _ in - try await swiftCommandState.loadPackageGraph() + if templateSource == nil, let initMode { + guard let _ = InitPackage.PackageType(rawValue: initMode) else { + throw ValidationError("Unknown package type: '\(initMode)'") } - - - let matchingPlugins = PluginCommand.findPlugins(matching: template, in: packageGraph, limitedTo: nil) - - guard let commandPlugin = matchingPlugins.first else { - guard let template = template - else { throw ValidationError("No templates were found in \(packageName)") } - - throw ValidationError("No templates were found that match the name \(template)") } - guard matchingPlugins.count == 1 else { - let templateNames = matchingPlugins.compactMap { module in - let plugin = module.underlying as! PluginModule - guard case .command(let intent, _) = plugin.capability else { return String?.none } - - return intent.invocationVerb - } - throw ValidationError( - "More than one template was found in the package. Please use `--type` along with one of the available templates: \(templateNames.joined(separator: ", "))" + if let source = templateSource { + let versionResolver = DependencyRequirementResolver( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to ) - } - - let output = try await TemplatePluginRunner.run( - plugin: commandPlugin, - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: ["--", "--experimental-dump-help"], - swiftCommandState: swiftCommandState - ) - - let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let cliResponses = try initTemplatePackage.promptUser(command: toolInfo.command, arguments: args) - for response in cliResponses { - _ = try await TemplatePluginRunner.run( - plugin: matchingPlugins[0], - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: response, + let initializer = TemplatePackageInitializer( + packageName: name, + cwd: cwd, + templateSource: source, + templateName: initMode, + templateDirectory: templateDirectory, + templateURL: templateURL, + templatePackageID: templatePackageID, + versionResolver: versionResolver, + buildOptions: buildOptions, + globalOptions: globalOptions, + validatePackage: validatePackage, + args: args, swiftCommandState: swiftCommandState ) - } - - // Move finalized package to target cwd - if swiftCommandState.fileSystem.exists(cwd) { - try swiftCommandState.fileSystem.removeFileTree(cwd) - } - - try swiftCommandState.fileSystem.copy(from: stagingPackagePath, to: cleanUpPath) - - let _ = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: cleanUpPath) { _, _ in - try swiftCommandState.getActiveWorkspace().clean(observabilityScope: swiftCommandState.observabilityScope) - } - - try swiftCommandState.fileSystem.copy(from: cleanUpPath, to: cwd) - - // Restore cwd for build - if validatePackage { - try await TemplateBuildSupport.build( - swiftCommandState: swiftCommandState, - buildOptions: self.buildOptions, - globalOptions: self.globalOptions, - cwd: cwd + try await initializer.run() + } else { + let initializer = StandardPackageInitializer( + packageName: name, + initMode: initMode, + testLibraryOptions: testLibraryOptions, + cwd: cwd, + swiftCommandState: swiftCommandState ) + try await initializer.run() } } - - - - /// Validates the loaded manifest to determine package type. - private func checkConditions(_ swiftCommandState: SwiftCommandState, template: String?) async throws -> InitPackage.PackageType { - let workspace = try swiftCommandState.getActiveWorkspace() - let root = try swiftCommandState.getWorkspaceRoot() - - let rootManifests = try await workspace.loadRootManifests( - packages: root.packages, - observabilityScope: swiftCommandState.observabilityScope - ) - guard let rootManifest = rootManifests.values.first else { - throw InternalError("invalid manifests at \(root.packages)") - } - - let products = rootManifest.products - let targets = rootManifest.targets - - for _ in products { - if let target: TargetDescription = targets.first(where: { template == nil || $0.name == template }) { - if let options = target.templateInitializationOptions { - if case .packageInit(let templateType, _, _) = options { - return try .init(from: templateType) - } - } - } - } - throw ValidationError( - "Could not find \(template != nil ? "template \(template!)" : "any templates in the package")" - ) + + init() { } - public init() {} } } @@ -442,4 +208,25 @@ extension InitPackage.PackageType { } } +protocol TemplateSourceResolver { + func resolveSource( + directory: Basics.AbsolutePath?, + url: String?, + packageID: String? + ) -> InitTemplatePackage.TemplateSource? +} + +struct DefaultTemplateSourceResolver: TemplateSourceResolver { + func resolveSource( + directory: Basics.AbsolutePath?, + url: String?, + packageID: String? + ) -> InitTemplatePackage.TemplateSource? { + if directory != nil { return .local } + if url != nil { return .git } + if packageID != nil { return .registry } + return nil + } +} + extension InitPackage.PackageType: ExpressibleByArgument {} diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 442161a08bd..ac50286ff19 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -73,8 +73,6 @@ struct ShowTemplates: AsyncSwiftCommand { var to: Version? func run(_ swiftCommandState: SwiftCommandState) async throws { - let packagePath: Basics.AbsolutePath - var shouldDeleteAfter = false let requirementResolver = DependencyRequirementResolver( exact: exact, @@ -86,10 +84,10 @@ struct ShowTemplates: AsyncSwiftCommand { ) let registryRequirement: PackageDependency.Registry.Requirement? = - try? requirementResolver.resolve(for: .registry) as? PackageDependency.Registry.Requirement + try? requirementResolver.resolveRegistry() let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = - try? requirementResolver.resolve(for: .sourceControl) as? PackageDependency.SourceControl.Requirement + try? requirementResolver.resolveSourceControl() var resolvedTemplatePath: Basics.AbsolutePath diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift index 5c84727436c..2896b0975a7 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift @@ -34,11 +34,7 @@ protocol PackageDependencyBuilder { /// /// - Throws: A `StringError` if required inputs (e.g., Git URL, Package ID) are missing or invalid for the selected /// source type. - func makePackageDependency( - sourceControlRequirement: PackageDependency.SourceControl.Requirement?, - registryRequirement: PackageDependency.Registry.Requirement?, - resolvedTemplatePath: Basics.AbsolutePath - ) throws -> MappablePackageDependency.Kind + func makePackageDependency() throws -> MappablePackageDependency.Kind } /// Default implementation of `PackageDependencyBuilder` that builds a package dependency @@ -58,6 +54,12 @@ struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { /// The registry package identifier, if the template source is registry-based. let templatePackageID: String? + + let sourceControlRequirement: PackageDependency.SourceControl.Requirement? + let registryRequirement: PackageDependency.Registry.Requirement? + let resolvedTemplatePath: Basics.AbsolutePath + + /// Constructs a package dependency kind based on the selected template source. /// /// - Parameters: @@ -68,11 +70,7 @@ struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { /// - Returns: A `MappablePackageDependency.Kind` representing the dependency. /// /// - Throws: A `StringError` if necessary information is missing or mismatched for the selected template source. - func makePackageDependency( - sourceControlRequirement: PackageDependency.SourceControl.Requirement? = nil, - registryRequirement: PackageDependency.Registry.Requirement? = nil, - resolvedTemplatePath: Basics.AbsolutePath - ) throws -> MappablePackageDependency.Kind { + func makePackageDependency() throws -> MappablePackageDependency.Kind { switch self.templateSource { case .local: return .fileSystem(name: self.packageName, path: resolvedTemplatePath.asURL.path) diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift new file mode 100644 index 00000000000..23c44762d1f --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift @@ -0,0 +1,50 @@ + +import Basics + +@_spi(SwiftPMInternal) + +import Workspace +import SPMBuildCore +import TSCBasic +import Foundation +import CoreCommands + +struct TemplateInitializationDirectoryManager { + let fileSystem: FileSystem + + func createTemporaryDirectories() throws -> (Basics.AbsolutePath, Basics.AbsolutePath, Basics.AbsolutePath) { + let tempRoot = try fileSystem.tempDirectory.appending(component: UUID().uuidString) + let stagingPath = tempRoot.appending(component: "generated-package") + let cleanupPath = tempRoot.appending(component: "clean-up") + try fileSystem.createDirectory(tempRoot) + return (stagingPath, cleanupPath, tempRoot) + } + + func finalize( + cwd: Basics.AbsolutePath, + stagingPath: Basics.AbsolutePath, + cleanupPath: Basics.AbsolutePath, + swiftCommandState: SwiftCommandState + ) async throws { + if fileSystem.exists(cwd) { + try fileSystem.removeFileTree(cwd) + } + try fileSystem.copy(from: stagingPath, to: cleanupPath) + _ = try await swiftCommandState.withTemporaryWorkspace(switchingTo: cleanupPath) { _, _ in + try SwiftPackageCommand.Clean().run(swiftCommandState) + } + try fileSystem.copy(from: cleanupPath, to: cwd) + } + + func cleanupTemporary(templateSource: InitTemplatePackage.TemplateSource, path: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) throws { + switch templateSource { + case .git: + try? FileManager.default.removeItem(at: path.asURL) + case .registry: + try? FileManager.default.removeItem(at: path.parentDirectory.asURL) + default: + break + } + try? fileSystem.removeFileTree(tempDir) + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift new file mode 100644 index 00000000000..743a60c8cc9 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -0,0 +1,198 @@ +import ArgumentParser +import ArgumentParserToolInfo + +import Basics + +@_spi(SwiftPMInternal) +import CoreCommands + +import PackageModel +import Workspace +import SPMBuildCore +import TSCBasic +import TSCUtility +import Foundation +import PackageGraph + +protocol PackageInitializer { + func run() async throws +} + +struct TemplatePackageInitializer: PackageInitializer { + let packageName: String + let cwd: Basics.AbsolutePath + let templateSource: InitTemplatePackage.TemplateSource + let templateName: String? + let templateDirectory: Basics.AbsolutePath? + let templateURL: String? + let templatePackageID: String? + let versionResolver: DependencyRequirementResolver + let buildOptions: BuildCommandOptions + let globalOptions: GlobalOptions + let validatePackage: Bool + let args: [String] + let swiftCommandState: SwiftCommandState + + func run() async throws { + let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem) + try precheck() + + let sourceControlRequirement = try? versionResolver.resolveSourceControl() + let registryRequirement = try? versionResolver.resolveRegistry() + + let resolvedTemplatePath = try await TemplatePathResolver( + source: templateSource, + templateDirectory: templateDirectory, + templateURL: templateURL, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() + + let (stagingPath, cleanupPath, tempDir) = try directoryManager.createTemporaryDirectories() + + let initType = try await inferPackageType(from: resolvedTemplatePath) + + let builder = DefaultPackageDependencyBuilder( + templateSource: templateSource, + packageName: packageName, + templateURL: templateURL, + templatePackageID: templatePackageID, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ) + + let templatePackage = try setUpPackage(builder: builder, packageType: initType, stagingPath: stagingPath) + + try await TemplateBuildSupport.build( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + globalOptions: globalOptions, + cwd: stagingPath, + transitiveFolder: stagingPath + ) + + try await TemplatePluginManager( + swiftCommandState: swiftCommandState, + template: templateName, + scratchDirectory: stagingPath, + args: args + ).run(templatePackage) + + try await directoryManager.finalize(cwd: cwd, stagingPath: stagingPath, cleanupPath: cleanupPath, swiftCommandState: swiftCommandState) + + if validatePackage { + try await TemplateBuildSupport.build( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + globalOptions: globalOptions, + cwd: cwd + ) + } + + try directoryManager.cleanupTemporary(templateSource: templateSource, path: resolvedTemplatePath, tempDir: tempDir) + } + + private func precheck() throws { + let manifest = cwd.appending(component: Manifest.filename) + guard !swiftCommandState.fileSystem.exists(manifest) else { + throw InitError.manifestAlreadyExists + } + + if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { + throw ValidationError("The specified template path does not exist: \(dir.pathString)") + } + } + + private func inferPackageType(from templatePath: Basics.AbsolutePath) async throws -> InitPackage.PackageType { + try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { _, _ in + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() + + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope + ) + + guard let manifest = rootManifests.values.first else { + throw InternalError("Invalid manifest in template at \(root.packages)") + } + + for target in manifest.targets { + if templateName == nil || target.name == templateName { + if let options = target.templateInitializationOptions { + if case .packageInit(let type, _, _) = options { + return try .init(from: type) + } + } + } + } + + throw ValidationError("Could not find template \(templateName ?? "")") + } + } + + private func setUpPackage( + builder: DefaultPackageDependencyBuilder, + packageType: InitPackage.PackageType, + stagingPath: Basics.AbsolutePath + ) throws -> InitTemplatePackage { + let templatePackage = try InitTemplatePackage( + name: packageName, + initMode: try builder.makePackageDependency(), + templatePath: builder.resolvedTemplatePath, + fileSystem: swiftCommandState.fileSystem, + packageType: packageType, + supportedTestingLibraries: [], + destinationPath: stagingPath, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration + ) + try swiftCommandState.fileSystem.createDirectory(stagingPath, recursive: true) + try templatePackage.setupTemplateManifest() + return templatePackage + } +} + + + +struct StandardPackageInitializer: PackageInitializer { + let packageName: String + let initMode: String? + let testLibraryOptions: TestLibraryOptions + let cwd: Basics.AbsolutePath + let swiftCommandState: SwiftCommandState + + func run() async throws { + + guard let initModeString = self.initMode else { + throw ValidationError("Specify a package type using the --type option.") + } + guard let knownType = InitPackage.PackageType(rawValue: initModeString) else { + throw ValidationError("Package type \(initModeString) not supported") + } + // Configure testing libraries + var supportedTestingLibraries = Set() + if testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: swiftCommandState) || + (knownType == .macro && testLibraryOptions.isEnabled(.xctest, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.xctest) + } + if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || + (knownType != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { + supportedTestingLibraries.insert(.swiftTesting) + } + + let initPackage = try InitPackage( + name: packageName, + packageType: knownType, + supportedTestingLibraries: supportedTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, + fileSystem: swiftCommandState.fileSystem + ) + initPackage.progressReporter = { message in print(message) } + try initPackage.writePackageStructure() + } +} + diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index 61b2981074a..8e6064bd997 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -14,17 +14,14 @@ import PackageModel import TSCBasic import TSCUtility -/// A protocol defining an interface for resolving package dependency requirements -/// based on a user’s input (such as version, branch, or revision). +/// A protocol defining interfaces for resolving package dependency requirements +/// based on versioning input (e.g., version, branch, or revision). protocol DependencyRequirementResolving { - /// Resolves the requirement for the specified dependency type. - /// - /// - Parameter type: The type of dependency (`.sourceControl` or `.registry`) to resolve. - /// - Returns: A resolved requirement (`SourceControl.Requirement` or `Registry.Requirement`) as `Any`. - /// - Throws: `StringError` if resolution fails due to invalid or conflicting input. - func resolve(for type: DependencyType) throws -> Any + func resolveSourceControl() throws -> PackageDependency.SourceControl.Requirement + func resolveRegistry() throws -> PackageDependency.Registry.Requirement } + /// A utility for resolving a single, well-formed package dependency requirement /// from mutually exclusive versioning inputs, such as: /// - `exact`: A specific version (e.g., 1.2.3) @@ -55,28 +52,12 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// An optional manual upper bound for the version range. Must be used with `from` or `upToNextMinorFrom`. let to: Version? - /// Resolves a concrete requirement based on the provided fields and target dependency type. - /// - /// - Parameter type: The dependency type to resolve (`.sourceControl` or `.registry`). - /// - Returns: A resolved requirement object (`PackageDependency.SourceControl.Requirement` or - /// `PackageDependency.Registry.Requirement`). - /// - Throws: `StringError` if the inputs are invalid, ambiguous, or incomplete. - - func resolve(for type: DependencyType) throws -> Any { - switch type { - case .sourceControl: - try self.resolveSourceControlRequirement() - case .registry: - try self.resolveRegistryRequirement() - } - } - /// Internal helper for resolving a source control (Git) requirement. /// /// - Returns: A valid `PackageDependency.SourceControl.Requirement`. /// - Throws: `StringError` if multiple or no input fields are set, or if `to` is used without `from` or /// `upToNextMinorFrom`. - private func resolveSourceControlRequirement() throws -> PackageDependency.SourceControl.Requirement { + func resolveSourceControl() throws -> PackageDependency.SourceControl.Requirement { var requirements: [PackageDependency.SourceControl.Requirement] = [] if let v = exact { requirements.append(.exact(v)) } if let b = branch { requirements.append(.branch(b)) } @@ -102,7 +83,7 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// - Returns: A valid `PackageDependency.Registry.Requirement`. /// - Throws: `StringError` if more than one registry versioning input is provided or if `to` is used without a base /// range. - private func resolveRegistryRequirement() throws -> PackageDependency.Registry.Requirement { + func resolveRegistry() throws -> PackageDependency.Registry.Requirement { var requirements: [PackageDependency.Registry.Requirement] = [] if let v = exact { requirements.append(.exact(v)) } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift index 314a9cfd7a4..e0985730164 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -17,27 +17,22 @@ import SPMBuildCore import TSCBasic import TSCUtility -/// A utility for building Swift packages using the SwiftPM build system. +/// A utility for building Swift packages templates using the SwiftPM build system. /// /// `TemplateBuildSupport` encapsulates the logic needed to initialize the /// SwiftPM build system and perform a build operation based on a specific /// command configuration and workspace context. enum TemplateBuildSupport { + /// Builds a Swift package using the given command state, options, and working directory. /// - /// This method performs the following steps: - /// 1. Initializes a temporary workspace, optionally switching to a user-specified package directory. - /// 2. Creates a build system with the specified configuration, including product, traits, and build parameters. - /// 3. Resolves the build subset (e.g., targets or products to build). - /// 4. Executes the build within the workspace. - /// /// - Parameters: - /// - swiftCommandState: The current Swift command state, containing context such as the workspace and - /// diagnostics. + /// - swiftCommandState: The current Swift command state, containing context such as the workspace and diagnostics. /// - buildOptions: Options used to configure what and how to build, including the product and traits. /// - globalOptions: Global configuration such as the package directory and logging verbosity. /// - cwd: The current working directory to use if no package directory is explicitly provided. + /// - transitiveFolder: Optional override for the package directory. /// /// - Throws: /// - `ExitCode.failure` if no valid build subset can be resolved or if the build fails due to diagnostics. @@ -49,77 +44,101 @@ enum TemplateBuildSupport { cwd: Basics.AbsolutePath, transitiveFolder: Basics.AbsolutePath? = nil ) async throws { + let packageRoot = transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd - - let buildSystem = try await swiftCommandState.withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in - - try await swiftCommandState.createBuildSystem( - explicitProduct: buildOptions.product, - traitConfiguration: .init(traitOptions: buildOptions.traits), - shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: swiftCommandState.toolsBuildParameters, - outputStream: TSCBasic.stdoutStream - ) - } + let buildSystem = try await makeBuildSystem( + swiftCommandState: swiftCommandState, + folder: packageRoot, + buildOptions: buildOptions + ) guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { throw ExitCode.failure } - try await swiftCommandState.withTemporaryWorkspace(switchingTo: transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd) { _, _ in + try await swiftCommandState.withTemporaryWorkspace(switchingTo: packageRoot) { _, _ in do { try await buildSystem.build(subset: subset) - } catch _ as Diagnostics { + } catch let diagnostics as Diagnostics { + swiftCommandState.observabilityScope.emit(diagnostics) throw ExitCode.failure } } } + /// Builds a Swift package for testing, applying code coverage and PIF graph options. + /// + /// - Parameters: + /// - swiftCommandState: The current Swift command state. + /// - buildOptions: Options used to configure the build. + /// - testingFolder: The path to the folder containing the testable package. + /// + /// - Throws: Errors related to build preparation or diagnostics. static func buildForTesting( swiftCommandState: SwiftCommandState, buildOptions: BuildCommandOptions, testingFolder: Basics.AbsolutePath ) async throws { + let buildSystem = try await makeBuildSystem( + swiftCommandState: swiftCommandState, + folder: testingFolder, + buildOptions: buildOptions, + forTesting: true + ) - var productsBuildParameters = try swiftCommandState.productsBuildParameters - var toolsBuildParameters = try swiftCommandState.toolsBuildParameters - - if buildOptions.enableCodeCoverage { - productsBuildParameters.testingParameters.enableCodeCoverage = true - toolsBuildParameters.testingParameters.enableCodeCoverage = true - } - - if buildOptions.printPIFManifestGraphviz { - productsBuildParameters.printPIFManifestGraphviz = true - toolsBuildParameters.printPIFManifestGraphviz = true + guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { + throw ExitCode.failure } + try await swiftCommandState.withTemporaryWorkspace(switchingTo: testingFolder) { _, _ in + do { + try await buildSystem.build(subset: subset) + } catch let diagnostics as Diagnostics { + swiftCommandState.observabilityScope.emit(diagnostics) + throw ExitCode.failure + } + } + } - let buildSystem = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: testingFolder) { _, _ in - try await swiftCommandState.createBuildSystem( - explicitProduct: buildOptions.product, - traitConfiguration: .init(traitOptions: buildOptions.traits), - shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, - productsBuildParameters: swiftCommandState.productsBuildParameters, - toolsBuildParameters: swiftCommandState.toolsBuildParameters, - outputStream: TSCBasic.stdoutStream - ) + /// Internal helper to create a `BuildSystem` with appropriate parameters. + /// + /// - Parameters: + /// - swiftCommandState: The active command context. + /// - folder: The directory to switch into for workspace operations. + /// - buildOptions: Build configuration options. + /// - forTesting: Whether to apply test-specific parameters (like code coverage). + /// + /// - Returns: A configured `BuildSystem` instance ready to build. + private static func makeBuildSystem( + swiftCommandState: SwiftCommandState, + folder: Basics.AbsolutePath, + buildOptions: BuildCommandOptions, + forTesting: Bool = false + ) async throws -> BuildSystem { + var productsParams = try swiftCommandState.productsBuildParameters + var toolsParams = try swiftCommandState.toolsBuildParameters + + if forTesting { + if buildOptions.enableCodeCoverage { + productsParams.testingParameters.enableCodeCoverage = true + toolsParams.testingParameters.enableCodeCoverage = true } - guard let subset = buildOptions.buildSubset(observabilityScope: swiftCommandState.observabilityScope) else { - throw ExitCode.failure + if buildOptions.printPIFManifestGraphviz { + productsParams.printPIFManifestGraphviz = true + toolsParams.printPIFManifestGraphviz = true + } } - try await swiftCommandState - .withTemporaryWorkspace(switchingTo: testingFolder) { _, _ in - do { - try await buildSystem.build(subset: subset) - } catch _ as Diagnostics { - throw ExitCode.failure - } - } + return try await swiftCommandState.withTemporaryWorkspace(switchingTo: folder) { _, _ in + try await swiftCommandState.createBuildSystem( + explicitProduct: buildOptions.product, + traitConfiguration: .init(traitOptions: buildOptions.traits), + shouldLinkStaticSwiftStdlib: buildOptions.shouldLinkStaticSwiftStdlib, + productsBuildParameters: productsParams, + toolsBuildParameters: toolsParams, + outputStream: TSCBasic.stdoutStream + ) + } } - } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index be18d97df36..123398acc61 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -10,6 +10,7 @@ // //===----------------------------------------------------------------------===// +//TODO: needs review import ArgumentParser import Basics import CoreCommands @@ -142,42 +143,35 @@ struct GitTemplateFetcher: TemplateFetcher { /// Fetches a bare clone of the Git repository to the specified path. func fetch() async throws -> Basics.AbsolutePath { - let fetchStandalonePackageByURL = { () async throws -> Basics.AbsolutePath in - try withTemporaryDirectory(removeTreeOnDeinit: false) { (tempDir: Basics.AbsolutePath) in + try withTemporaryDirectory(removeTreeOnDeinit: false) { tempDir in + + let url = SourceControlURL(source) + let repositorySpecifier = RepositorySpecifier(url: url) + let repositoryProvider = GitRepositoryProvider() - let url = SourceControlURL(source) - let repositorySpecifier = RepositorySpecifier(url: url) - let repositoryProvider = GitRepositoryProvider() + let bareCopyPath = tempDir.appending(component: "bare-copy") + let workingCopyPath = tempDir.appending(component: "working-copy") - let bareCopyPath = tempDir.appending(component: "bare-copy") + try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) + try self.validateDirectory(provider: repositoryProvider, at: bareCopyPath) - let workingCopyPath = tempDir.appending(component: "working-copy") - - try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) - - try self.validateDirectory(provider: repositoryProvider, at: bareCopyPath) - - try FileManager.default.createDirectory( - atPath: workingCopyPath.pathString, - withIntermediateDirectories: true - ) - - let repository = try repositoryProvider.createWorkingCopyFromBare( - repository: repositorySpecifier, - sourcePath: bareCopyPath, - at: workingCopyPath, - editable: true - ) + try FileManager.default.createDirectory( + atPath: workingCopyPath.pathString, + withIntermediateDirectories: true + ) - try FileManager.default.removeItem(at: bareCopyPath.asURL) + let repository = try repositoryProvider.createWorkingCopyFromBare( + repository: repositorySpecifier, + sourcePath: bareCopyPath, + at: workingCopyPath, + editable: true + ) - try self.checkout(repository: repository) + try FileManager.default.removeItem(at: bareCopyPath.asURL) + try self.checkout(repository: repository) - return workingCopyPath - } + return workingCopyPath } - - return try await fetchStandalonePackageByURL() } /// Validates that the directory contains a valid Git repository. diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift new file mode 100644 index 00000000000..6e81f388f4c --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -0,0 +1,91 @@ + +import ArgumentParser +import ArgumentParserToolInfo + +import Basics + +@_spi(SwiftPMInternal) +import CoreCommands + +import PackageModel +import Workspace +import SPMBuildCore +import TSCBasic +import TSCUtility +import Foundation +import PackageGraph + + +struct TemplatePluginManager { + let swiftCommandState: SwiftCommandState + let template: String? + + let packageGraph: ModulesGraph + + let scratchDirectory: Basics.AbsolutePath + + let args: [String] + + init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String]) async throws { + self.swiftCommandState = swiftCommandState + self.template = template + self.scratchDirectory = scratchDirectory + self.args = args + + self.packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: scratchDirectory) { _, _ in + try await swiftCommandState.loadPackageGraph() + } + } + //revisit for future refactoring + func run(_ initTemplatePackage: InitTemplatePackage) async throws { + + let commandLinePlugin = try loadTemplatePlugin() + + let output = try await TemplatePluginRunner.run( + plugin: commandLinePlugin, + package: self.packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: ["--", "--experimental-dump-help"], + swiftCommandState: swiftCommandState + ) + let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + let cliResponses = try initTemplatePackage.promptUser(command: toolInfo.command, arguments: args) + + for response in cliResponses { + _ = try await TemplatePluginRunner.run( + plugin: commandLinePlugin, + package: packageGraph.rootPackages.first!, + packageGraph: packageGraph, + arguments: response, + swiftCommandState: swiftCommandState + ) + } + + } + + private func loadTemplatePlugin() throws -> ResolvedModule { + + let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: self.packageGraph, limitedTo: nil) + + guard let commandPlugin = matchingPlugins.first else { + guard let template = template + else { throw ValidationError("No templates were found in \(packageGraph.rootPackages.first!.path)") } //better error message + + throw ValidationError("No templates were found that match the name \(template)") + } + + guard matchingPlugins.count == 1 else { + let templateNames = matchingPlugins.compactMap { module in + let plugin = module.underlying as! PluginModule + guard case .command(let intent, _) = plugin.capability else { return String?.none } + + return intent.invocationVerb + } + throw ValidationError( + "More than one template was found in the package. Please use `--type` along with one of the available templates: \(templateNames.joined(separator: ", "))" + ) + } + + return commandPlugin + } +} diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 1dc1421ddcc..9073cbfda79 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -85,7 +85,7 @@ public final class InitTemplatePackage { } /// The type of template source. - public enum TemplateSource: String, CustomStringConvertible, Decodable { + public enum TemplateSource: String, CustomStringConvertible { case local case git case registry From a29d9302daddc799456493f9e650f284cf772ee7 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 13 Aug 2025 09:55:02 -0400 Subject: [PATCH 201/225] refactoring + error handling --- Sources/Commands/PackageCommands/Init.swift | 184 +++++++++++++----- .../PackageCommands/ShowTemplates.swift | 2 +- Sources/Commands/SwiftTestCommand.swift | 18 +- .../PackageDependencyBuilder.swift | 23 +++ ...ackageInitializationDirectoryManager.swift | 77 ++++++-- .../PackageInitializer.swift | 57 +++++- .../RequirementResolver.swift | 69 +++++-- .../TemplatePathResolver.swift | 165 ++++++++++++---- .../TemplatePluginManager.swift | 184 ++++++++++++++---- Sources/Workspace/InitTemplatePackage.swift | 10 +- 10 files changed, 603 insertions(+), 186 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index e1eddb1770d..0bc142f9c0f 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -121,66 +121,34 @@ extension SwiftPackageCommand { var createPackagePath = true func run(_ swiftCommandState: SwiftCommandState) async throws { - - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") - } //Should this be refactored? - - let name = packageName ?? cwd.basename - - - var templateSourceResolver: TemplateSourceResolver = DefaultTemplateSourceResolver() + let versionFlags = VersionFlags( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ) - let templateSource = templateSourceResolver.resolveSource( + let state = try PackageInitConfiguration( + swiftCommandState: swiftCommandState, + name: packageName, + initMode: initMode, + testLibraryOptions: testLibraryOptions, + buildOptions: buildOptions, + globalOptions: globalOptions, + validatePackage: validatePackage, + args: args, directory: templateDirectory, url: templateURL, - packageID: templatePackageID + packageID: templatePackageID, + versionFlags: versionFlags ) - if templateSource == nil, let initMode { - guard let _ = InitPackage.PackageType(rawValue: initMode) else { - throw ValidationError("Unknown package type: '\(initMode)'") - } - } - - if let source = templateSource { - let versionResolver = DependencyRequirementResolver( - exact: exact, - revision: revision, - branch: branch, - from: from, - upToNextMinorFrom: upToNextMinorFrom, - to: to - ) - - let initializer = TemplatePackageInitializer( - packageName: name, - cwd: cwd, - templateSource: source, - templateName: initMode, - templateDirectory: templateDirectory, - templateURL: templateURL, - templatePackageID: templatePackageID, - versionResolver: versionResolver, - buildOptions: buildOptions, - globalOptions: globalOptions, - validatePackage: validatePackage, - args: args, - swiftCommandState: swiftCommandState - ) - try await initializer.run() - } else { - let initializer = StandardPackageInitializer( - packageName: name, - initMode: initMode, - testLibraryOptions: testLibraryOptions, - cwd: cwd, - swiftCommandState: swiftCommandState - ) - try await initializer.run() - } + let initializer = try state.makeInitializer() + try await initializer.run() } - + init() { } @@ -208,6 +176,114 @@ extension InitPackage.PackageType { } } + +struct PackageInitConfiguration { + let packageName: String + let cwd: Basics.AbsolutePath + let swiftCommandState: SwiftCommandState + let initMode: String? + let templateSource: InitTemplatePackage.TemplateSource? + let testLibraryOptions: TestLibraryOptions + let buildOptions: BuildCommandOptions? + let globalOptions: GlobalOptions? + let validatePackage: Bool? + let args: [String] + let versionResolver: DependencyRequirementResolver? + + init( + swiftCommandState: SwiftCommandState, + name: String?, + initMode: String?, + testLibraryOptions: TestLibraryOptions, + buildOptions: BuildCommandOptions, + globalOptions: GlobalOptions, + validatePackage: Bool, + args: [String], + directory: Basics.AbsolutePath?, + url: String?, + packageID: String?, + versionFlags: VersionFlags + ) throws { + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") + } + + self.cwd = cwd + self.packageName = name ?? cwd.basename + self.swiftCommandState = swiftCommandState + self.initMode = initMode + self.testLibraryOptions = testLibraryOptions + self.buildOptions = buildOptions + self.globalOptions = globalOptions + self.validatePackage = validatePackage + self.args = args + + let sourceResolver = DefaultTemplateSourceResolver() + self.templateSource = sourceResolver.resolveSource( + directory: directory, + url: url, + packageID: packageID + ) + + if templateSource != nil { + self.versionResolver = DependencyRequirementResolver( + exact: versionFlags.exact, + revision: versionFlags.revision, + branch: versionFlags.branch, + from: versionFlags.from, + upToNextMinorFrom: versionFlags.upToNextMinorFrom, + to: versionFlags.to + ) + } else { + self.versionResolver = nil + } + } + + func makeInitializer() throws -> PackageInitializer { + if let templateSource = templateSource, + let versionResolver = versionResolver, + let buildOptions = buildOptions, + let globalOptions = globalOptions, + let validatePackage = validatePackage { + + return TemplatePackageInitializer( + packageName: packageName, + cwd: cwd, + templateSource: templateSource, + templateName: initMode, + templateDirectory: nil, + templateURL: nil, + templatePackageID: nil, + versionResolver: versionResolver, + buildOptions: buildOptions, + globalOptions: globalOptions, + validatePackage: validatePackage, + args: args, + swiftCommandState: swiftCommandState + ) + } else { + return StandardPackageInitializer( + packageName: packageName, + initMode: initMode, + testLibraryOptions: testLibraryOptions, + cwd: cwd, + swiftCommandState: swiftCommandState + ) + } + } +} + + +struct VersionFlags { + let exact: Version? + let revision: String? + let branch: String? + let from: Version? + let upToNextMinorFrom: Version? + let to: Version? +} + + protocol TemplateSourceResolver { func resolveSource( directory: Basics.AbsolutePath?, diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index ac50286ff19..5c840cf30d5 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -106,7 +106,7 @@ struct ShowTemplates: AsyncSwiftCommand { templateSource = .git - } else if let packageID = self.templatePackageID { + } else if let _ = self.templatePackageID { // Download and resolve the Git-based template. resolvedTemplatePath = try await TemplatePathResolver( source: .registry, diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index 92e68b555bb..4845eeb82d8 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -714,7 +714,7 @@ final class ArgumentTreeNode { let command: CommandInfoV0 var children: [ArgumentTreeNode] = [] - var arguments: [String: InitTemplatePackage.ArgumentResponse] = [:] + var arguments: [String: TemplatePromptingSystem.ArgumentResponse] = [:] init(command: CommandInfoV0) { self.command = command @@ -748,18 +748,18 @@ final class ArgumentTreeNode { static func promptForUniqueArguments( uniqueArguments: [String: ArgumentInfoV0] - ) -> [String: InitTemplatePackage.ArgumentResponse] { - var collected: [String: InitTemplatePackage.ArgumentResponse] = [:] + ) -> [String: TemplatePromptingSystem.ArgumentResponse] { + var collected: [String: TemplatePromptingSystem.ArgumentResponse] = [:] let argsToPrompt = Array(uniqueArguments.values) // Prompt for all unique arguments at once - _ = InitTemplatePackage.UserPrompter.prompt(for: argsToPrompt, collected: &collected) + _ = TemplatePromptingSystem.UserPrompter.prompt(for: argsToPrompt, collected: &collected) return collected } //Fill node arguments by assigning the prompted values for keys it requires - func fillArguments(with responses: [String: InitTemplatePackage.ArgumentResponse]) { + func fillArguments(with responses: [String: TemplatePromptingSystem.ArgumentResponse]) { if let args = command.arguments { for arg in args { if let resp = responses[arg.valueName ?? ""] { @@ -808,8 +808,8 @@ extension ArgumentTreeNode { /// Traverses all command paths and returns CLI paths along with their arguments func collectCommandPaths( currentPath: [String] = [], - currentArguments: [String: InitTemplatePackage.ArgumentResponse] = [:] - ) -> [([String], [String: InitTemplatePackage.ArgumentResponse])] { + currentArguments: [String: TemplatePromptingSystem.ArgumentResponse] = [:] + ) -> [([String], [String: TemplatePromptingSystem.ArgumentResponse])] { let newPath = currentPath + [command.commandName] var combinedArguments = currentArguments @@ -821,7 +821,7 @@ extension ArgumentTreeNode { return [(newPath, combinedArguments)] } - var results: [([String], [String: InitTemplatePackage.ArgumentResponse])] = [] + var results: [([String], [String: TemplatePromptingSystem.ArgumentResponse])] = [] for child in children { results += child.collectCommandPaths( currentPath: newPath, @@ -932,7 +932,7 @@ extension SwiftTestCommand { return intent.invocationVerb } throw ValidationError( - "More than one template was found in the package. Please use `--type` along with one of the available templates: \(templateNames.joined(separator: ", "))" + "More than one template was found in the package. Please use `--template-name` along with one of the available templates: \(templateNames.joined(separator: ", "))" ) } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift index 2896b0975a7..b3b18d4a782 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift @@ -94,4 +94,27 @@ struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { return .registry(id: id, requirement: requirement) } } + + + /// Errors thrown by `TemplatePathResolver` during initialization. + enum PackageDependencyBuilderError: LocalizedError, Equatable { + case missingGitURL + case missingGitRequirement + case missingRegistryIdentity + case missingRegistryRequirement + + var errorDescription: String? { + switch self { + case .missingGitURL: + return "Missing Git URL for git template." + case .missingGitRequirement: + return "Missing version requirement for template in git." + case .missingRegistryIdentity: + return "Missing registry package identity for template in registry." + case .missingRegistryRequirement: + return "Missing version requirement for template in registry ." + } + } + } + } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift index 23c44762d1f..7e5f8b0a781 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift @@ -1,23 +1,21 @@ import Basics -@_spi(SwiftPMInternal) import Workspace -import SPMBuildCore -import TSCBasic import Foundation import CoreCommands + struct TemplateInitializationDirectoryManager { let fileSystem: FileSystem - func createTemporaryDirectories() throws -> (Basics.AbsolutePath, Basics.AbsolutePath, Basics.AbsolutePath) { - let tempRoot = try fileSystem.tempDirectory.appending(component: UUID().uuidString) - let stagingPath = tempRoot.appending(component: "generated-package") - let cleanupPath = tempRoot.appending(component: "clean-up") - try fileSystem.createDirectory(tempRoot) - return (stagingPath, cleanupPath, tempRoot) + func createTemporaryDirectories() throws -> (stagingPath: Basics.AbsolutePath, cleanUpPath: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) { + let tempDir = try fileSystem.tempDirectory.appending(component: UUID().uuidString) + let stagingPath = tempDir.appending(component: "generated-package") + let cleanupPath = tempDir.appending(component: "clean-up") + try fileSystem.createDirectory(tempDir) + return (stagingPath, cleanupPath, tempDir) } func finalize( @@ -27,24 +25,63 @@ struct TemplateInitializationDirectoryManager { swiftCommandState: SwiftCommandState ) async throws { if fileSystem.exists(cwd) { - try fileSystem.removeFileTree(cwd) + do { + try fileSystem.removeFileTree(cwd) + } catch { + throw FileOperationError.failedToRemoveExistingDirectory(path: cwd, underlying: error) + } } try fileSystem.copy(from: stagingPath, to: cleanupPath) - _ = try await swiftCommandState.withTemporaryWorkspace(switchingTo: cleanupPath) { _, _ in + try await cleanBuildArtifacts(at: cleanupPath, swiftCommandState: swiftCommandState) + try fileSystem.copy(from: cleanupPath, to: cwd) + } + + func cleanBuildArtifacts(at path: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) async throws { + _ = try await swiftCommandState.withTemporaryWorkspace(switchingTo: path) { _, _ in try SwiftPackageCommand.Clean().run(swiftCommandState) } - try fileSystem.copy(from: cleanupPath, to: cwd) } func cleanupTemporary(templateSource: InitTemplatePackage.TemplateSource, path: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) throws { - switch templateSource { - case .git: - try? FileManager.default.removeItem(at: path.asURL) - case .registry: - try? FileManager.default.removeItem(at: path.parentDirectory.asURL) - default: - break + do { + switch templateSource { + case .git: + if FileManager.default.fileExists(atPath: path.pathString) { + try FileManager.default.removeItem(at: path.asURL) + } + case .registry: + if FileManager.default.fileExists(atPath: path.pathString) { + try FileManager.default.removeItem(at: path.asURL) + } + case .local: + break + } + try fileSystem.removeFileTree(tempDir) + } catch { + throw CleanupError.failedToCleanup(tempDir: tempDir, underlying: error) + } + } + + enum CleanupError: Error, CustomStringConvertible { + case failedToCleanup(tempDir: Basics.AbsolutePath, underlying: Error) + + var description: String { + switch self { + case .failedToCleanup(let tempDir, let error): + return "Failed to clean up temporary directory at \(tempDir): \(error.localizedDescription)" + } } - try? fileSystem.removeFileTree(tempDir) } + + enum FileOperationError: Error, CustomStringConvertible { + case failedToRemoveExistingDirectory(path: Basics.AbsolutePath, underlying: Error) + + var description: String { + switch self { + case .failedToRemoveExistingDirectory(let path, let underlying): + return "Failed to remove existing directory at \(path): \(underlying.localizedDescription)" + } + } + } + } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index 743a60c8cc9..ea030c04416 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -34,9 +34,9 @@ struct TemplatePackageInitializer: PackageInitializer { let swiftCommandState: SwiftCommandState func run() async throws { - let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem) try precheck() + // Resolve version requirements let sourceControlRequirement = try? versionResolver.resolveSourceControl() let registryRequirement = try? versionResolver.resolveRegistry() @@ -50,9 +50,10 @@ struct TemplatePackageInitializer: PackageInitializer { swiftCommandState: swiftCommandState ).resolve() + let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem) let (stagingPath, cleanupPath, tempDir) = try directoryManager.createTemporaryDirectories() - let initType = try await inferPackageType(from: resolvedTemplatePath) + let packageType = try await inferPackageType(from: resolvedTemplatePath) let builder = DefaultPackageDependencyBuilder( templateSource: templateSource, @@ -64,7 +65,10 @@ struct TemplatePackageInitializer: PackageInitializer { resolvedTemplatePath: resolvedTemplatePath ) - let templatePackage = try setUpPackage(builder: builder, packageType: initType, stagingPath: stagingPath) + + let templatePackage = try setUpPackage(builder: builder, packageType: packageType, stagingPath: stagingPath) + + swiftCommandState.observabilityScope.emit(debug: "Set up initial package: \(templatePackage.packageName)") try await TemplateBuildSupport.build( swiftCommandState: swiftCommandState, @@ -74,12 +78,12 @@ struct TemplatePackageInitializer: PackageInitializer { transitiveFolder: stagingPath ) - try await TemplatePluginManager( + try await TemplateInitializationPluginManager( swiftCommandState: swiftCommandState, template: templateName, scratchDirectory: stagingPath, args: args - ).run(templatePackage) + ).run() try await directoryManager.finalize(cwd: cwd, stagingPath: stagingPath, cleanupPath: cleanupPath, swiftCommandState: swiftCommandState) @@ -102,7 +106,7 @@ struct TemplatePackageInitializer: PackageInitializer { } if let dir = templateDirectory, !swiftCommandState.fileSystem.exists(dir) { - throw ValidationError("The specified template path does not exist: \(dir.pathString)") + throw TemplatePackageInitializerError.templateDirectoryNotFound(dir.pathString) } } @@ -117,7 +121,7 @@ struct TemplatePackageInitializer: PackageInitializer { ) guard let manifest = rootManifests.values.first else { - throw InternalError("Invalid manifest in template at \(root.packages)") + throw TemplatePackageInitializerError.invalidManifestInTemplate(root.packages.description) } for target in manifest.targets { @@ -130,7 +134,7 @@ struct TemplatePackageInitializer: PackageInitializer { } } - throw ValidationError("Could not find template \(templateName ?? "")") + throw TemplatePackageInitializerError.templateNotFound(templateName ?? "") } } @@ -153,6 +157,24 @@ struct TemplatePackageInitializer: PackageInitializer { try templatePackage.setupTemplateManifest() return templatePackage } + + enum TemplatePackageInitializerError: Error, CustomStringConvertible { + case templateDirectoryNotFound(String) + case invalidManifestInTemplate(String) + case templateNotFound(String) + + var description: String { + switch self { + case .templateDirectoryNotFound(let path): + return "The specified template path does not exist: \(path)" + case .invalidManifestInTemplate(let path): + return "Invalid manifest found in template at \(path)." + case .templateNotFound(let templateName): + return "Could not find template \(templateName)." + } + } + } + } @@ -167,10 +189,10 @@ struct StandardPackageInitializer: PackageInitializer { func run() async throws { guard let initModeString = self.initMode else { - throw ValidationError("Specify a package type using the --type option.") + throw StandardPackageInitializerError.missingInitMode } guard let knownType = InitPackage.PackageType(rawValue: initModeString) else { - throw ValidationError("Package type \(initModeString) not supported") + throw StandardPackageInitializerError.unsupportedPackageType(initModeString) } // Configure testing libraries var supportedTestingLibraries = Set() @@ -194,5 +216,20 @@ struct StandardPackageInitializer: PackageInitializer { initPackage.progressReporter = { message in print(message) } try initPackage.writePackageStructure() } + + enum StandardPackageInitializerError: Error, CustomStringConvertible { + case missingInitMode + case unsupportedPackageType(String) + + var description: String { + switch self { + case .missingInitMode: + return "Specify a package type using the --type option." + case .unsupportedPackageType(let type): + return "Package type '\(type)' is not supported." + } + } + } + } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index 8e6064bd997..02883294ea8 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -58,24 +58,28 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// - Throws: `StringError` if multiple or no input fields are set, or if `to` is used without `from` or /// `upToNextMinorFrom`. func resolveSourceControl() throws -> PackageDependency.SourceControl.Requirement { - var requirements: [PackageDependency.SourceControl.Requirement] = [] - if let v = exact { requirements.append(.exact(v)) } - if let b = branch { requirements.append(.branch(b)) } - if let r = revision { requirements.append(.revision(r)) } - if let f = from { requirements.append(.range(.upToNextMajor(from: f))) } - if let u = upToNextMinorFrom { requirements.append(.range(.upToNextMinor(from: u))) } - - guard requirements.count == 1, let requirement = requirements.first else { - throw StringError("Specify exactly one source control version requirement.") + var specifiedRequirements: [PackageDependency.SourceControl.Requirement] = [] + if let v = exact { specifiedRequirements.append(.exact(v)) } + if let b = branch { specifiedRequirements.append(.branch(b)) } + if let r = revision { specifiedRequirements.append(.revision(r)) } + if let f = from { specifiedRequirements.append(.range(.upToNextMajor(from: f))) } + if let u = upToNextMinorFrom { specifiedRequirements.append(.range(.upToNextMinor(from: u))) } + + guard !specifiedRequirements.isEmpty else { + throw DependencyRequirementError.noRequirementSpecified } - if case .range(let range) = requirement, let upper = to { + guard specifiedRequirements.count == 1, let specifiedRequirements = specifiedRequirements.first else { + throw DependencyRequirementError.multipleRequirementsSpecified + } + + if case .range(let range) = specifiedRequirements, let upper = to { return .range(range.lowerBound ..< upper) } else if self.to != nil { - throw StringError("--to requires --from or --up-to-next-minor-from") + throw DependencyRequirementError.invalidToParameterWithoutFrom } - return requirement + return specifiedRequirements } /// Internal helper for resolving a registry-based requirement. @@ -84,23 +88,28 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// - Throws: `StringError` if more than one registry versioning input is provided or if `to` is used without a base /// range. func resolveRegistry() throws -> PackageDependency.Registry.Requirement { - var requirements: [PackageDependency.Registry.Requirement] = [] - if let v = exact { requirements.append(.exact(v)) } - if let f = from { requirements.append(.range(.upToNextMajor(from: f))) } - if let u = upToNextMinorFrom { requirements.append(.range(.upToNextMinor(from: u))) } + var specifiedRequirements: [PackageDependency.Registry.Requirement] = [] + + if let v = exact { specifiedRequirements.append(.exact(v)) } + if let f = from { specifiedRequirements.append(.range(.upToNextMajor(from: f))) } + if let u = upToNextMinorFrom { specifiedRequirements.append(.range(.upToNextMinor(from: u))) } + + guard !specifiedRequirements.isEmpty else { + throw DependencyRequirementError.noRequirementSpecified + } - guard requirements.count == 1, let requirement = requirements.first else { - throw StringError("Specify exactly one source control version requirement.") + guard specifiedRequirements.count == 1, let specifiedRequirements = specifiedRequirements.first else { + throw DependencyRequirementError.multipleRequirementsSpecified } - if case .range(let range) = requirement, let upper = to { + if case .range(let range) = specifiedRequirements, let upper = to { return .range(range.lowerBound ..< upper) } else if self.to != nil { - throw StringError("--to requires --from or --up-to-next-minor-from") + throw DependencyRequirementError.invalidToParameterWithoutFrom } - return requirement + return specifiedRequirements } } @@ -111,3 +120,21 @@ enum DependencyType { /// A registry dependency, typically resolved from a package registry. case registry } + +enum DependencyRequirementError: Error, CustomStringConvertible { + case multipleRequirementsSpecified + case noRequirementSpecified + case invalidToParameterWithoutFrom + + var description: String { + switch self { + case .multipleRequirementsSpecified: + return "Specify exactly one source control version requirement." + case .noRequirementSpecified: + return "No source control version requirement specified." + case .invalidToParameterWithoutFrom: + return "--to requires --from or --up-to-next-minor-from" + } + } +} + diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index 123398acc61..74565b1b2a7 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -74,19 +74,19 @@ struct TemplatePathResolver { switch source { case .local: guard let path = templateDirectory else { - throw StringError("Template path must be specified for local templates.") + throw TemplatePathResolverError.missingLocalTemplatePath } self.fetcher = LocalTemplateFetcher(path: path) case .git: guard let url = templateURL, let requirement = sourceControlRequirement else { - throw StringError("Missing Git URL or requirement for git template.") + throw TemplatePathResolverError.missingGitURLOrRequirement } self.fetcher = GitTemplateFetcher(source: url, requirement: requirement) case .registry: guard let identity = packageIdentity, let requirement = registryRequirement else { - throw StringError("Missing registry package identity or requirement.") + throw TemplatePathResolverError.missingRegistryIdentityOrRequirement } self.fetcher = RegistryTemplateFetcher( swiftCommandState: swiftCommandState, @@ -95,7 +95,7 @@ struct TemplatePathResolver { ) case .none: - throw StringError("Missing --template-type.") + throw TemplatePathResolverError.missingTemplateType } } @@ -106,6 +106,27 @@ struct TemplatePathResolver { func resolve() async throws -> Basics.AbsolutePath { try await self.fetcher.fetch() } + + /// Errors thrown by `TemplatePathResolver` during initialization. + enum TemplatePathResolverError: LocalizedError, Equatable { + case missingLocalTemplatePath + case missingGitURLOrRequirement + case missingRegistryIdentityOrRequirement + case missingTemplateType + + var errorDescription: String? { + switch self { + case .missingLocalTemplatePath: + return "Template path must be specified for local templates." + case .missingGitURLOrRequirement: + return "Missing Git URL or requirement for git template." + case .missingRegistryIdentityOrRequirement: + return "Missing registry package identity or requirement." + case .missingTemplateType: + return "Missing --template-type." + } + } + } } /// Fetcher implementation for local file system templates. @@ -144,43 +165,69 @@ struct GitTemplateFetcher: TemplateFetcher { /// Fetches a bare clone of the Git repository to the specified path. func fetch() async throws -> Basics.AbsolutePath { try withTemporaryDirectory(removeTreeOnDeinit: false) { tempDir in - - let url = SourceControlURL(source) - let repositorySpecifier = RepositorySpecifier(url: url) - let repositoryProvider = GitRepositoryProvider() - let bareCopyPath = tempDir.appending(component: "bare-copy") let workingCopyPath = tempDir.appending(component: "working-copy") - try repositoryProvider.fetch(repository: repositorySpecifier, to: bareCopyPath) - try self.validateDirectory(provider: repositoryProvider, at: bareCopyPath) + + try cloneBareRepository(into: bareCopyPath) + try validateBareRepository(at: bareCopyPath) try FileManager.default.createDirectory( atPath: workingCopyPath.pathString, withIntermediateDirectories: true ) - let repository = try repositoryProvider.createWorkingCopyFromBare( - repository: repositorySpecifier, - sourcePath: bareCopyPath, - at: workingCopyPath, - editable: true - ) - + let repository = try createWorkingCopy(fromBare: bareCopyPath, at: workingCopyPath) try FileManager.default.removeItem(at: bareCopyPath.asURL) - try self.checkout(repository: repository) + + try checkout(repository: repository) return workingCopyPath } } + /// Clones a bare git repository. + /// + /// - Throws: An error is thrown if fetching fails. + private func cloneBareRepository(into path: Basics.AbsolutePath) throws { + let url = SourceControlURL(source) + let repositorySpecifier = RepositorySpecifier(url: url) + let provider = GitRepositoryProvider() + do { + try provider.fetch(repository: repositorySpecifier, to: path) + } catch { + throw GitTemplateFetcherError.cloneFailed(source: source, underlyingError: error) + } + } + /// Validates that the directory contains a valid Git repository. - private func validateDirectory(provider: GitRepositoryProvider, at path: Basics.AbsolutePath) throws { + private func validateBareRepository(at path: Basics.AbsolutePath) throws { + let provider = GitRepositoryProvider() guard try provider.isValidDirectory(path) else { - throw InternalError("Invalid directory at \(path)") + throw GitTemplateFetcherError.invalidRepositoryDirectory(path: path) + } + } + + /// Creates a working copy from a bare directory. + /// + /// - Throws: An error. + private func createWorkingCopy(fromBare barePath: Basics.AbsolutePath, at workingCopyPath: Basics.AbsolutePath) throws -> WorkingCheckout { + let url = SourceControlURL(source) + let repositorySpecifier = RepositorySpecifier(url: url) + let provider = GitRepositoryProvider() + do { + return try provider.createWorkingCopyFromBare( + repository: repositorySpecifier, + sourcePath: barePath, + at: workingCopyPath, + editable: true + ) + } catch { + throw GitTemplateFetcherError.createWorkingCopyFailed(path: workingCopyPath, underlyingError: error) } } + /// Checks out the desired state (branch, tag, revision) in the working copy based on the requirement. /// /// - Throws: An error if no matching version is found in a version range, or if checkout fails. @@ -200,11 +247,35 @@ struct GitTemplateFetcher: TemplateFetcher { let versions = tags.compactMap { Version($0) } let filteredVersions = versions.filter { range.contains($0) } guard let latestVersion = filteredVersions.max() else { - throw InternalError("No tags found within the specified version range \(range)") + throw GitTemplateFetcherError.noMatchingTagInRange(range) } try repository.checkout(tag: latestVersion.description) } } + + enum GitTemplateFetcherError: Error, LocalizedError { + case cloneFailed(source: String, underlyingError: Error) + case invalidRepositoryDirectory(path: Basics.AbsolutePath) + case createWorkingCopyFailed(path: Basics.AbsolutePath, underlyingError: Error) + case checkoutFailed(requirement: PackageDependency.SourceControl.Requirement, underlyingError: Error) + case noMatchingTagInRange(Range) + + var errorDescription: String? { + switch self { + case .cloneFailed(let source, let error): + return "Failed to clone repository from '\(source)': \(error.localizedDescription)" + case .invalidRepositoryDirectory(let path): + return "Invalid Git repository at path: \(path.pathString)" + case .createWorkingCopyFailed(let path, let error): + return "Failed to create working copy at '\(path)': \(error.localizedDescription)" + case .checkoutFailed(let requirement, let error): + return "Failed to checkout using requirement '\(requirement)': \(error.localizedDescription)" + case .noMatchingTagInRange(let range): + return "No Git tags found within version range \(range)" + } + } + } + } /// Fetches a Swift package template from a package registry. @@ -249,11 +320,6 @@ struct RegistryTemplateFetcher: TemplateFetcher { let identity = PackageIdentity.plain(self.packageIdentity) - let version: Version = switch self.requirement { - case .exact(let ver): ver - case .range(let range): range.upperBound - } - let dest = tempDir.appending(component: self.packageIdentity) try await registryClient.downloadSourceArchive( package: identity, @@ -269,19 +335,48 @@ struct RegistryTemplateFetcher: TemplateFetcher { } } + /// Extract the version from the registry requirements + private var version: Version { + switch requirement { + case .exact(let v): return v + case .range(let r): return r.upperBound + } + } + + /// Resolves the registry configuration from shared SwiftPM configuration. /// /// - Returns: Registry configuration to use for fetching packages. - /// - Throws: If configuration files are missing or unreadable. + /// - Throws: If configurations are missing or unreadable. private static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace - .Configuration.Registries - { + .Configuration.Registries { let sharedFile = Workspace.DefaultLocations .registriesConfigurationFile(at: swiftCommandState.sharedConfigurationDirectory) - return try .init( - fileSystem: swiftCommandState.fileSystem, - localRegistriesFile: .none, - sharedRegistriesFile: sharedFile - ) + do { + return try .init( + fileSystem: swiftCommandState.fileSystem, + localRegistriesFile: .none, + sharedRegistriesFile: sharedFile + ) + } catch { + throw RegistryConfigError.failedToLoadConfiguration(file: sharedFile, underlyingError: error) + } } + + /// Errors that can occur while loading Swift package registry configuration. + enum RegistryConfigError: Error, LocalizedError { + /// Indicates the configuration file could not be loaded. + case failedToLoadConfiguration(file: Basics.AbsolutePath, underlyingError: Error) + + var errorDescription: String? { + switch self { + case .failedToLoadConfiguration(let file, let underlyingError): + return """ + Failed to load registry configuration from '\(file.pathString)': \ + \(underlyingError.localizedDescription) + """ + } + } + } + } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 6e81f388f4c..3fa4d4f443c 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -15,8 +15,27 @@ import TSCUtility import Foundation import PackageGraph +public protocol TemplatePluginManager { + func run() async throws + func loadTemplatePlugin() throws -> ResolvedModule + func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [[String]] + func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data + + var swiftCommandState: SwiftCommandState { get } + var template: String? { get } + var packageGraph: ModulesGraph { get } + var scratchDirectory: Basics.AbsolutePath { get } + var args: [String] { get } + + var EXPERIMENTAL_DUMP_HELP: [String] { get } +} + -struct TemplatePluginManager { +/// A utility for obtaining and running a template's plugin . +/// +/// `TemplateIntiializationPluginManager` encapsulates the logic needed to fetch, +/// and run templates' plugins given arguments, based on the template initialization workflow. +struct TemplateInitializationPluginManager: TemplatePluginManager { let swiftCommandState: SwiftCommandState let template: String? @@ -26,6 +45,15 @@ struct TemplatePluginManager { let args: [String] + let EXPERIMENTAL_DUMP_HELP: [String] = ["--", "--experimental-dump-help"] + + var rootPackage: ResolvedPackage { + guard let root = packageGraph.rootPackages.first else { + fatalError("No root package found in the package graph.") + } + return root + } + init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String]) async throws { self.swiftCommandState = swiftCommandState self.template = template @@ -36,56 +64,142 @@ struct TemplatePluginManager { try await swiftCommandState.loadPackageGraph() } } - //revisit for future refactoring - func run(_ initTemplatePackage: InitTemplatePackage) async throws { + /// Manages the logic of running a template and executing on the information provided by the JSON representation of a template's arguments. + /// + /// - Throws: + /// - `TemplatePluginError.executionFailed(underlying: error)` If there was an error during the execution of a template's plugin. + /// - `TemplatePluginError.failedToDecodeToolInfo(underlying: error)` If there is a change in representation between the JSON and the current version of the ToolInfoV0 struct + /// - `TemplatePluginError.execu` + + func run() async throws { + //Load the plugin corresponding to the template let commandLinePlugin = try loadTemplatePlugin() - let output = try await TemplatePluginRunner.run( - plugin: commandLinePlugin, - package: self.packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: ["--", "--experimental-dump-help"], - swiftCommandState: swiftCommandState - ) - let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let cliResponses = try initTemplatePackage.promptUser(command: toolInfo.command, arguments: args) + // Execute experimental-dump-help to get the JSON representing the template's decision tree + let output: Data + + do { + output = try await executeTemplatePlugin(commandLinePlugin, with: EXPERIMENTAL_DUMP_HELP) + } catch { + throw TemplatePluginError.executionFailed(underlying: error) + } + + //Decode the JSON into ArgumentParserToolInfo ToolInfoV0 struct + let toolInfo: ToolInfoV0 + do { + toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + } catch { + throw TemplatePluginError.failedToDecodeToolInfo(underlying: error) + } + + // Prompt the user for any information needed by the template + let cliResponses: [[String]] = try promptUserForTemplateArguments(using: toolInfo) + + // Execute the template to generate a user's project for response in cliResponses { - _ = try await TemplatePluginRunner.run( - plugin: commandLinePlugin, - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: response, - swiftCommandState: swiftCommandState - ) + do { + let _ = try await executeTemplatePlugin(commandLinePlugin, with: response) + } catch { + throw TemplatePluginError.executionFailed(underlying: error) + } } + } + /// Utilizes the prompting system defined by the struct to prompt user. + /// + /// - Parameters: + /// - toolInfo: The JSON representation of the template's decision tree. + /// + /// - Throws: + /// - Any other errors thrown during the prompting of the user. + /// + /// - Returns: A 2D array of the arguments given by the user, that will be consumed by the template during the project generation phase. + func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [[String]] { + return try TemplatePromptingSystem().promptUser(command: toolInfo.command, arguments: args) } - private func loadTemplatePlugin() throws -> ResolvedModule { - let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: self.packageGraph, limitedTo: nil) + /// Runs the plugin of a template given a set of arguments. + /// + /// - Parameters: + /// - plugin: The resolved module that corresponds to the plugin tied with the template executable. + /// - arguments: A 2D array of arguments that will be passed to the plugin + /// + /// - Throws: + /// - Any Errors thrown during the execution of the template's plugin given a 2D of arguments. + /// + /// - Returns: A data representation of the result of the execution of the template's plugin. + + func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { + return try await TemplatePluginRunner.run( + plugin: plugin, + package: rootPackage, + packageGraph: packageGraph, + arguments: arguments, + swiftCommandState: swiftCommandState + ) + } - guard let commandPlugin = matchingPlugins.first else { - guard let template = template - else { throw ValidationError("No templates were found in \(packageGraph.rootPackages.first!.path)") } //better error message + /// Loads the plugin that corresponds to the template's name. + /// + /// - Throws: + /// - `TempaltePluginError.noMatchingTemplate(name: String?)` if there are no plugins corresponding to the desired template. + /// - `TemplatePluginError.multipleMatchingTemplates(names: [String]` if the search returns more than one plugin given a desired template + /// + /// - Returns: A data representation of the result of the execution of the template's plugin. - throw ValidationError("No templates were found that match the name \(template)") - } + internal func loadTemplatePlugin() throws -> ResolvedModule { + let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: self.packageGraph, limitedTo: nil) - guard matchingPlugins.count == 1 else { - let templateNames = matchingPlugins.compactMap { module in - let plugin = module.underlying as! PluginModule - guard case .command(let intent, _) = plugin.capability else { return String?.none } + switch matchingPlugins.count { + case 0: + throw TemplatePluginError.noMatchingTemplate(name: self.template) + case 1: + return matchingPlugins[0] + default: + let names = matchingPlugins.compactMap { plugin in + (plugin.underlying as? PluginModule)?.capability.commandInvocationVerb + } + throw TemplatePluginError.multipleMatchingTemplates(names: names) + } + } - return intent.invocationVerb + enum TemplatePluginError: Error, CustomStringConvertible { + case noRootPackage + case noMatchingTemplate(name: String?) + case multipleMatchingTemplates(names: [String]) + case failedToDecodeToolInfo(underlying: Error) + case executionFailed(underlying: Error) + + var description: String { + switch self { + case .noRootPackage: + return "No root package found in the package graph." + case let .noMatchingTemplate(name): + let templateName = name ?? "" + return "No templates found matching '\(templateName)" + case let .multipleMatchingTemplates(names): + return """ + Multiple templates matched. Use `--type` to specify one of the following: \(names.joined(separator: ", ")) + """ + case let .failedToDecodeToolInfo(underlying): + return "Failed to decode template tool info: \(underlying.localizedDescription)" + case let .executionFailed(underlying): + return "Plugin execution failed: \(underlying.localizedDescription)" } - throw ValidationError( - "More than one template was found in the package. Please use `--type` along with one of the available templates: \(templateNames.joined(separator: ", "))" - ) } + } +} - return commandPlugin +private extension PluginCapability { + var commandInvocationVerb: String? { + guard case .command(let intent, _) = self else { return nil } + return intent.invocationVerb } } + + + +//struct TemplateTestingPluginManager: TemplatePluginManager diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/InitTemplatePackage.swift index 9073cbfda79..c3b0991de24 100644 --- a/Sources/Workspace/InitTemplatePackage.swift +++ b/Sources/Workspace/InitTemplatePackage.swift @@ -46,7 +46,7 @@ public final class InitTemplatePackage { let installedSwiftPMConfiguration: InstalledSwiftPMConfiguration /// The name of the package to create. - var packageName: String + public var packageName: String /// The path to the template files. var templatePath: Basics.AbsolutePath @@ -191,7 +191,14 @@ public final class InitTemplatePackage { verbose: false ) } +} + + +public final class TemplatePromptingSystem { + + + public init() {} /// Prompts the user for input based on the given command definition and arguments. /// /// This method collects responses for a command's arguments by first validating any user-provided @@ -588,6 +595,7 @@ public final class InitTemplatePackage { } } } + } /// An error enum representing various template-related errors. From 25d251084d8d0084e8414445b0ac29aeffc6f3d4 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 13 Aug 2025 13:41:47 -0400 Subject: [PATCH 202/225] refactoring templates + fixing bug where git and template id was not registered + readding resolution to template if only one template in package --- Sources/Commands/PackageCommands/Init.swift | 18 +- .../PackageCommands/ShowTemplates.swift | 179 ++++++++---------- ...ackageInitializationDirectoryManager.swift | 17 +- .../PackageInitializer.swift | 46 ++++- 4 files changed, 141 insertions(+), 119 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 0bc142f9c0f..31b4da6cc12 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -189,6 +189,9 @@ struct PackageInitConfiguration { let validatePackage: Bool? let args: [String] let versionResolver: DependencyRequirementResolver? + let directory: Basics.AbsolutePath? + let url: String? + let packageID: String? init( swiftCommandState: SwiftCommandState, @@ -217,6 +220,9 @@ struct PackageInitConfiguration { self.globalOptions = globalOptions self.validatePackage = validatePackage self.args = args + self.directory = directory + self.url = url + self.packageID = packageID let sourceResolver = DefaultTemplateSourceResolver() self.templateSource = sourceResolver.resolveSource( @@ -251,9 +257,9 @@ struct PackageInitConfiguration { cwd: cwd, templateSource: templateSource, templateName: initMode, - templateDirectory: nil, - templateURL: nil, - templatePackageID: nil, + templateDirectory: self.directory, + templateURL: self.url, + templatePackageID: self.packageID, versionResolver: versionResolver, buildOptions: buildOptions, globalOptions: globalOptions, @@ -274,7 +280,7 @@ struct PackageInitConfiguration { } -struct VersionFlags { +public struct VersionFlags { let exact: Version? let revision: String? let branch: String? @@ -292,15 +298,15 @@ protocol TemplateSourceResolver { ) -> InitTemplatePackage.TemplateSource? } -struct DefaultTemplateSourceResolver: TemplateSourceResolver { +public struct DefaultTemplateSourceResolver: TemplateSourceResolver { func resolveSource( directory: Basics.AbsolutePath?, url: String?, packageID: String? ) -> InitTemplatePackage.TemplateSource? { - if directory != nil { return .local } if url != nil { return .git } if packageID != nil { return .registry } + if directory != nil { return .local } return nil } } diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 5c840cf30d5..2049e4a62a9 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -74,6 +74,31 @@ struct ShowTemplates: AsyncSwiftCommand { func run(_ swiftCommandState: SwiftCommandState) async throws { + // precheck() needed, extremely similar to the Init precheck, can refactor possibly + + let cwd = swiftCommandState.fileSystem.currentWorkingDirectory + let source = try resolveSource(cwd: cwd) + let resolvedPath = try await resolveTemplatePath(using: swiftCommandState, source: source) + let templates = try await loadTemplates(from: resolvedPath, swiftCommandState: swiftCommandState) + try await displayTemplates(templates, at: resolvedPath, using: swiftCommandState) + try cleanupTemplate(source: source, path: resolvedPath, fileSystem: swiftCommandState.fileSystem) + } + + private func resolveSource(cwd: AbsolutePath?) throws -> InitTemplatePackage.TemplateSource { + guard let source = DefaultTemplateSourceResolver().resolveSource( + directory: cwd, + url: self.templateURL, + packageID: self.templatePackageID + ) else { + throw ValidationError("No template source specified. Provide --url or run in a valid package directory.") + } + return source + } + + + + private func resolveTemplatePath(using swiftCommandState: SwiftCommandState, source: InitTemplatePackage.TemplateSource) async throws -> Basics.AbsolutePath { + let requirementResolver = DependencyRequirementResolver( exact: exact, revision: revision, @@ -83,95 +108,68 @@ struct ShowTemplates: AsyncSwiftCommand { to: to ) - let registryRequirement: PackageDependency.Registry.Requirement? = - try? requirementResolver.resolveRegistry() - - let sourceControlRequirement: PackageDependency.SourceControl.Requirement? = - try? requirementResolver.resolveSourceControl() - - var resolvedTemplatePath: Basics.AbsolutePath - - var templateSource: InitTemplatePackage.TemplateSource - if let templateURL = self.templateURL { - // Download and resolve the Git-based template. - resolvedTemplatePath = try await TemplatePathResolver( - source: .git, - templateDirectory: nil, - templateURL: templateURL, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - packageIdentity: self.templatePackageID, - swiftCommandState: swiftCommandState - ).resolve() - - templateSource = .git - - } else if let _ = self.templatePackageID { - // Download and resolve the Git-based template. - resolvedTemplatePath = try await TemplatePathResolver( - source: .registry, - templateDirectory: nil, - templateURL: nil, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - packageIdentity: self.templatePackageID, - swiftCommandState: swiftCommandState - ).resolve() - - templateSource = .registry - - } else { - // Use the current working directory. - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("No template URL provided and no current directory") - } + let registryRequirement = try? requirementResolver.resolveRegistry() + let sourceControlRequirement = try? requirementResolver.resolveSourceControl() + + return try await TemplatePathResolver( + source: source, + templateDirectory: swiftCommandState.fileSystem.currentWorkingDirectory, + templateURL: self.templateURL, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: self.templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() + } - resolvedTemplatePath = try await TemplatePathResolver( - source: .local, - templateDirectory: cwd, - templateURL: nil, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - packageIdentity: nil, - swiftCommandState: swiftCommandState - ).resolve() - - templateSource = .local + private func loadTemplates(from path: AbsolutePath, swiftCommandState: SwiftCommandState) async throws -> [Template] { + let graph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: path) { _, _ in + try await swiftCommandState.loadPackageGraph() } - // Clean up downloaded package after execution. - defer { - if templateSource == .git { - try? FileManager.default.removeItem(at: resolvedTemplatePath.asURL) - } else if templateSource == .registry { - let parentDirectoryURL = resolvedTemplatePath.parentDirectory.asURL - try? FileManager.default.removeItem(at: parentDirectoryURL) - } + let rootPackages = graph.rootPackages.map(\.identity) + + return graph.allModules.filter(\.underlying.template).map { + Template(package: rootPackages.contains($0.packageIdentity) ? nil : $0.packageIdentity.description, name: $0.name) } + } - // Load the package graph. - let packageGraph = try await swiftCommandState - .withTemporaryWorkspace(switchingTo: resolvedTemplatePath) { _, _ in - try await swiftCommandState.loadPackageGraph() + private func getDescription(_ swiftCommandState: SwiftCommandState, template: String) async throws -> String { + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() + + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope + ) + guard let rootManifest = rootManifests.values.first else { + throw InternalError("invalid manifests at \(root.packages)") } - let rootPackages = packageGraph.rootPackages.map(\.identity) + let products = rootManifest.products + let targets = rootManifest.targets - // Extract executable modules marked as templates. - let templates = packageGraph.allModules.filter(\.underlying.template).map { module -> Template in - if !rootPackages.contains(module.packageIdentity) { - return Template(package: module.packageIdentity.description, name: module.name) - } else { - return Template(package: String?.none, name: module.name) - } + if let target = targets.first(where: { $0.name == template }), + let options = target.templateInitializationOptions, + case .packageInit(_, _, let description) = options { + return description } - // Display templates in the requested format. + throw InternalError( + "Could not find template \(template)" + ) + } + + private func displayTemplates( + _ templates: [Template], + at path: AbsolutePath, + using swiftCommandState: SwiftCommandState + ) async throws { switch self.format { case .flatlist: for template in templates.sorted(by: { $0.name < $1.name }) { - let description = try await swiftCommandState.withTemporaryWorkspace(switchingTo: resolvedTemplatePath) {_, _ in + let description = try await swiftCommandState.withTemporaryWorkspace(switchingTo: path) { _, _ in try await getDescription(swiftCommandState, template: template.name) } if let package = template.package { @@ -183,6 +181,7 @@ struct ShowTemplates: AsyncSwiftCommand { case .json: let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(templates) if let output = String(data: data, encoding: .utf8) { print(output) @@ -190,34 +189,14 @@ struct ShowTemplates: AsyncSwiftCommand { } } - private func getDescription(_ swiftCommandState: SwiftCommandState, template: String) async throws -> String { - let workspace = try swiftCommandState.getActiveWorkspace() - let root = try swiftCommandState.getWorkspaceRoot() + private func cleanupTemplate(source: InitTemplatePackage.TemplateSource, path: AbsolutePath, fileSystem: FileSystem) throws { + try TemplateInitializationDirectoryManager(fileSystem: fileSystem) + .cleanupTemporary(templateSource: source, path: path, temporaryDirectory: nil) + } + - let rootManifests = try await workspace.loadRootManifests( - packages: root.packages, - observabilityScope: swiftCommandState.observabilityScope - ) - guard let rootManifest = rootManifests.values.first else { - throw InternalError("invalid manifests at \(root.packages)") - } - let products = rootManifest.products - let targets = rootManifest.targets - for _ in products { - if let target: TargetDescription = targets.first(where: { $0.name == template }) { - if let options = target.templateInitializationOptions { - if case .packageInit(_, _, let description) = options { - return description - } - } - } - } - throw InternalError( - "Could not find template \(template)" - ) - } /// Represents a discovered template. struct Template: Codable { diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift index 7e5f8b0a781..96f84398b2f 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift @@ -7,7 +7,7 @@ import Foundation import CoreCommands -struct TemplateInitializationDirectoryManager { +public struct TemplateInitializationDirectoryManager { let fileSystem: FileSystem func createTemporaryDirectories() throws -> (stagingPath: Basics.AbsolutePath, cleanUpPath: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) { @@ -42,7 +42,7 @@ struct TemplateInitializationDirectoryManager { } } - func cleanupTemporary(templateSource: InitTemplatePackage.TemplateSource, path: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) throws { + public func cleanupTemporary(templateSource: InitTemplatePackage.TemplateSource, path: Basics.AbsolutePath, temporaryDirectory: Basics.AbsolutePath?) throws { do { switch templateSource { case .git: @@ -56,18 +56,23 @@ struct TemplateInitializationDirectoryManager { case .local: break } - try fileSystem.removeFileTree(tempDir) + + if let tempDir = temporaryDirectory { + try fileSystem.removeFileTree(tempDir) + } + } catch { - throw CleanupError.failedToCleanup(tempDir: tempDir, underlying: error) + throw CleanupError.failedToCleanup(temporaryDirectory: temporaryDirectory, underlying: error) } } enum CleanupError: Error, CustomStringConvertible { - case failedToCleanup(tempDir: Basics.AbsolutePath, underlying: Error) + case failedToCleanup(temporaryDirectory: Basics.AbsolutePath?, underlying: Error) var description: String { switch self { - case .failedToCleanup(let tempDir, let error): + case .failedToCleanup(let temporaryDirectory, let error): + let tempDir = temporaryDirectory?.pathString ?? "" return "Failed to clean up temporary directory at \(tempDir): \(error.localizedDescription)" } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index ea030c04416..d6aeae8bcfb 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -96,9 +96,10 @@ struct TemplatePackageInitializer: PackageInitializer { ) } - try directoryManager.cleanupTemporary(templateSource: templateSource, path: resolvedTemplatePath, tempDir: tempDir) + try directoryManager.cleanupTemporary(templateSource: templateSource, path: resolvedTemplatePath, temporaryDirectory: tempDir) } + //Will have to add checking for git + registry too private func precheck() throws { let manifest = cwd.appending(component: Manifest.filename) guard !swiftCommandState.fileSystem.exists(manifest) else { @@ -124,13 +125,17 @@ struct TemplatePackageInitializer: PackageInitializer { throw TemplatePackageInitializerError.invalidManifestInTemplate(root.packages.description) } + var targetName = templateName + + if targetName == nil { + targetName = try findTemplateName(from: manifest) + } + for target in manifest.targets { - if templateName == nil || target.name == templateName { - if let options = target.templateInitializationOptions { - if case .packageInit(let type, _, _) = options { - return try .init(from: type) - } - } + if target.name == targetName, + let options = target.templateInitializationOptions, + case .packageInit(let type, _, _) = options { + return try .init(from: type) } } @@ -138,6 +143,26 @@ struct TemplatePackageInitializer: PackageInitializer { } } + private func findTemplateName(from manifest: Manifest) throws -> String { + let templateTargets = manifest.targets.compactMap { target -> String? in + if let options = target.templateInitializationOptions, + case .packageInit = options { + return target.name + } + return nil + } + + switch templateTargets.count { + case 0: + throw TemplatePackageInitializerError.noTemplatesInManifest + case 1: + return templateTargets[0] + default: + throw TemplatePackageInitializerError.multipleTemplatesFound(templateTargets) + } + } + + private func setUpPackage( builder: DefaultPackageDependencyBuilder, packageType: InitPackage.PackageType, @@ -162,6 +187,8 @@ struct TemplatePackageInitializer: PackageInitializer { case templateDirectoryNotFound(String) case invalidManifestInTemplate(String) case templateNotFound(String) + case noTemplatesInManifest + case multipleTemplatesFound([String]) var description: String { switch self { @@ -171,6 +198,11 @@ struct TemplatePackageInitializer: PackageInitializer { return "Invalid manifest found in template at \(path)." case .templateNotFound(let templateName): return "Could not find template \(templateName)." + case .noTemplatesInManifest: + return "No templates with packageInit options were found in the manifest." + case .multipleTemplatesFound(let templates): + return "Multiple templates found: \(templates.joined(separator: ", ")). Please specify one using --template." + } } } From 3019c8a1f655d88a22f543d673562fd4c300a9af Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 14 Aug 2025 14:46:11 -0400 Subject: [PATCH 203/225] refactoring test template + changed structure holding prompting answers --- Sources/Commands/SwiftTestCommand.swift | 623 ------------------ .../TestCommands/TestTemplateCommand.swift | 457 +++++++++++++ .../TemplatePluginManager.swift | 8 +- .../TemplateTesterManager.swift | 488 ++++++++++++++ 4 files changed, 947 insertions(+), 629 deletions(-) create mode 100644 Sources/Commands/TestCommands/TestTemplateCommand.swift create mode 100644 Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index 4845eeb82d8..3048c00b8a1 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -710,143 +710,6 @@ extension SwiftTestCommand { } } -final class ArgumentTreeNode { - let command: CommandInfoV0 - var children: [ArgumentTreeNode] = [] - - var arguments: [String: TemplatePromptingSystem.ArgumentResponse] = [:] - - init(command: CommandInfoV0) { - self.command = command - } - - static func build(from command: CommandInfoV0) -> ArgumentTreeNode { - let node = ArgumentTreeNode(command: command) - if let subcommands = command.subcommands { - node.children = subcommands.map { build(from: $0) } - } - return node - } - - func collectUniqueArguments() -> [String: ArgumentInfoV0] { - var dict: [String: ArgumentInfoV0] = [:] - if let args = command.arguments { - for arg in args { - let key = arg.preferredName?.name ?? arg.valueName ?? UUID().uuidString - dict[key] = arg - } - } - for child in children { - let childDict = child.collectUniqueArguments() - for (key, arg) in childDict { - dict[key] = arg - } - } - return dict - } - - - static func promptForUniqueArguments( - uniqueArguments: [String: ArgumentInfoV0] - ) -> [String: TemplatePromptingSystem.ArgumentResponse] { - var collected: [String: TemplatePromptingSystem.ArgumentResponse] = [:] - let argsToPrompt = Array(uniqueArguments.values) - - // Prompt for all unique arguments at once - _ = TemplatePromptingSystem.UserPrompter.prompt(for: argsToPrompt, collected: &collected) - - return collected - } - - //Fill node arguments by assigning the prompted values for keys it requires - func fillArguments(with responses: [String: TemplatePromptingSystem.ArgumentResponse]) { - if let args = command.arguments { - for arg in args { - if let resp = responses[arg.valueName ?? ""] { - arguments[arg.valueName ?? ""] = resp - } - } - } - // Recurse - for child in children { - child.fillArguments(with: responses) - } - } - - func printTree(level: Int = 0) { - let indent = String(repeating: " ", count: level) - print("\(indent)- Command: \(command.commandName)") - for (key, response) in arguments { - print("\(indent) - \(key): \(response.values)") - } - for child in children { - child.printTree(level: level + 1) - } - } - - func createCLITree(root: ArgumentTreeNode) -> [[ArgumentTreeNode]] { - // Base case: If it's a leaf node, return a path with only itself - if root.children.isEmpty { - return [[root]] - } - - var result: [[ArgumentTreeNode]] = [] - - // Recurse into children and prepend the current root to each path - for child in root.children { - let childPaths = createCLITree(root: child) - for path in childPaths { - result.append([root] + path) - } - } - - return result - } -} - -extension ArgumentTreeNode { - /// Traverses all command paths and returns CLI paths along with their arguments - func collectCommandPaths( - currentPath: [String] = [], - currentArguments: [String: TemplatePromptingSystem.ArgumentResponse] = [:] - ) -> [([String], [String: TemplatePromptingSystem.ArgumentResponse])] { - let newPath = currentPath + [command.commandName] - - var combinedArguments = currentArguments - for (key, value) in arguments { - combinedArguments[key] = value - } - - if children.isEmpty { - return [(newPath, combinedArguments)] - } - - var results: [([String], [String: TemplatePromptingSystem.ArgumentResponse])] = [] - for child in children { - results += child.collectCommandPaths( - currentPath: newPath, - currentArguments: combinedArguments - ) - } - - return results - } -} - -extension DispatchTimeInterval { - var seconds: TimeInterval { - switch self { - case .seconds(let s): return TimeInterval(s) - case .milliseconds(let ms): return TimeInterval(Double(ms) / 1000) - case .microseconds(let us): return TimeInterval(Double(us) / 1_000_000) - case .nanoseconds(let ns): return TimeInterval(Double(ns) / 1_000_000_000) - case .never: return 0 - @unknown default: return 0 - } - } -} - - extension SwiftTestCommand { struct Last: SwiftCommand { @OptionGroup(visibility: .hidden) @@ -860,492 +723,6 @@ extension SwiftTestCommand { } } - struct Template: AsyncSwiftCommand { - static let configuration = CommandConfiguration( - abstract: "Test the various outputs of a template" - ) - - @OptionGroup(visibility: .hidden) - var globalOptions: GlobalOptions - - @OptionGroup() - var sharedOptions: SharedOptions - - @Option(help: "Specify name of the template") - var templateName: String? - - @Option( - name: .customLong("output-path"), - help: "Specify the output path of the created templates.", - completion: .directory - ) - public var outputDirectory: AbsolutePath - - @OptionGroup(visibility: .hidden) - var buildOptions: BuildCommandOptions - - /// Predetermined arguments specified by the consumer. - @Argument( - help: "Predetermined arguments to pass to the template." - ) - var args: [String] = [] - - @Flag(help: "Dry-run to display argument tree") - var dryRun: Bool = false - - /// Output format for the templates result. - /// - /// Can be either `.matrix` (default) or `.json`. - @Option(help: "Set the output format.") - var format: ShowTestTemplateOutput = .matrix - - func run(_ swiftCommandState: SwiftCommandState) async throws { - let manifest = outputDirectory.appending(component: Manifest.filename) - let fileSystem = swiftCommandState.fileSystem - let directoryExists = fileSystem.exists(outputDirectory) - - if !directoryExists { - try FileManager.default.createDirectory( - at: outputDirectory.asURL, - withIntermediateDirectories: true - ) - } else { - if fileSystem.exists(manifest) { - throw ValidationError("Package.swift was found in \(outputDirectory).") - } - } - - // Load Package Graph - let packageGraph = try await swiftCommandState.loadPackageGraph() - - // Find matching plugin - let matchingPlugins = PluginCommand.findPlugins(matching: self.templateName, in: packageGraph, limitedTo: nil) - guard let commandPlugin = matchingPlugins.first else { - throw ValidationError("No templates were found that match the name \(self.templateName ?? "")") - } - - guard matchingPlugins.count == 1 else { - let templateNames = matchingPlugins.compactMap { module in - let plugin = module.underlying as! PluginModule - guard case .command(let intent, _) = plugin.capability else { return String?.none } - - return intent.invocationVerb - } - throw ValidationError( - "More than one template was found in the package. Please use `--template-name` along with one of the available templates: \(templateNames.joined(separator: ", "))" - ) - } - - let output = try await TemplatePluginRunner.run( - plugin: commandPlugin, - package: packageGraph.rootPackages.first!, - packageGraph: packageGraph, - arguments: ["--", "--experimental-dump-help"], - swiftCommandState: swiftCommandState - ) - - let toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - let root = ArgumentTreeNode.build(from: toolInfo.command) - - let uniqueArguments = root.collectUniqueArguments() - let responses = ArgumentTreeNode.promptForUniqueArguments(uniqueArguments: uniqueArguments) - root.fillArguments(with: responses) - - if dryRun { - root.printTree() - return - } - - let cliArgumentPaths = root.createCLITree(root: root) - - func checkConditions(_ swiftCommandState: SwiftCommandState, template: String?) async throws -> InitPackage.PackageType { - let workspace = try swiftCommandState.getActiveWorkspace() - let root = try swiftCommandState.getWorkspaceRoot() - - let rootManifests = try await workspace.loadRootManifests( - packages: root.packages, - observabilityScope: swiftCommandState.observabilityScope - ) - guard let rootManifest = rootManifests.values.first else { - throw InternalError("Invalid manifests at \(root.packages)") - } - - let targets = rootManifest.targets - for target in targets { - if template == nil || target.name == template, - let options = target.templateInitializationOptions, - case .packageInit(let templateType, _, _) = options { - return try .init(from: templateType) - } - } - - throw ValidationError("Could not find \(template != nil ? "template \(template!)" : "any templates")") - } - - let initialPackageType: InitPackage.PackageType = try await checkConditions(swiftCommandState, template: templateName) - - var buildMatrix: [String: BuildInfo] = [:] - - for path in cliArgumentPaths { - let commandNames = path.map { $0.command.commandName } - let folderName = commandNames.joined(separator: "-") - let destinationAbsolutePath = outputDirectory.appending(component: folderName) - let destinationURL = destinationAbsolutePath.asURL - - print("\nGenerating \(folderName)") - try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true) - - - let buildInfo = try await testTemplateIntialization( - commandPlugin: commandPlugin, - swiftCommandState: swiftCommandState, - buildOptions: buildOptions, - destinationAbsolutePath: destinationAbsolutePath, - testingFolderName: folderName, - argumentPath: path, - initialPackageType: initialPackageType - ) - - buildMatrix[folderName] = buildInfo - - - } - - switch self.format { - case .matrix: - printBuildMatrix(buildMatrix) - case .json: - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - do { - let data = try encoder.encode(buildMatrix) - if let output = String(data: data, encoding: .utf8) { - print(output) - } - } catch { - print("Failed to encode JSON: \(error)") - } - } - - func printBuildMatrix(_ matrix: [String: BuildInfo]) { - let header = [ - "Argument Branch".padding(toLength: 30, withPad: " ", startingAt: 0), - "Gen Success".padding(toLength: 12, withPad: " ", startingAt: 0), - "Gen Time(s)".padding(toLength: 12, withPad: " ", startingAt: 0), - "Build Success".padding(toLength: 14, withPad: " ", startingAt: 0), - "Build Time(s)".padding(toLength: 14, withPad: " ", startingAt: 0), - "Log File" - ] - print(header.joined(separator: " ")) - - for (folder, info) in matrix { - let row = [ - folder.padding(toLength: 30, withPad: " ", startingAt: 0), - String(info.generationSuccess).padding(toLength: 12, withPad: " ", startingAt: 0), - String(format: "%.2f", info.generationDuration.seconds).padding(toLength: 12, withPad: " ", startingAt: 0), - String(info.buildSuccess).padding(toLength: 14, withPad: " ", startingAt: 0), - String(format: "%.2f", info.buildDuration.seconds).padding(toLength: 14, withPad: " ", startingAt: 0), - info.logFilePath ?? "-" - ] - print(row.joined(separator: " ")) - } - } - } - - /// Output format modes for the `ShowTemplates` command. - enum ShowTestTemplateOutput: String, RawRepresentable, CustomStringConvertible, ExpressibleByArgument, CaseIterable { - /// Output as a matrix. - case matrix - /// Output as a JSON array of template along with fields. - case json - - public init?(rawValue: String) { - switch rawValue.lowercased() { - case "matrix": - self = .matrix - case "json": - self = .json - default: - return nil - } - } - - public var description: String { - switch self { - case .matrix: "matrix" - case .json: "json" - } - } - } - - struct BuildInfo: Encodable { - var generationDuration: DispatchTimeInterval - var buildDuration: DispatchTimeInterval - var generationSuccess: Bool - var buildSuccess: Bool - var logFilePath: String? - - - public init(generationDuration: DispatchTimeInterval, buildDuration: DispatchTimeInterval, generationSuccess: Bool, buildSuccess: Bool, logFilePath: String? = nil) { - self.generationDuration = generationDuration - self.buildDuration = buildDuration - self.generationSuccess = generationSuccess - self.buildSuccess = buildSuccess - self.logFilePath = logFilePath - } - enum CodingKeys: String, CodingKey { - case generationDuration - case buildDuration - case generationSuccess - case buildSuccess - case logFilePath - } - - // Encoding - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(Self.dispatchTimeIntervalToSeconds(generationDuration), forKey: .generationDuration) - try container.encode(Self.dispatchTimeIntervalToSeconds(buildDuration), forKey: .buildDuration) - try container.encode(generationSuccess, forKey: .generationSuccess) - try container.encode(buildSuccess, forKey: .buildSuccess) - if logFilePath == nil { - try container.encodeNil(forKey: .logFilePath) - } else { - try container.encodeIfPresent(logFilePath, forKey: .logFilePath) - } - } - - // Helpers - private static func dispatchTimeIntervalToSeconds(_ interval: DispatchTimeInterval) -> Double { - switch interval { - case .seconds(let s): return Double(s) - case .milliseconds(let ms): return Double(ms) / 1000 - case .microseconds(let us): return Double(us) / 1_000_000 - case .nanoseconds(let ns): return Double(ns) / 1_000_000_000 - case .never: return -1 // or some sentinel value - @unknown default: return -1 - } - } - - private static func secondsToDispatchTimeInterval(_ seconds: Double) -> DispatchTimeInterval { - return .milliseconds(Int(seconds * 1000)) - } - - } - - private func testTemplateIntialization( - commandPlugin: ResolvedModule, - swiftCommandState: SwiftCommandState, - buildOptions: BuildCommandOptions, - destinationAbsolutePath: AbsolutePath, - testingFolderName: String, - argumentPath: [ArgumentTreeNode], - initialPackageType: InitPackage.PackageType - ) async throws -> BuildInfo { - - let generationStart = DispatchTime.now() - var generationDuration: DispatchTimeInterval = .never - var buildDuration: DispatchTimeInterval = .never - var generationSuccess = false - var buildSuccess = false - var logFilePath: String? = nil - - - var pluginOutput: String = "" - - do { - - let logPath = destinationAbsolutePath.appending("generation-output.log").pathString - - // Redirect stdout/stderr to file before starting generation - let (origOut, origErr) = try redirectStdoutAndStderr(to: logPath) - - defer { - restoreStdoutAndStderr(originalStdout: origOut, originalStderr: origErr) - } - - - let initTemplatePackage = try InitTemplatePackage( - name: testingFolderName, - initMode: .fileSystem(name: templateName, path: swiftCommandState.originalWorkingDirectory.pathString), - templatePath: swiftCommandState.originalWorkingDirectory, - fileSystem: swiftCommandState.fileSystem, - packageType: initialPackageType, - supportedTestingLibraries: [], - destinationPath: destinationAbsolutePath, - installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration - ) - - try initTemplatePackage.setupTemplateManifest() - - let generatedGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in - try await swiftCommandState.loadPackageGraph() - } - - try await TemplateBuildSupport.buildForTesting( - swiftCommandState: swiftCommandState, - buildOptions: buildOptions, - testingFolder: destinationAbsolutePath - ) - - for (index, node) in argumentPath.enumerated() { - let currentPath = index == 0 ? [] : argumentPath[1...index].map { $0.command.commandName } - let currentArgs = node.arguments.values.flatMap { $0.commandLineFragments } - let fullCommand = currentPath + currentArgs - - try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in - do { - let outputData = try await TemplatePluginRunner.run( - plugin: commandPlugin, - package: generatedGraph.rootPackages.first!, - packageGraph: generatedGraph, - arguments: fullCommand, - swiftCommandState: swiftCommandState - ) - pluginOutput = String(data: outputData, encoding: .utf8) ?? "[Invalid UTF-8 output]" - print(pluginOutput) - } - } - } - - generationDuration = generationStart.distance(to: .now()) - generationSuccess = true - - if generationSuccess { - try FileManager.default.removeItem(atPath: logPath) - } - - } catch { - generationDuration = generationStart.distance(to: .now()) - generationSuccess = false - - - let logPath = destinationAbsolutePath.appending("generation-output.log") - let outputPath = logPath.pathString - let capturedOutput = (try? String(contentsOfFile: outputPath)) ?? "" - - let unifiedLog = """ - Error: - -------------------------------- - \(error.localizedDescription) - - Plugin Output (before failure): - -------------------------------- - \(capturedOutput) - """ - - try? unifiedLog.write(to: logPath.asURL, atomically: true, encoding: .utf8) - logFilePath = logPath.pathString - - } - // Only start the build step if generation was successful - if generationSuccess { - let buildStart = DispatchTime.now() - do { - - let logPath = destinationAbsolutePath.appending("build-output.log").pathString - - // Redirect stdout/stderr to file before starting build - let (origOut, origErr) = try redirectStdoutAndStderr(to: logPath) - - defer { - restoreStdoutAndStderr(originalStdout: origOut, originalStderr: origErr) - } - - - try await TemplateBuildSupport.buildForTesting( - swiftCommandState: swiftCommandState, - buildOptions: buildOptions, - testingFolder: destinationAbsolutePath - ) - - buildDuration = buildStart.distance(to: .now()) - buildSuccess = true - - - if buildSuccess { - try FileManager.default.removeItem(atPath: logPath) - } - - - } catch { - buildDuration = buildStart.distance(to: .now()) - buildSuccess = false - - let logPath = destinationAbsolutePath.appending("build-output.log") - let outputPath = logPath.pathString - let capturedOutput = (try? String(contentsOfFile: outputPath)) ?? "" - - let unifiedLog = """ - Error: - -------------------------------- - \(error.localizedDescription) - - Build Output (before failure): - -------------------------------- - \(capturedOutput) - """ - - try? unifiedLog.write(to: logPath.asURL, atomically: true, encoding: .utf8) - logFilePath = logPath.pathString - - } - } - - return BuildInfo( - generationDuration: generationDuration, - buildDuration: buildDuration, - generationSuccess: generationSuccess, - buildSuccess: buildSuccess, - logFilePath: logFilePath - ) - } - - func writeLogToFile(_ content: String, to directory: AbsolutePath, named fileName: String) throws { - let fileURL = URL(fileURLWithPath: directory.pathString).appendingPathComponent(fileName) - try content.write(to: fileURL, atomically: true, encoding: .utf8) - } - - func redirectStdoutAndStderr(to path: String) throws -> (originalStdout: Int32, originalStderr: Int32) { - // Open file for writing (create/truncate) - guard let file = fopen(path, "w") else { - throw NSError(domain: "RedirectError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot open file for writing"]) - } - - let originalStdout = dup(STDOUT_FILENO) - let originalStderr = dup(STDERR_FILENO) - - dup2(fileno(file), STDOUT_FILENO) - dup2(fileno(file), STDERR_FILENO) - - fclose(file) - - return (originalStdout, originalStderr) - } - - func restoreStdoutAndStderr(originalStdout: Int32, originalStderr: Int32) { - fflush(stdout) - fflush(stderr) - - if dup2(originalStdout, STDOUT_FILENO) == -1 { - perror("dup2 stdout restore failed") - } - if dup2(originalStderr, STDERR_FILENO) == -1 { - perror("dup2 stderr restore failed") - } - - fflush(stdout) - fflush(stderr) - - if close(originalStdout) == -1 { - perror("close originalStdout failed") - } - if close(originalStderr) == -1 { - perror("close originalStderr failed") - } - } - } - struct List: AsyncSwiftCommand { static let configuration = CommandConfiguration( abstract: "Lists test methods in specifier format" diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift new file mode 100644 index 00000000000..2c0fd2d3cc3 --- /dev/null +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -0,0 +1,457 @@ +import ArgumentParser +import ArgumentParserToolInfo + +@_spi(SwiftPMInternal) +import Basics + +import _Concurrency + +@_spi(SwiftPMInternal) +import CoreCommands + +import Dispatch +import Foundation +import PackageGraph + +@_spi(SwiftPMInternal) +import PackageModel + +import SPMBuildCore +import TSCUtility + +import func TSCLibc.exit +import Workspace + +import class Basics.AsyncProcess +import struct TSCBasic.ByteString +import struct TSCBasic.FileSystemError +import enum TSCBasic.JSON +import var TSCBasic.stdoutStream +import class TSCBasic.SynchronizedQueue +import class TSCBasic.Thread + + + +extension DispatchTimeInterval { + var seconds: TimeInterval { + switch self { + case .seconds(let s): return TimeInterval(s) + case .milliseconds(let ms): return TimeInterval(Double(ms) / 1000) + case .microseconds(let us): return TimeInterval(Double(us) / 1_000_000) + case .nanoseconds(let ns): return TimeInterval(Double(ns) / 1_000_000_000) + case .never: return 0 + @unknown default: return 0 + } + } +} + + +//DEAL WITH THIS LATER +public struct TemplateTestingDirectoryManager { + let fileSystem: FileSystem + + //revisit + func createTemporaryDirectories(directories: Set) throws -> [Basics.AbsolutePath] { + + var result: [Basics.AbsolutePath] = [] + for directory in directories { + let dirPath = try fileSystem.tempDirectory.appending(component: directory) + try fileSystem.createDirectory(dirPath) + result.append(dirPath) + } + + return result + } + + func createOutputDirectory(outputDirectoryPath: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) throws { + let manifest = outputDirectoryPath.appending(component: Manifest.filename) + let fileSystem = swiftCommandState.fileSystem + let directoryExists = fileSystem.exists(outputDirectoryPath) + + if !directoryExists { + try FileManager.default.createDirectory( + at: outputDirectoryPath.asURL, + withIntermediateDirectories: true + ) + } else { + if fileSystem.exists(manifest) { + throw ValidationError("Package.swift was found in \(outputDirectoryPath).") + } + } + + } +} + + +extension SwiftTestCommand { + struct Template: AsyncSwiftCommand { + static let configuration = CommandConfiguration( + abstract: "Test the various outputs of a template" + ) + + @OptionGroup(visibility: .hidden) + var globalOptions: GlobalOptions + + @OptionGroup() + var sharedOptions: SharedOptions + + @Option(help: "Specify name of the template") + var templateName: String? + + @Option( + name: .customLong("output-path"), + help: "Specify the output path of the created templates.", + completion: .directory + ) + public var outputDirectory: AbsolutePath + + @OptionGroup(visibility: .hidden) + var buildOptions: BuildCommandOptions + + /// Predetermined arguments specified by the consumer. + @Argument( + help: "Predetermined arguments to pass to the template." + ) + var args: [String] = [] + + @Flag(help: "Dry-run to display argument tree") + var dryRun: Bool = false + + /// Output format for the templates result. + /// + /// Can be either `.matrix` (default) or `.json`. + @Option(help: "Set the output format.") + var format: ShowTestTemplateOutput = .matrix + + + func run(_ swiftCommandState: SwiftCommandState) async throws { + + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw ValidationError("Could not determine current working directory.") + } + + let directoryManager = TemplateTestingDirectoryManager(fileSystem: swiftCommandState.fileSystem) + try directoryManager.createOutputDirectory(outputDirectoryPath: outputDirectory, swiftCommandState: swiftCommandState) + + let pluginManager = try await TemplateTesterPluginManager( + swiftCommandState: swiftCommandState, + template: templateName, + scratchDirectory: cwd, + args: args + ) + + let commandPlugin = try pluginManager.loadTemplatePlugin() + let commandLineFragments = try await pluginManager.run() + let packageType = try await inferPackageType(swiftCommandState: swiftCommandState, from: cwd) + + + var buildMatrix: [String: BuildInfo] = [:] + + for commandLine in commandLineFragments { + + let folderName = commandLine.fullPathKey + + buildMatrix[folderName] = try await testDecisionTreeBranch(folderName: folderName, commandLine: commandLine, swiftCommandState: swiftCommandState, packageType: packageType, commandPlugin: commandPlugin) + + } + + switch self.format { + case .matrix: + printBuildMatrix(buildMatrix) + case .json: + printJSONMatrix(buildMatrix) + } + } + + private func testDecisionTreeBranch(folderName: String, commandLine: CommandPath, swiftCommandState: SwiftCommandState, packageType: InitPackage.PackageType, commandPlugin: ResolvedModule) async throws -> BuildInfo { + let destinationPath = outputDirectory.appending(component: folderName) + + swiftCommandState.observabilityScope.emit(debug: "Generating \(folderName)") + try FileManager.default.createDirectory(at: destinationPath.asURL, withIntermediateDirectories: true) + + return try await testTemplateInitialization( + commandPlugin: commandPlugin, + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + destinationAbsolutePath: destinationPath, + testingFolderName: folderName, + argumentPath: commandLine, + initialPackageType: packageType + ) + } + + private func printBuildMatrix(_ matrix: [String: BuildInfo]) { + let header = [ + "Argument Branch".padding(toLength: 30, withPad: " ", startingAt: 0), + "Gen Success".padding(toLength: 12, withPad: " ", startingAt: 0), + "Gen Time(s)".padding(toLength: 12, withPad: " ", startingAt: 0), + "Build Success".padding(toLength: 14, withPad: " ", startingAt: 0), + "Build Time(s)".padding(toLength: 14, withPad: " ", startingAt: 0), + "Log File" + ] + print(header.joined(separator: " ")) + + for (folder, info) in matrix { + let row = [ + folder.padding(toLength: 30, withPad: " ", startingAt: 0), + String(info.generationSuccess).padding(toLength: 12, withPad: " ", startingAt: 0), + String(format: "%.2f", info.generationDuration.seconds).padding(toLength: 12, withPad: " ", startingAt: 0), + String(info.buildSuccess).padding(toLength: 14, withPad: " ", startingAt: 0), + String(format: "%.2f", info.buildDuration.seconds).padding(toLength: 14, withPad: " ", startingAt: 0), + info.logFilePath ?? "-" + ] + print(row.joined(separator: " ")) + } + } + + private func printJSONMatrix(_ matrix: [String: BuildInfo]) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + do { + let data = try encoder.encode(matrix) + if let output = String(data: data, encoding: .utf8) { + print(output) + } + } catch { + print("Failed to encode JSON: \(error)") + } + + } + + private func inferPackageType(swiftCommandState: SwiftCommandState, from templatePath: Basics.AbsolutePath) async throws -> InitPackage.PackageType { + let workspace = try swiftCommandState.getActiveWorkspace() + let root = try swiftCommandState.getWorkspaceRoot() + + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: swiftCommandState.observabilityScope + ) + + guard let manifest = rootManifests.values.first else { + throw ValidationError("") + } + + var targetName = templateName + + if targetName == nil { + targetName = try findTemplateName(from: manifest) + } + + for target in manifest.targets { + if target.name == targetName, + let options = target.templateInitializationOptions, + case .packageInit(let type, _, _) = options { + return try .init(from: type) + } + } + + throw ValidationError("") + } + + + private func findTemplateName(from manifest: Manifest) throws -> String { + let templateTargets = manifest.targets.compactMap { target -> String? in + if let options = target.templateInitializationOptions, + case .packageInit = options { + return target.name + } + return nil + } + + switch templateTargets.count { + case 0: + throw ValidationError("") + case 1: + return templateTargets[0] + default: + throw ValidationError("") + } + } + + + private func testTemplateInitialization( + commandPlugin: ResolvedModule, + swiftCommandState: SwiftCommandState, + buildOptions: BuildCommandOptions, + destinationAbsolutePath: AbsolutePath, + testingFolderName: String, + argumentPath: CommandPath, + initialPackageType: InitPackage.PackageType + ) async throws -> BuildInfo { + + let startGen = DispatchTime.now() + var genSuccess = false + var buildSuccess = false + var genDuration: DispatchTimeInterval = .never + var buildDuration: DispatchTimeInterval = .never + var logPath: String? = nil + + do { + let log = destinationAbsolutePath.appending("generation-output.log").pathString + let (origOut, origErr) = try redirectStdoutAndStderr(to: log) + defer { restoreStdoutAndStderr(originalStdout: origOut, originalStderr: origErr) } + + let initTemplate = try InitTemplatePackage( + name: testingFolderName, + initMode: .fileSystem(name: templateName, path: swiftCommandState.originalWorkingDirectory.pathString), + templatePath: swiftCommandState.originalWorkingDirectory, + fileSystem: swiftCommandState.fileSystem, + packageType: initialPackageType, + supportedTestingLibraries: [], + destinationPath: destinationAbsolutePath, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration + ) + + try initTemplate.setupTemplateManifest() + + let graph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in + try await swiftCommandState.loadPackageGraph() + } + + for (index, command) in argumentPath.commandChain.enumerated() { + let commandArgs = command.arguments.flatMap { $0.commandLineFragments } + let fullCommand = (index == 0) ? [] : Array(argumentPath.commandChain.prefix(index + 1).map(\.commandName)) + commandArgs + + print("Running plugin with args:", fullCommand) + + try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in + _ = try await TemplatePluginRunner.run( + plugin: commandPlugin, + package: graph.rootPackages.first!, + packageGraph: graph, + arguments: fullCommand, + swiftCommandState: swiftCommandState + ) + } + } + + genDuration = startGen.distance(to: .now()) + genSuccess = true + try FileManager.default.removeItem(atPath: log) + } catch { + genDuration = startGen.distance(to: .now()) + genSuccess = false + + let errorLog = destinationAbsolutePath.appending("generation-output.log") + logPath = try? captureAndWriteError( + to: errorLog, + error: error, + context: "Plugin Output (before failure)" + ) + } + + // Build step + if genSuccess { + let buildStart = DispatchTime.now() + do { + let log = destinationAbsolutePath.appending("build-output.log").pathString + let (origOut, origErr) = try redirectStdoutAndStderr(to: log) + defer { restoreStdoutAndStderr(originalStdout: origOut, originalStderr: origErr) } + + try await TemplateBuildSupport.buildForTesting( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + testingFolder: destinationAbsolutePath + ) + + buildDuration = buildStart.distance(to: .now()) + buildSuccess = true + try FileManager.default.removeItem(atPath: log) + } catch { + buildDuration = buildStart.distance(to: .now()) + buildSuccess = false + + let errorLog = destinationAbsolutePath.appending("build-output.log") + logPath = try? captureAndWriteError( + to: errorLog, + error: error, + context: "Build Output (before failure)" + ) + } + } + + return BuildInfo( + generationDuration: genDuration, + buildDuration: buildDuration, + generationSuccess: genSuccess, + buildSuccess: buildSuccess, + logFilePath: logPath + ) + } + + private func captureAndWriteError(to path: AbsolutePath, error: Error, context: String) throws -> String { + let existingOutput = (try? String(contentsOf: path.asURL)) ?? "" + let logContent = + """ + Error: + -------------------------------- + \(error.localizedDescription) + + \(context): + -------------------------------- + \(existingOutput) + """ + try logContent.write(to: path.asURL, atomically: true, encoding: .utf8) + return path.pathString + } + + private func redirectStdoutAndStderr(to path: String) throws -> (originalStdout: Int32, originalStderr: Int32) { + guard let file = fopen(path, "w") else { + throw NSError(domain: "RedirectError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Cannot open file for writing"]) + } + + let originalStdout = dup(STDOUT_FILENO) + let originalStderr = dup(STDERR_FILENO) + dup2(fileno(file), STDOUT_FILENO) + dup2(fileno(file), STDERR_FILENO) + fclose(file) + return (originalStdout, originalStderr) + } + + private func restoreStdoutAndStderr(originalStdout: Int32, originalStderr: Int32) { + fflush(stdout) + fflush(stderr) + + dup2(originalStdout, STDOUT_FILENO) + dup2(originalStderr, STDERR_FILENO) + close(originalStdout) + close(originalStderr) + } + + enum ShowTestTemplateOutput: String, RawRepresentable, CustomStringConvertible, ExpressibleByArgument, CaseIterable { + case matrix + case json + + public var description: String { rawValue } + } + + + struct BuildInfo: Encodable { + var generationDuration: DispatchTimeInterval + var buildDuration: DispatchTimeInterval + var generationSuccess: Bool + var buildSuccess: Bool + var logFilePath: String? + + enum CodingKeys: String, CodingKey { + case generationDuration, buildDuration, generationSuccess, buildSuccess, logFilePath + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(generationDuration.seconds, forKey: .generationDuration) + try container.encode(buildDuration.seconds, forKey: .buildDuration) + try container.encode(generationSuccess, forKey: .generationSuccess) + try container.encode(buildSuccess, forKey: .buildSuccess) + try container.encodeIfPresent(logFilePath, forKey: .logFilePath) + } + } + } +} + +private extension String { + func padded(_ toLength: Int) -> String { + self.padding(toLength: toLength, withPad: " ", startingAt: 0) + } +} + diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 3fa4d4f443c..5db8218f6fd 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -16,9 +16,9 @@ import Foundation import PackageGraph public protocol TemplatePluginManager { - func run() async throws + func loadTemplatePlugin() throws -> ResolvedModule - func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [[String]] + //func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [[String]] func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data var swiftCommandState: SwiftCommandState { get } @@ -199,7 +199,3 @@ private extension PluginCapability { return intent.invocationVerb } } - - - -//struct TemplateTestingPluginManager: TemplatePluginManager diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift new file mode 100644 index 00000000000..565679053a5 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -0,0 +1,488 @@ + +import ArgumentParser +import ArgumentParserToolInfo + +import Basics + +@_spi(SwiftPMInternal) +import CoreCommands + +import PackageModel +import Workspace +import SPMBuildCore +import TSCBasic +import TSCUtility +import Foundation +import PackageGraph + +/// A utility for obtaining and running a template's plugin . +/// +/// `TemplateTesterPluginManager` encapsulates the logic needed to fetch, +/// and run templates' plugins given arguments, based on the template initialization workflow. +public struct TemplateTesterPluginManager: TemplatePluginManager { + public let swiftCommandState: SwiftCommandState + public let template: String? + + public let packageGraph: ModulesGraph + + public let scratchDirectory: Basics.AbsolutePath + + public let args: [String] + + public let EXPERIMENTAL_DUMP_HELP: [String] = ["--", "--experimental-dump-help"] + + var rootPackage: ResolvedPackage { + guard let root = packageGraph.rootPackages.first else { + fatalError("No root package found in the package graph.") + } + return root + } + + init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String]) async throws { + self.swiftCommandState = swiftCommandState + self.template = template + self.scratchDirectory = scratchDirectory + self.args = args + + self.packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: scratchDirectory) { _, _ in + try await swiftCommandState.loadPackageGraph() + } + } + + /// Manages the logic of running a template and executing on the information provided by the JSON representation of a template's arguments. + /// + /// - Throws: + /// - `TemplatePluginError.executionFailed(underlying: error)` If there was an error during the execution of a template's plugin. + /// - `TemplatePluginError.failedToDecodeToolInfo(underlying: error)` If there is a change in representation between the JSON and the current version of the ToolInfoV0 struct + /// - `TemplatePluginError.execute` + + func run() async throws -> [CommandPath] { + //Load the plugin corresponding to the template + + let commandLinePlugin = try loadTemplatePlugin() + + // Execute experimental-dump-help to get the JSON representing the template's decision tree + let output: Data + + do { + output = try await executeTemplatePlugin(commandLinePlugin, with: EXPERIMENTAL_DUMP_HELP) + } catch { + throw TemplatePluginError.executionFailed(underlying: error) + } + + //Decode the JSON into ArgumentParserToolInfo ToolInfoV0 struct + let toolInfo: ToolInfoV0 + + do { + toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) + } catch { + throw TemplatePluginError.failedToDecodeToolInfo(underlying: error) + } + + // Prompt the user for any information needed by the template + return try promptUserForTemplateArguments(using: toolInfo) + } + + + + + + /// Utilizes the prompting system defined by the struct to prompt user. + /// + /// - Parameters: + /// - toolInfo: The JSON representation of the template's decision tree. + /// + /// - Throws: + /// - Any other errors thrown during the prompting of the user. + /// + /// - Returns: A 2D array of the arguments given by the user, that will be consumed by the template during the project generation phase. + func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [CommandPath] { + return try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command) + } + + + /// Runs the plugin of a template given a set of arguments. + /// + /// - Parameters: + /// - plugin: The resolved module that corresponds to the plugin tied with the template executable. + /// - arguments: A 2D array of arguments that will be passed to the plugin + /// + /// - Throws: + /// - Any Errors thrown during the execution of the template's plugin given a 2D of arguments. + /// + /// - Returns: A data representation of the result of the execution of the template's plugin. + + public func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { + return try await TemplatePluginRunner.run( + plugin: plugin, + package: rootPackage, + packageGraph: packageGraph, + arguments: arguments, + swiftCommandState: swiftCommandState + ) + } + + /// Loads the plugin that corresponds to the template's name. + /// + /// - Throws: + /// - `TempaltePluginError.noMatchingTemplate(name: String?)` if there are no plugins corresponding to the desired template. + /// - `TemplatePluginError.multipleMatchingTemplates(names: [String]` if the search returns more than one plugin given a desired template + /// + /// - Returns: A data representation of the result of the execution of the template's plugin. + + public func loadTemplatePlugin() throws -> ResolvedModule { + + let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: self.packageGraph, limitedTo: nil) + + switch matchingPlugins.count { + case 0: + throw TemplatePluginError.noMatchingTemplate(name: self.template) + case 1: + return matchingPlugins[0] + default: + let names = matchingPlugins.compactMap { plugin in + (plugin.underlying as? PluginModule)?.capability.commandInvocationVerb + } + throw TemplatePluginError.multipleMatchingTemplates(names: names) + } + } + + enum TemplatePluginError: Error, CustomStringConvertible { + case noRootPackage + case noMatchingTemplate(name: String?) + case multipleMatchingTemplates(names: [String]) + case failedToDecodeToolInfo(underlying: Error) + case executionFailed(underlying: Error) + + var description: String { + switch self { + case .noRootPackage: + return "No root package found in the package graph." + case let .noMatchingTemplate(name): + let templateName = name ?? "" + return "No templates found matching '\(templateName)" + case let .multipleMatchingTemplates(names): + return """ + Multiple templates matched. Use `--type` to specify one of the following: \(names.joined(separator: ", ")) + """ + case let .failedToDecodeToolInfo(underlying): + return "Failed to decode template tool info: \(underlying.localizedDescription)" + case let .executionFailed(underlying): + return "Plugin execution failed: \(underlying.localizedDescription)" + } + } + } +} + + + +public struct CommandPath { + public let fullPathKey: String + public let commandChain: [CommandComponent] +} + +public struct CommandComponent { + let commandName: String + let arguments: [TemplateTestPromptingSystem.ArgumentResponse] +} + + + +public class TemplateTestPromptingSystem { + + + + public init() {} + /// Prompts the user for input based on the given command definition and arguments. + /// + /// This method collects responses for a command's arguments by first validating any user-provided + /// arguments (`arguments`) against the command's defined parameters. Any required arguments that are + /// missing will be interactively prompted from the user. + /// + /// If the command has subcommands, the method will attempt to detect a subcommand from any leftover + /// arguments. If no subcommand is found, the user is interactively prompted to select one. This process + /// is recursive: each subcommand is treated as a new command and processed accordingly. + /// + /// When building each CLI command line, only arguments defined for the current command level are included— + /// inherited arguments from previous levels are excluded to avoid duplication. + /// + /// - Parameters: + /// - command: The top-level or current `CommandInfoV0` to prompt for. + /// - arguments: The list of pre-supplied command-line arguments to match against defined arguments. + /// - subcommandTrail: An internal list of command names to build the final CLI path (used recursively). + /// - inheritedResponses: Argument responses collected from parent commands that should be passed down. + /// + /// - Returns: A list of command line invocations (`[[String]]`), each representing a full CLI command. + /// Each entry includes only arguments relevant to the specific command or subcommand level. + /// + /// - Throws: An error if argument parsing or user prompting fails. + + + + // resolve arguments at this level + // append arguments to the current path + // if subcommands exist, then for each subcommand, pass the function again, where we deepCopy a path + // if not, then jointhe command names of all the paths, and append CommandPath() + + public func generateCommandPaths(rootCommand: CommandInfoV0) throws -> [CommandPath] { + var paths: [CommandPath] = [] + var visitedArgs = Set() + + try dfs(command: rootCommand, path: [], visitedArgs: &visitedArgs, paths: &paths) + + return paths + } + + func dfs(command: CommandInfoV0, path: [CommandComponent], visitedArgs: inout Set, paths: inout [CommandPath]) throws{ + + let allArgs = try convertArguments(from: command) + + let currentArgs = allArgs.filter { arg in + !visitedArgs.contains(where: {$0.argument.valueName == arg.valueName}) + } + + + var collected: [String: ArgumentResponse] = [:] + let resolvedArgs = UserPrompter.prompt(for: currentArgs, collected: &collected) + + resolvedArgs.forEach { visitedArgs.insert($0) } + + let currentComponent = CommandComponent( + commandName: command.commandName, arguments: resolvedArgs + ) + + var newPath = path + + newPath.append(currentComponent) + + if let subcommands = getSubCommand(from: command) { + for sub in subcommands { + try dfs(command: sub, path: newPath, visitedArgs: &visitedArgs, paths: &paths) + } + } else { + let fullPathKey = joinCommandNames(newPath) + let commandPath = CommandPath( + fullPathKey: fullPathKey, commandChain: newPath + ) + + paths.append(commandPath) + } + + func joinCommandNames(_ path: [CommandComponent]) -> String { + path.map { $0.commandName }.joined(separator: "-") + } + + + } + + + + + /// Retrieves the list of subcommands for a given command, excluding common utility commands. + /// + /// This method checks whether the given command contains any subcommands. If so, it filters + /// out the `"help"` subcommand (often auto-generated or reserved), and returns the remaining + /// subcommands. + /// + /// - Parameter command: The `CommandInfoV0` instance representing the current command. + /// + /// - Returns: An array of `CommandInfoV0` representing valid subcommands, or `nil` if no subcommands exist. + func getSubCommand(from command: CommandInfoV0) -> [CommandInfoV0]? { + guard let subcommands = command.subcommands else { return nil } + + let filteredSubcommands = subcommands.filter { $0.commandName.lowercased() != "help" } + + guard !filteredSubcommands.isEmpty else { return nil } + + return filteredSubcommands + } + + /// Converts the command information into an array of argument metadata. + /// + /// - Parameter command: The command info object. + /// - Returns: An array of argument info objects. + /// - Throws: `TemplateError.noArguments` if the command has no arguments. + + func convertArguments(from command: CommandInfoV0) throws -> [ArgumentInfoV0] { + guard let rawArgs = command.arguments else { + throw TemplateError.noArguments + } + return rawArgs + } + + /// A helper struct to prompt the user for input values for command arguments. + + public enum UserPrompter { + /// Prompts the user for input for each argument, handling flags, options, and positional arguments. + /// + /// - Parameter arguments: The list of argument metadata to prompt for. + /// - Returns: An array of `ArgumentResponse` representing the user's input. + + public static func prompt( + for arguments: [ArgumentInfoV0], + collected: inout [String: ArgumentResponse] + ) -> [ArgumentResponse] { + return arguments + .filter { $0.valueName != "help" && $0.shouldDisplay } + .compactMap { arg in + let key = arg.preferredName?.name ?? arg.valueName ?? UUID().uuidString + + if let existing = collected[key] { + print("Using previous value for '\(key)': \(existing.values.joined(separator: ", "))") + return existing + } + + let defaultText = arg.defaultValue.map { " (default: \($0))" } ?? "" + let allValuesText = (arg.allValues?.isEmpty == false) ? + " [\(arg.allValues!.joined(separator: ", "))]" : "" + let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(defaultText):" + + var values: [String] = [] + + switch arg.kind { + case .flag: + let confirmed = promptForConfirmation( + prompt: promptMessage, + defaultBehavior: arg.defaultValue?.lowercased() == "true" + ) + values = [confirmed ? "true" : "false"] + + case .option, .positional: + print(promptMessage) + + if arg.isRepeating { + while let input = readLine(), !input.isEmpty { + if let allowed = arg.allValues, !allowed.contains(input) { + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + continue + } + values.append(input) + } + if values.isEmpty, let def = arg.defaultValue { + values = [def] + } + } else { + let input = readLine() + if let input, !input.isEmpty { + if let allowed = arg.allValues, !allowed.contains(input) { + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + exit(1) + } + values = [input] + } else if let def = arg.defaultValue { + values = [def] + } else if arg.isOptional == false { + fatalError("Required argument '\(arg.valueName ?? "")' not provided.") + } + } + } + + let response = ArgumentResponse(argument: arg, values: values) + collected[key] = response + return response + } + + } + } + + /// Prompts the user for a yes/no confirmation. + /// + /// - Parameters: + /// - prompt: The prompt message to display. + /// - defaultBehavior: The default value if the user provides no input. + /// - Returns: `true` if the user confirmed, otherwise `false`. + + private static func promptForConfirmation(prompt: String, defaultBehavior: Bool?) -> Bool { + let suffix = defaultBehavior == true ? " [Y/n]" : defaultBehavior == false ? " [y/N]" : " [y/n]" + print(prompt + suffix, terminator: " ") + guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + return defaultBehavior ?? false + } + + switch input { + case "y", "yes": return true + case "n", "no": return false + default: return defaultBehavior ?? false + } + } + + /// Represents a user's response to an argument prompt. + + public struct ArgumentResponse: Hashable { + /// The argument metadata. + let argument: ArgumentInfoV0 + + /// The values provided by the user. + public let values: [String] + + /// Returns the command line fragments representing this argument and its values. + public var commandLineFragments: [String] { + guard let name = argument.valueName else { + return self.values + } + + switch self.argument.kind { + case .flag: + return self.values.first == "true" ? ["--\(name)"] : [] + case .option: + return self.values.flatMap { ["--\(name)", $0] } + case .positional: + return self.values + } + } + } + + +} + +/// An error enum representing various template-related errors. +private enum TemplateError: Swift.Error { + /// The provided path is invalid or does not exist. + case invalidPath + + /// A manifest file already exists in the target directory. + case manifestAlreadyExists + + /// The template has no arguments to prompt for. + + case noArguments + case invalidArgument(name: String) + case unexpectedArgument(name: String) + case unexpectedNamedArgument(name: String) + case missingValueForOption(name: String) + case invalidValue(argument: String, invalidValues: [String], allowed: [String]) +} + +extension TemplateError: CustomStringConvertible { + /// A readable description of the error + var description: String { + switch self { + case .manifestAlreadyExists: + "a manifest file already exists in this directory" + case .invalidPath: + "Path does not exist, or is invalid." + case .noArguments: + "Template has no arguments" + case .invalidArgument(name: let name): + "Invalid argument name: \(name)" + case .unexpectedArgument(name: let name): + "Unexpected argument: \(name)" + case .unexpectedNamedArgument(name: let name): + "Unexpected named argument: \(name)" + case .missingValueForOption(name: let name): + "Missing value for option: \(name)" + case .invalidValue(argument: let argument, invalidValues: let invalidValues, allowed: let allowed): + "Invalid value \(argument). Valid values are: \(allowed.joined(separator: ", ")). \(invalidValues.isEmpty ? "" : "Also, \(invalidValues.joined(separator: ", ")) are not valid.")" + } + } +} + + + +private extension PluginCapability { + var commandInvocationVerb: String? { + guard case .command(let intent, _) = self else { return nil } + return intent.invocationVerb + } +} + From 5b71568f7d0dac542ae53d47d740a45a7980401a Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 14 Aug 2025 15:23:08 -0400 Subject: [PATCH 204/225] refactored directory manager to encapsulate shared logic between init and test --- .../TestCommands/TestTemplateCommand.swift | 39 +-------- ...ackageInitializationDirectoryManager.swift | 86 ++++++++----------- .../PackageInitializer.swift | 2 +- .../TemplateTestDirectoryManager.swift | 39 +++++++++ .../Workspace/TemplateDirectoryManager.swift | 57 ++++++++++++ 5 files changed, 135 insertions(+), 88 deletions(-) create mode 100644 Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift create mode 100644 Sources/Workspace/TemplateDirectoryManager.swift diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 2c0fd2d3cc3..f4e0e972e63 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -46,43 +46,6 @@ extension DispatchTimeInterval { } -//DEAL WITH THIS LATER -public struct TemplateTestingDirectoryManager { - let fileSystem: FileSystem - - //revisit - func createTemporaryDirectories(directories: Set) throws -> [Basics.AbsolutePath] { - - var result: [Basics.AbsolutePath] = [] - for directory in directories { - let dirPath = try fileSystem.tempDirectory.appending(component: directory) - try fileSystem.createDirectory(dirPath) - result.append(dirPath) - } - - return result - } - - func createOutputDirectory(outputDirectoryPath: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) throws { - let manifest = outputDirectoryPath.appending(component: Manifest.filename) - let fileSystem = swiftCommandState.fileSystem - let directoryExists = fileSystem.exists(outputDirectoryPath) - - if !directoryExists { - try FileManager.default.createDirectory( - at: outputDirectoryPath.asURL, - withIntermediateDirectories: true - ) - } else { - if fileSystem.exists(manifest) { - throw ValidationError("Package.swift was found in \(outputDirectoryPath).") - } - } - - } -} - - extension SwiftTestCommand { struct Template: AsyncSwiftCommand { static let configuration = CommandConfiguration( @@ -130,7 +93,7 @@ extension SwiftTestCommand { throw ValidationError("Could not determine current working directory.") } - let directoryManager = TemplateTestingDirectoryManager(fileSystem: swiftCommandState.fileSystem) + let directoryManager = TemplateTestingDirectoryManager(fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) try directoryManager.createOutputDirectory(outputDirectoryPath: outputDirectory, swiftCommandState: swiftCommandState) let pluginManager = try await TemplateTesterPluginManager( diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift index 96f84398b2f..5cb910175bf 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift @@ -1,39 +1,51 @@ - import Basics - - import Workspace import Foundation import CoreCommands +import Basics +import CoreCommands +import Foundation +import PackageModel + public struct TemplateInitializationDirectoryManager { + let observabilityScope: ObservabilityScope let fileSystem: FileSystem + let helper: TemporaryDirectoryHelper - func createTemporaryDirectories() throws -> (stagingPath: Basics.AbsolutePath, cleanUpPath: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) { - let tempDir = try fileSystem.tempDirectory.appending(component: UUID().uuidString) - let stagingPath = tempDir.appending(component: "generated-package") - let cleanupPath = tempDir.appending(component: "clean-up") - try fileSystem.createDirectory(tempDir) - return (stagingPath, cleanupPath, tempDir) + public init(fileSystem: FileSystem, observabilityScope: ObservabilityScope) { + self.fileSystem = fileSystem + self.helper = TemporaryDirectoryHelper(fileSystem: fileSystem) + self.observabilityScope = observabilityScope } - func finalize( + public func createTemporaryDirectories() throws -> (stagingPath: Basics.AbsolutePath, cleanupPath: Basics.AbsolutePath, tempDir: Basics.AbsolutePath) { + let tempDir = try helper.createTemporaryDirectory() + let dirs = try helper.createSubdirectories(in: tempDir, names: ["generated-package", "clean-up"]) + + return (dirs[0], dirs[1], tempDir) + } + + public func finalize( cwd: Basics.AbsolutePath, stagingPath: Basics.AbsolutePath, cleanupPath: Basics.AbsolutePath, swiftCommandState: SwiftCommandState ) async throws { - if fileSystem.exists(cwd) { - do { - try fileSystem.removeFileTree(cwd) - } catch { - throw FileOperationError.failedToRemoveExistingDirectory(path: cwd, underlying: error) - } + do { + try helper.removeDirectoryIfExists(cwd) + } catch { + observabilityScope.emit( + error: DirectoryManagerError.failedToRemoveDirectory(path: cwd, underlying: error), + underlyingError: error + ) + throw DirectoryManagerError.failedToRemoveDirectory(path: cwd, underlying: error) } - try fileSystem.copy(from: stagingPath, to: cleanupPath) + + try helper.copyDirectory(from: stagingPath, to: cleanupPath) try await cleanBuildArtifacts(at: cleanupPath, swiftCommandState: swiftCommandState) - try fileSystem.copy(from: cleanupPath, to: cwd) + try helper.copyDirectory(from: cleanupPath, to: cwd) } func cleanBuildArtifacts(at path: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) async throws { @@ -45,11 +57,7 @@ public struct TemplateInitializationDirectoryManager { public func cleanupTemporary(templateSource: InitTemplatePackage.TemplateSource, path: Basics.AbsolutePath, temporaryDirectory: Basics.AbsolutePath?) throws { do { switch templateSource { - case .git: - if FileManager.default.fileExists(atPath: path.pathString) { - try FileManager.default.removeItem(at: path.asURL) - } - case .registry: + case .git, .registry: if FileManager.default.fileExists(atPath: path.pathString) { try FileManager.default.removeItem(at: path.asURL) } @@ -58,35 +66,15 @@ public struct TemplateInitializationDirectoryManager { } if let tempDir = temporaryDirectory { - try fileSystem.removeFileTree(tempDir) + try helper.removeDirectoryIfExists(tempDir) } } catch { - throw CleanupError.failedToCleanup(temporaryDirectory: temporaryDirectory, underlying: error) - } - } - - enum CleanupError: Error, CustomStringConvertible { - case failedToCleanup(temporaryDirectory: Basics.AbsolutePath?, underlying: Error) - - var description: String { - switch self { - case .failedToCleanup(let temporaryDirectory, let error): - let tempDir = temporaryDirectory?.pathString ?? "" - return "Failed to clean up temporary directory at \(tempDir): \(error.localizedDescription)" - } + observabilityScope.emit( + error: DirectoryManagerError.cleanupFailed(path: temporaryDirectory, underlying: error), + underlyingError: error + ) + throw DirectoryManagerError.cleanupFailed(path: temporaryDirectory, underlying: error) } } - - enum FileOperationError: Error, CustomStringConvertible { - case failedToRemoveExistingDirectory(path: Basics.AbsolutePath, underlying: Error) - - var description: String { - switch self { - case .failedToRemoveExistingDirectory(let path, let underlying): - return "Failed to remove existing directory at \(path): \(underlying.localizedDescription)" - } - } - } - } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index d6aeae8bcfb..d0a20ece22b 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -50,7 +50,7 @@ struct TemplatePackageInitializer: PackageInitializer { swiftCommandState: swiftCommandState ).resolve() - let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem) + let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) let (stagingPath, cleanupPath, tempDir) = try directoryManager.createTemporaryDirectories() let packageType = try await inferPackageType(from: resolvedTemplatePath) diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift new file mode 100644 index 00000000000..2cb9bd2642f --- /dev/null +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift @@ -0,0 +1,39 @@ +import Basics +import CoreCommands +import Foundation +import Workspace +import PackageModel + +public struct TemplateTestingDirectoryManager { + let fileSystem: FileSystem + let helper: TemporaryDirectoryHelper + let observabilityScope: ObservabilityScope + + public init(fileSystem: FileSystem, observabilityScope: ObservabilityScope) { + self.fileSystem = fileSystem + self.helper = TemporaryDirectoryHelper(fileSystem: fileSystem) + self.observabilityScope = observabilityScope + } + + public func createTemporaryDirectories(directories: Set) throws -> [Basics.AbsolutePath] { + let tempDir = try helper.createTemporaryDirectory() + return try helper.createSubdirectories(in: tempDir, names: Array(directories)) + } + + public func createOutputDirectory(outputDirectoryPath: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) throws { + let manifestPath = outputDirectoryPath.appending(component: Manifest.filename) + let fs = swiftCommandState.fileSystem + + if !helper.directoryExists(outputDirectoryPath) { + try FileManager.default.createDirectory( + at: outputDirectoryPath.asURL, + withIntermediateDirectories: true + ) + } else if fs.exists(manifestPath) { + observabilityScope.emit( + error: DirectoryManagerError.foundManifestFile(path: outputDirectoryPath) + ) + throw DirectoryManagerError.foundManifestFile(path: outputDirectoryPath) + } + } +} diff --git a/Sources/Workspace/TemplateDirectoryManager.swift b/Sources/Workspace/TemplateDirectoryManager.swift new file mode 100644 index 00000000000..f8dab6c66f3 --- /dev/null +++ b/Sources/Workspace/TemplateDirectoryManager.swift @@ -0,0 +1,57 @@ +import Basics +import Foundation + +public struct TemporaryDirectoryHelper { + let fileSystem: FileSystem + + public init(fileSystem: FileSystem) { + self.fileSystem = fileSystem + } + + public func createTemporaryDirectory(named name: String? = nil) throws -> Basics.AbsolutePath { + let dirName = name ?? UUID().uuidString + let dirPath = try fileSystem.tempDirectory.appending(component: dirName) + try fileSystem.createDirectory(dirPath) + return dirPath + } + + public func createSubdirectories(in parent: Basics.AbsolutePath, names: [String]) throws -> [Basics.AbsolutePath] { + return try names.map { name in + let path = parent.appending(component: name) + try fileSystem.createDirectory(path) + return path + } + } + + public func directoryExists(_ path: Basics.AbsolutePath) -> Bool { + return fileSystem.exists(path) + } + + public func removeDirectoryIfExists(_ path: Basics.AbsolutePath) throws { + if fileSystem.exists(path) { + try fileSystem.removeFileTree(path) + } + } + + public func copyDirectory(from: Basics.AbsolutePath, to: Basics.AbsolutePath) throws { + try fileSystem.copy(from: from, to: to) + } +} + +public enum DirectoryManagerError: Error, CustomStringConvertible { + case failedToRemoveDirectory(path: Basics.AbsolutePath, underlying: Error) + case foundManifestFile(path: Basics.AbsolutePath) + case cleanupFailed(path: Basics.AbsolutePath?, underlying: Error) + + public var description: String { + switch self { + case .failedToRemoveDirectory(let path, let error): + return "Failed to remove directory at \(path): \(error.localizedDescription)" + case .foundManifestFile(let path): + return "Package.swift was found in \(path)." + case .cleanupFailed(let path, let error): + let dir = path?.pathString ?? "" + return "Failed to clean up directory at \(dir): \(error.localizedDescription)" + } + } +} From 4423fd781995e1c51e9d603b1c92debf748933f2 Mon Sep 17 00:00:00 2001 From: John Bute Date: Fri, 15 Aug 2025 07:59:30 -0400 Subject: [PATCH 205/225] refactored TemplateManager based on shared logic between init + test --- .../PackageCommands/ShowTemplates.swift | 10 +- .../TemplatePluginCoordinator.swift | 99 ++++++++++++ .../TemplatePluginManager.swift | 117 ++------------ .../TemplateTesterManager.swift | 152 +++--------------- .../InitPackage.swift | 0 .../InitTemplatePackage.swift | 0 .../TemplateDirectoryManager.swift | 0 7 files changed, 138 insertions(+), 240 deletions(-) create mode 100644 Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift rename Sources/Workspace/{ => TemplateWorkspaceUtilities}/InitPackage.swift (100%) rename Sources/Workspace/{ => TemplateWorkspaceUtilities}/InitTemplatePackage.swift (100%) rename Sources/Workspace/{ => TemplateWorkspaceUtilities}/TemplateDirectoryManager.swift (100%) diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 2049e4a62a9..7cccb3e7444 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -81,7 +81,7 @@ struct ShowTemplates: AsyncSwiftCommand { let resolvedPath = try await resolveTemplatePath(using: swiftCommandState, source: source) let templates = try await loadTemplates(from: resolvedPath, swiftCommandState: swiftCommandState) try await displayTemplates(templates, at: resolvedPath, using: swiftCommandState) - try cleanupTemplate(source: source, path: resolvedPath, fileSystem: swiftCommandState.fileSystem) + try cleanupTemplate(source: source, path: resolvedPath, fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) } private func resolveSource(cwd: AbsolutePath?) throws -> InitTemplatePackage.TemplateSource { @@ -127,9 +127,9 @@ struct ShowTemplates: AsyncSwiftCommand { try await swiftCommandState.loadPackageGraph() } - let rootPackages = graph.rootPackages.map(\.identity) + let rootPackages = graph.rootPackages.map{ $0.identity } - return graph.allModules.filter(\.underlying.template).map { + return graph.allModules.filter({$0.underlying.template}).map { Template(package: rootPackages.contains($0.packageIdentity) ? nil : $0.packageIdentity.description, name: $0.name) } } @@ -189,8 +189,8 @@ struct ShowTemplates: AsyncSwiftCommand { } } - private func cleanupTemplate(source: InitTemplatePackage.TemplateSource, path: AbsolutePath, fileSystem: FileSystem) throws { - try TemplateInitializationDirectoryManager(fileSystem: fileSystem) + private func cleanupTemplate(source: InitTemplatePackage.TemplateSource, path: AbsolutePath, fileSystem: FileSystem, observabilityScope: ObservabilityScope) throws { + try TemplateInitializationDirectoryManager(fileSystem: fileSystem, observabilityScope: observabilityScope) .cleanupTemporary(templateSource: source, path: path, temporaryDirectory: nil) } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift new file mode 100644 index 00000000000..d45496ba94d --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift @@ -0,0 +1,99 @@ + +import ArgumentParser +import ArgumentParserToolInfo + +import Basics + +@_spi(SwiftPMInternal) +import CoreCommands + +import PackageModel +import Workspace +import SPMBuildCore +import TSCBasic +import TSCUtility +import Foundation +import PackageGraph + + +struct TemplatePluginCoordinator { + let swiftCommandState: SwiftCommandState + let scratchDirectory: Basics.AbsolutePath + let template: String? + let args: [String] + + let EXPERIMENTAL_DUMP_HELP = ["--", "--experimental-dump-help"] + + func loadPackageGraph() async throws -> ModulesGraph { + try await swiftCommandState.withTemporaryWorkspace(switchingTo: scratchDirectory) { _, _ in + try await swiftCommandState.loadPackageGraph() + } + } + + /// Loads the plugin that corresponds to the template's name. + /// + /// - Throws: + /// - `PluginError.noMatchingTemplate(name: String?)` if there are no plugins corresponding to the desired template. + /// - `PluginError.multipleMatchingTemplates(names: [String]` if the search returns more than one plugin given a desired template + /// + /// - Returns: A data representation of the result of the execution of the template's plugin. + func loadTemplatePlugin(from packageGraph: ModulesGraph) throws -> ResolvedModule { + let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: packageGraph, limitedTo: nil) + switch matchingPlugins.count { + case 0: + throw PluginError.noMatchingTemplate(name: self.template) + case 1: + return matchingPlugins[0] + default: + let names = matchingPlugins.compactMap { plugin in + (plugin.underlying as? PluginModule)?.capability.commandInvocationVerb + } + throw PluginError.multipleMatchingTemplates(names: names) + } + } + + /// Manages the logic of dumping the JSON representation of a template's decision tree. + /// + /// - Throws: + /// - `TemplatePluginError.failedToDecodeToolInfo(underlying: error)` If there is a change in representation between the JSON and the current version of the ToolInfoV0 struct + + func dumpToolInfo(using plugin: ResolvedModule, from packageGraph: ModulesGraph, rootPackage: ResolvedPackage) async throws -> ToolInfoV0 { + let output = try await TemplatePluginRunner.run( + plugin: plugin, + package: rootPackage, + packageGraph: packageGraph, + arguments: EXPERIMENTAL_DUMP_HELP, + swiftCommandState: swiftCommandState + ) + + do { + return try JSONDecoder().decode(ToolInfoV0.self, from: output) + } catch { + throw PluginError.failedToDecodeToolInfo(underlying: error) + } + } + + enum PluginError: Error, CustomStringConvertible { + case noMatchingTemplate(name: String?) + case multipleMatchingTemplates(names: [String]) + case failedToDecodeToolInfo(underlying: Error) + + var description: String { + switch self { + case let .noMatchingTemplate(name): + "No templates found matching '\(name ?? "")'" + case let .multipleMatchingTemplates(names): + "Multiple templates matched: \(names.joined(separator: ", "))" + case let .failedToDecodeToolInfo(underlying): + "Failed to decode tool info: \(underlying.localizedDescription)" + } + } + } +} + +private extension PluginCapability { + var commandInvocationVerb: String? { + guard case .command(let intent, _) = self else { return nil } + return intent.invocationVerb + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 5db8218f6fd..113a9168d6e 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -1,36 +1,18 @@ - -import ArgumentParser import ArgumentParserToolInfo import Basics -@_spi(SwiftPMInternal) import CoreCommands -import PackageModel import Workspace -import SPMBuildCore -import TSCBasic -import TSCUtility import Foundation import PackageGraph public protocol TemplatePluginManager { - func loadTemplatePlugin() throws -> ResolvedModule - //func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [[String]] func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data - - var swiftCommandState: SwiftCommandState { get } - var template: String? { get } - var packageGraph: ModulesGraph { get } - var scratchDirectory: Basics.AbsolutePath { get } - var args: [String] { get } - - var EXPERIMENTAL_DUMP_HELP: [String] { get } } - /// A utility for obtaining and running a template's plugin . /// /// `TemplateIntiializationPluginManager` encapsulates the logic needed to fetch, @@ -38,14 +20,11 @@ public protocol TemplatePluginManager { struct TemplateInitializationPluginManager: TemplatePluginManager { let swiftCommandState: SwiftCommandState let template: String? - - let packageGraph: ModulesGraph - let scratchDirectory: Basics.AbsolutePath - let args: [String] + let packageGraph: ModulesGraph + let coordinator: TemplatePluginCoordinator - let EXPERIMENTAL_DUMP_HELP: [String] = ["--", "--experimental-dump-help"] var rootPackage: ResolvedPackage { guard let root = packageGraph.rootPackages.first else { @@ -55,14 +34,19 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { } init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String]) async throws { + let coordinator = TemplatePluginCoordinator( + swiftCommandState: swiftCommandState, + scratchDirectory: scratchDirectory, + template: template, + args: args + ) + + self.packageGraph = try await coordinator.loadPackageGraph() self.swiftCommandState = swiftCommandState self.template = template self.scratchDirectory = scratchDirectory self.args = args - - self.packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: scratchDirectory) { _, _ in - try await swiftCommandState.loadPackageGraph() - } + self.coordinator = coordinator } /// Manages the logic of running a template and executing on the information provided by the JSON representation of a template's arguments. @@ -73,37 +57,13 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// - `TemplatePluginError.execu` func run() async throws { - //Load the plugin corresponding to the template - let commandLinePlugin = try loadTemplatePlugin() + let plugin = try coordinator.loadTemplatePlugin(from: packageGraph) + let toolInfo = try await coordinator.dumpToolInfo(using: plugin, from: packageGraph, rootPackage: rootPackage) - // Execute experimental-dump-help to get the JSON representing the template's decision tree - let output: Data - - do { - output = try await executeTemplatePlugin(commandLinePlugin, with: EXPERIMENTAL_DUMP_HELP) - } catch { - throw TemplatePluginError.executionFailed(underlying: error) - } - - //Decode the JSON into ArgumentParserToolInfo ToolInfoV0 struct - let toolInfo: ToolInfoV0 - - do { - toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - } catch { - throw TemplatePluginError.failedToDecodeToolInfo(underlying: error) - } - - // Prompt the user for any information needed by the template let cliResponses: [[String]] = try promptUserForTemplateArguments(using: toolInfo) - // Execute the template to generate a user's project for response in cliResponses { - do { - let _ = try await executeTemplatePlugin(commandLinePlugin, with: response) - } catch { - throw TemplatePluginError.executionFailed(underlying: error) - } + _ = try await executeTemplatePlugin(plugin, with: response) } } @@ -150,52 +110,7 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// /// - Returns: A data representation of the result of the execution of the template's plugin. - internal func loadTemplatePlugin() throws -> ResolvedModule { - let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: self.packageGraph, limitedTo: nil) - - switch matchingPlugins.count { - case 0: - throw TemplatePluginError.noMatchingTemplate(name: self.template) - case 1: - return matchingPlugins[0] - default: - let names = matchingPlugins.compactMap { plugin in - (plugin.underlying as? PluginModule)?.capability.commandInvocationVerb - } - throw TemplatePluginError.multipleMatchingTemplates(names: names) - } - } - - enum TemplatePluginError: Error, CustomStringConvertible { - case noRootPackage - case noMatchingTemplate(name: String?) - case multipleMatchingTemplates(names: [String]) - case failedToDecodeToolInfo(underlying: Error) - case executionFailed(underlying: Error) - - var description: String { - switch self { - case .noRootPackage: - return "No root package found in the package graph." - case let .noMatchingTemplate(name): - let templateName = name ?? "" - return "No templates found matching '\(templateName)" - case let .multipleMatchingTemplates(names): - return """ - Multiple templates matched. Use `--type` to specify one of the following: \(names.joined(separator: ", ")) - """ - case let .failedToDecodeToolInfo(underlying): - return "Failed to decode template tool info: \(underlying.localizedDescription)" - case let .executionFailed(underlying): - return "Plugin execution failed: \(underlying.localizedDescription)" - } - } - } -} - -private extension PluginCapability { - var commandInvocationVerb: String? { - guard case .command(let intent, _) = self else { return nil } - return intent.invocationVerb + func loadTemplatePlugin() throws -> ResolvedModule { + try coordinator.loadTemplatePlugin(from: packageGraph) } } diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index 565679053a5..3860cc8baff 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -1,17 +1,10 @@ - -import ArgumentParser import ArgumentParserToolInfo import Basics -@_spi(SwiftPMInternal) import CoreCommands -import PackageModel import Workspace -import SPMBuildCore -import TSCBasic -import TSCUtility import Foundation import PackageGraph @@ -22,98 +15,47 @@ import PackageGraph public struct TemplateTesterPluginManager: TemplatePluginManager { public let swiftCommandState: SwiftCommandState public let template: String? - - public let packageGraph: ModulesGraph - public let scratchDirectory: Basics.AbsolutePath - public let args: [String] + public let packageGraph: ModulesGraph + let coordinator: TemplatePluginCoordinator - public let EXPERIMENTAL_DUMP_HELP: [String] = ["--", "--experimental-dump-help"] - - var rootPackage: ResolvedPackage { + public var rootPackage: ResolvedPackage { guard let root = packageGraph.rootPackages.first else { - fatalError("No root package found in the package graph.") + fatalError("No root package found.") } return root } init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String]) async throws { + let coordinator = TemplatePluginCoordinator( + swiftCommandState: swiftCommandState, + scratchDirectory: scratchDirectory, + template: template, + args: args + ) + + self.packageGraph = try await coordinator.loadPackageGraph() self.swiftCommandState = swiftCommandState self.template = template self.scratchDirectory = scratchDirectory self.args = args - - self.packageGraph = try await swiftCommandState.withTemporaryWorkspace(switchingTo: scratchDirectory) { _, _ in - try await swiftCommandState.loadPackageGraph() - } + self.coordinator = coordinator } - /// Manages the logic of running a template and executing on the information provided by the JSON representation of a template's arguments. - /// - /// - Throws: - /// - `TemplatePluginError.executionFailed(underlying: error)` If there was an error during the execution of a template's plugin. - /// - `TemplatePluginError.failedToDecodeToolInfo(underlying: error)` If there is a change in representation between the JSON and the current version of the ToolInfoV0 struct - /// - `TemplatePluginError.execute` - func run() async throws -> [CommandPath] { - //Load the plugin corresponding to the template + let plugin = try coordinator.loadTemplatePlugin(from: packageGraph) + let toolInfo = try await coordinator.dumpToolInfo(using: plugin, from: packageGraph, rootPackage: rootPackage) - let commandLinePlugin = try loadTemplatePlugin() - - // Execute experimental-dump-help to get the JSON representing the template's decision tree - let output: Data - - do { - output = try await executeTemplatePlugin(commandLinePlugin, with: EXPERIMENTAL_DUMP_HELP) - } catch { - throw TemplatePluginError.executionFailed(underlying: error) - } - - //Decode the JSON into ArgumentParserToolInfo ToolInfoV0 struct - let toolInfo: ToolInfoV0 - - do { - toolInfo = try JSONDecoder().decode(ToolInfoV0.self, from: output) - } catch { - throw TemplatePluginError.failedToDecodeToolInfo(underlying: error) - } - - // Prompt the user for any information needed by the template return try promptUserForTemplateArguments(using: toolInfo) } - - - - - /// Utilizes the prompting system defined by the struct to prompt user. - /// - /// - Parameters: - /// - toolInfo: The JSON representation of the template's decision tree. - /// - /// - Throws: - /// - Any other errors thrown during the prompting of the user. - /// - /// - Returns: A 2D array of the arguments given by the user, that will be consumed by the template during the project generation phase. func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [CommandPath] { - return try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command) + try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command) } - - /// Runs the plugin of a template given a set of arguments. - /// - /// - Parameters: - /// - plugin: The resolved module that corresponds to the plugin tied with the template executable. - /// - arguments: A 2D array of arguments that will be passed to the plugin - /// - /// - Throws: - /// - Any Errors thrown during the execution of the template's plugin given a 2D of arguments. - /// - /// - Returns: A data representation of the result of the execution of the template's plugin. - public func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { - return try await TemplatePluginRunner.run( + try await TemplatePluginRunner.run( plugin: plugin, package: rootPackage, packageGraph: packageGraph, @@ -122,60 +64,12 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { ) } - /// Loads the plugin that corresponds to the template's name. - /// - /// - Throws: - /// - `TempaltePluginError.noMatchingTemplate(name: String?)` if there are no plugins corresponding to the desired template. - /// - `TemplatePluginError.multipleMatchingTemplates(names: [String]` if the search returns more than one plugin given a desired template - /// - /// - Returns: A data representation of the result of the execution of the template's plugin. - public func loadTemplatePlugin() throws -> ResolvedModule { - - let matchingPlugins = PluginCommand.findPlugins(matching: self.template, in: self.packageGraph, limitedTo: nil) - - switch matchingPlugins.count { - case 0: - throw TemplatePluginError.noMatchingTemplate(name: self.template) - case 1: - return matchingPlugins[0] - default: - let names = matchingPlugins.compactMap { plugin in - (plugin.underlying as? PluginModule)?.capability.commandInvocationVerb - } - throw TemplatePluginError.multipleMatchingTemplates(names: names) - } - } - - enum TemplatePluginError: Error, CustomStringConvertible { - case noRootPackage - case noMatchingTemplate(name: String?) - case multipleMatchingTemplates(names: [String]) - case failedToDecodeToolInfo(underlying: Error) - case executionFailed(underlying: Error) - - var description: String { - switch self { - case .noRootPackage: - return "No root package found in the package graph." - case let .noMatchingTemplate(name): - let templateName = name ?? "" - return "No templates found matching '\(templateName)" - case let .multipleMatchingTemplates(names): - return """ - Multiple templates matched. Use `--type` to specify one of the following: \(names.joined(separator: ", ")) - """ - case let .failedToDecodeToolInfo(underlying): - return "Failed to decode template tool info: \(underlying.localizedDescription)" - case let .executionFailed(underlying): - return "Plugin execution failed: \(underlying.localizedDescription)" - } - } + try coordinator.loadTemplatePlugin(from: packageGraph) } } - public struct CommandPath { public let fullPathKey: String public let commandChain: [CommandComponent] @@ -476,13 +370,3 @@ extension TemplateError: CustomStringConvertible { } } } - - - -private extension PluginCapability { - var commandInvocationVerb: String? { - guard case .command(let intent, _) = self else { return nil } - return intent.invocationVerb - } -} - diff --git a/Sources/Workspace/InitPackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift similarity index 100% rename from Sources/Workspace/InitPackage.swift rename to Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift diff --git a/Sources/Workspace/InitTemplatePackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift similarity index 100% rename from Sources/Workspace/InitTemplatePackage.swift rename to Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift diff --git a/Sources/Workspace/TemplateDirectoryManager.swift b/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift similarity index 100% rename from Sources/Workspace/TemplateDirectoryManager.swift rename to Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift From 9ffc964eacedc01b6f9687f4944cf78e8ccc7ff9 Mon Sep 17 00:00:00 2001 From: John Bute Date: Fri, 22 Aug 2025 10:55:42 -0400 Subject: [PATCH 206/225] added end-to-end test for testing initializing a package from local template + fixed logic with handling of directories --- .../ExecutableTemplate/Package.swift | 23 +++++++ .../ExecutableTemplatePlugin.swift | 54 +++++++++++++++ .../ExecutableTemplate/Template.swift | 68 +++++++++++++++++++ ...ackageInitializationDirectoryManager.swift | 14 +--- .../TemplateDirectoryManager.swift | 9 ++- 5 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift create mode 100644 Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Plugins/ExecutableTemplate/ExecutableTemplatePlugin.swift create mode 100644 Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Templates/ExecutableTemplate/Template.swift diff --git a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift new file mode 100644 index 00000000000..d2399bd40c3 --- /dev/null +++ b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version:999.0.0 +import PackageDescription + + +let package = Package( + name: "SimpleTemplateExample", + products: + .template(name: "ExecutableTemplate"), + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2"), + ], + targets: .template( + name: "ExecutableTemplate", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "SystemPackage", package: "swift-system"), + ], + + initialPackageType: .executable, + description: "This is a simple template that uses Swift string interpolation." + ) +) diff --git a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Plugins/ExecutableTemplate/ExecutableTemplatePlugin.swift b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Plugins/ExecutableTemplate/ExecutableTemplatePlugin.swift new file mode 100644 index 00000000000..63aa3ed64c6 --- /dev/null +++ b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Plugins/ExecutableTemplate/ExecutableTemplatePlugin.swift @@ -0,0 +1,54 @@ +// +// plugin.swift +// TemplateWorkflow +// +// Created by John Bute on 2025-04-14. +// +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct TemplatePlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws { + let tool = try context.tool(named: "ExecutableTemplate") + let packageDirectory = context.package.directoryURL.path + let process = Process() + let stderrPipe = Pipe() + + process.executableURL = URL(filePath: tool.url.path()) + process.arguments = ["--pkg-dir", packageDirectory] + arguments.filter { $0 != "--" } + process.standardError = stderrPipe + + try process.run() + process.waitUntilExit() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderrOutput = String(data: stderrData, encoding: .utf8) ?? "" + + if process.terminationStatus != 0 { + throw PluginError.executionFailed(code: process.terminationStatus, stderrOutput: stderrOutput) + } + + } + enum PluginError: Error, CustomStringConvertible { + case executionFailed(code: Int32, stderrOutput: String) + + var description: String { + switch self { + case .executionFailed(let code, let stderrOutput): + return """ + + Plugin subprocess failed with exit code \(code). + + Output: + \(stderrOutput) + + """ + } + } + } +} diff --git a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Templates/ExecutableTemplate/Template.swift b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Templates/ExecutableTemplate/Template.swift new file mode 100644 index 00000000000..68f3fc1efb4 --- /dev/null +++ b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Templates/ExecutableTemplate/Template.swift @@ -0,0 +1,68 @@ +import ArgumentParser +import Foundation +import SystemPackage + +extension FilePath { + static func / (left: FilePath, right: String) -> FilePath { + left.appending(right) + } +} + +extension String { + func write(toFile: FilePath) throws { + try self.write(toFile: toFile.string, atomically: true, encoding: .utf8) + } +} + +//basic structure of a template that uses string interpolation +@main +struct HelloTemplateTool: ParsableCommand { + + @OptionGroup(visibility: .hidden) + var packageOptions: PkgDir + + //swift argument parser needed to expose arguments to template generator + @Option(help: "The name of your app") + var name: String + + @Flag(help: "Include a README?") + var includeReadme: Bool = false + + //entrypoint of the template executable, that generates just a main.swift and a readme.md + func run() throws { + + guard let pkgDir = packageOptions.pkgDir else { + throw ValidationError("No --pkg-dir was provided.") + } + + let fs = FileManager.default + + let packageDir = FilePath(pkgDir) + + let mainFile = packageDir / "Sources" / name / "main.swift" + + try fs.createDirectory(atPath: mainFile.removingLastComponent().string, withIntermediateDirectories: true) + + try """ + // This is the entry point to your command-line app + print("Hello, \(name)!") + + """.write(toFile: mainFile) + + if includeReadme { + try """ + # \(name) + This is a new Swift app! + """.write(toFile: packageDir / "README.md") + } + + print("Project generated at \(packageDir)") + } +} + + +// MARK: - Shared option commands that are used to show inheritances of arguments and flags +struct PkgDir: ParsableArguments { + @Option(help: .hidden) + var pkgDir: String? +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift index 5cb910175bf..213c5372d7c 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift @@ -33,19 +33,9 @@ public struct TemplateInitializationDirectoryManager { cleanupPath: Basics.AbsolutePath, swiftCommandState: SwiftCommandState ) async throws { - do { - try helper.removeDirectoryIfExists(cwd) - } catch { - observabilityScope.emit( - error: DirectoryManagerError.failedToRemoveDirectory(path: cwd, underlying: error), - underlyingError: error - ) - throw DirectoryManagerError.failedToRemoveDirectory(path: cwd, underlying: error) - } - - try helper.copyDirectory(from: stagingPath, to: cleanupPath) + try helper.copyDirectoryContents(from: stagingPath, to: cleanupPath) try await cleanBuildArtifacts(at: cleanupPath, swiftCommandState: swiftCommandState) - try helper.copyDirectory(from: cleanupPath, to: cwd) + try helper.copyDirectoryContents(from: cleanupPath, to: cwd) } func cleanBuildArtifacts(at path: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) async throws { diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift b/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift index f8dab6c66f3..69e32e2f5fd 100644 --- a/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift +++ b/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift @@ -33,8 +33,13 @@ public struct TemporaryDirectoryHelper { } } - public func copyDirectory(from: Basics.AbsolutePath, to: Basics.AbsolutePath) throws { - try fileSystem.copy(from: from, to: to) + public func copyDirectoryContents(from sourceDir: AbsolutePath, to destinationDir: AbsolutePath) throws { + let contents = try fileSystem.getDirectoryContents(sourceDir) + for entry in contents { + let source = sourceDir.appending(component: entry) + let destination = destinationDir.appending(component: entry) + try fileSystem.copy(from: source, to: destination) + } } } From a4bdf0193b72fc0b1ac82eead7414c4dd38560de Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 26 Aug 2025 10:55:51 -0400 Subject: [PATCH 207/225] reducing PR size --- README.md | 4 +- Sources/Basics/SourceControlURL.swift | 4 - Sources/Commands/PackageCommands/Init.swift | 9 - .../PackageCommands/PluginCommand.swift | 2 +- .../PackageCommands/ShowTemplates.swift | 1 - Sources/Commands/SwiftTestCommand.swift | 1 - Sources/PackageMetadata/PackageMetadata.swift | 16 +- .../PackageModel/Module/BinaryModule.swift | 7 +- Sources/PackageRegistry/RegistryClient.swift | 2 +- .../PackageRegistryCommand+Discover.swift | 101 ----------- .../PackageRegistryCommand+Get.swift | 164 ------------------ .../PackageRegistryCommand.swift | 10 +- Sources/SourceControl/GitRepository.swift | 15 -- .../InitPackage.swift | 1 + .../Workspace/Workspace+Dependencies.swift | 87 +--------- 15 files changed, 10 insertions(+), 414 deletions(-) delete mode 100644 Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift delete mode 100644 Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift diff --git a/README.md b/README.md index d42f138d7d5..1b1f8bcde16 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,13 @@ Now you can go to an empty directory and use an example template to make a packa There's also a template maker that will help you to write your own template. Here's how you can generate your own template: ``` -/.build/debug/swift-package init --template TemplateMaker --template-type git --template-url git@github.pie.apple.com:christie-mcgee/template.git +/.build/debug/swift-package init --type TemplateMaker --template-type git --template-url git@github.pie.apple.com:christie-mcgee/template.git ``` Once you've customized your template then you can test it from an empty directory: ``` -/.build/debug/swift-package init --template MyTemplate --template-type local --template-path +/.build/debug/swift-package init --type MyTemplate --template-type local --template-path ``` ## About SwiftPM diff --git a/Sources/Basics/SourceControlURL.swift b/Sources/Basics/SourceControlURL.swift index 70bb1df9cee..398c2f89ddf 100644 --- a/Sources/Basics/SourceControlURL.swift +++ b/Sources/Basics/SourceControlURL.swift @@ -23,10 +23,6 @@ public struct SourceControlURL: Codable, Equatable, Hashable, Sendable { self.urlString = urlString } - public init(argument: String) { - self.urlString = argument - } - public init(_ url: URL) { self.urlString = url.absoluteString } diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 31b4da6cc12..f02569f003f 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -11,7 +11,6 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ArgumentParserToolInfo import Basics @@ -21,10 +20,7 @@ import CoreCommands import PackageModel import Workspace import SPMBuildCore -import TSCBasic import TSCUtility -import Foundation -import PackageGraph extension SwiftPackageCommand { @@ -54,11 +50,6 @@ extension SwiftPackageCommand { """)) var initMode: String? - //if --type is mentioned with one of the seven above, then normal initialization - // if --type is mentioned along with a templateSource, its a template (no matter what) - // if-type is not mentioned with no templatesoURCE, then defaults to library - // if --type is not mentioned and templateSource is not nil, then there is only one template in package - /// Which testing libraries to use (and any related options.) @OptionGroup(visibility: .hidden) var testLibraryOptions: TestLibraryOptions diff --git a/Sources/Commands/PackageCommands/PluginCommand.swift b/Sources/Commands/PackageCommands/PluginCommand.swift index 380e133052c..042e99b77e9 100644 --- a/Sources/Commands/PackageCommands/PluginCommand.swift +++ b/Sources/Commands/PackageCommands/PluginCommand.swift @@ -376,7 +376,7 @@ struct PluginCommand: AsyncSwiftCommand { let allowNetworkConnectionsCopy = allowNetworkConnections let buildEnvironment = buildParameters.buildEnvironment - _ = try await pluginTarget.invoke( + let _ = try await pluginTarget.invoke( action: .performCommand(package: package, arguments: arguments), buildEnvironment: buildEnvironment, scriptRunner: pluginScriptRunner, diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index 7cccb3e7444..e51ed7530e9 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -147,7 +147,6 @@ struct ShowTemplates: AsyncSwiftCommand { throw InternalError("invalid manifests at \(root.packages)") } - let products = rootManifest.products let targets = rootManifest.targets if let target = targets.first(where: { $0.name == template }), diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index 3048c00b8a1..2056d6b9be6 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -11,7 +11,6 @@ //===----------------------------------------------------------------------===// import ArgumentParser -import ArgumentParserToolInfo @_spi(SwiftPMInternal) import Basics diff --git a/Sources/PackageMetadata/PackageMetadata.swift b/Sources/PackageMetadata/PackageMetadata.swift index 5745ac608cd..8622c1181d7 100644 --- a/Sources/PackageMetadata/PackageMetadata.swift +++ b/Sources/PackageMetadata/PackageMetadata.swift @@ -24,20 +24,6 @@ import struct Foundation.URL import struct TSCUtility.Version public struct Package { - - public struct Template: Sendable { - public let name: String - public let description: String? - //public let permissions: [String]? TODO ADD - public let arguments: [TemplateArguments]? - } - - public struct TemplateArguments: Sendable { - public let name: String - public let description: String? - public let isRequired: Bool? - } - public enum Source { case indexAndCollections(collections: [PackageCollectionsModel.CollectionIdentifier], indexes: [URL]) case registry(url: URL) @@ -103,7 +89,7 @@ public struct Package { publishedAt: Date? = nil, signingEntity: SigningEntity? = nil, latestVersion: Version? = nil, - source: Source, + source: Source ) { self.identity = identity self.location = location diff --git a/Sources/PackageModel/Module/BinaryModule.swift b/Sources/PackageModel/Module/BinaryModule.swift index 57a0cc9a8b5..5762262fb0e 100644 --- a/Sources/PackageModel/Module/BinaryModule.swift +++ b/Sources/PackageModel/Module/BinaryModule.swift @@ -50,11 +50,8 @@ public final class BinaryModule: Module { buildSettingsDescription: [], pluginUsages: [], usesUnsafeFlags: false, -<<<<<<< HEAD - implicit: false -======= - template: false // TODO: determine whether binary modules can be templates or not ->>>>>>> 7b7986368 (Remove template target and product types and use the template init options instead) + implicit: false, + template: false ) } diff --git a/Sources/PackageRegistry/RegistryClient.swift b/Sources/PackageRegistry/RegistryClient.swift index 88a29cbec99..320297a0c30 100644 --- a/Sources/PackageRegistry/RegistryClient.swift +++ b/Sources/PackageRegistry/RegistryClient.swift @@ -2157,7 +2157,7 @@ extension RegistryClient { licenseURL: String? = nil, readmeURL: String? = nil, repositoryURLs: [String]? = nil, - originalPublicationTime: Date? = nil, + originalPublicationTime: Date? = nil ) { self.author = author self.description = description diff --git a/Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift b/Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift deleted file mode 100644 index f6f0137342e..00000000000 --- a/Sources/PackageRegistryCommand/PackageRegistryCommand+Discover.swift +++ /dev/null @@ -1,101 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift open source project -// -// Copyright (c) 2023 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 Commands -import CoreCommands -import Foundation -import PackageModel -import PackageFingerprint -import PackageRegistry -import PackageSigning -import Workspace - -#if USE_IMPL_ONLY_IMPORTS -@_implementationOnly import X509 // FIXME: need this import or else SwiftSigningIdentity initializer fails -#else -import X509 -#endif - -import struct TSCBasic.ByteString -import struct TSCBasic.RegEx -import struct TSCBasic.SHA256 - -import struct TSCUtility.Version - -extension PackageRegistryCommand { - struct Discover: AsyncSwiftCommand { - static let configuration = CommandConfiguration( - abstract: "Get a package registry entry." - ) - - @OptionGroup(visibility: .hidden) - var globalOptions: GlobalOptions - - @Argument(help: .init("URL pointing towards package identifiers", valueName: "scm-url")) - var url: SourceControlURL - - @Flag(name: .customLong("allow-insecure-http"), help: "Allow using a non-HTTPS registry URL.") - var allowInsecureHTTP: Bool = false - - @Option(name: [.customLong("url"), .customLong("registry-url")], help: "Override registry URL.") - var registryURL: URL? - - func run(_ swiftCommandState: SwiftCommandState) async throws { - let packageDirectory = try resolvePackageDirectory(swiftCommandState) - let authorizationProvider = try resolveAuthorizationProvider(swiftCommandState) - - let registryClient = RegistryClient( - configuration: try getRegistriesConfig(swiftCommandState, global: false).configuration, - fingerprintStorage: .none, - fingerprintCheckingMode: .strict, - skipSignatureValidation: false, - signingEntityStorage: .none, - signingEntityCheckingMode: .strict, - authorizationProvider: authorizationProvider, - delegate: .none, - checksumAlgorithm: SHA256() - ) - - let set = try await registryClient.lookupIdentities(scmURL: url, observabilityScope: swiftCommandState.observabilityScope) - - if set.isEmpty { - throw ValidationError.invalidLookupURL(url) - } - - print(set) - } - - private func resolvePackageDirectory(_ swiftCommandState: SwiftCommandState) throws -> AbsolutePath { - let directory = try self.globalOptions.locations.packageDirectory - ?? swiftCommandState.getPackageRoot() - - guard localFileSystem.isDirectory(directory) else { - throw StringError("No package found at '\(directory)'.") - } - - return directory - } - - private func resolveAuthorizationProvider(_ swiftCommandState: SwiftCommandState) throws -> AuthorizationProvider { - guard let provider = try swiftCommandState.getRegistryAuthorizationProvider() else { - throw ValidationError.unknownCredentialStore - } - - return provider - } - } -} - - -extension SourceControlURL: ExpressibleByArgument {} diff --git a/Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift b/Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift deleted file mode 100644 index a785075ef4e..00000000000 --- a/Sources/PackageRegistryCommand/PackageRegistryCommand+Get.swift +++ /dev/null @@ -1,164 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift open source project -// -// Copyright (c) 2023 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 Commands -import CoreCommands -import Foundation -import PackageModel -import PackageFingerprint -import PackageRegistry -import PackageSigning -import Workspace - -#if USE_IMPL_ONLY_IMPORTS -@_implementationOnly import X509 // FIXME: need this import or else SwiftSigningIdentity initializer fails -#else -import X509 -#endif - -import struct TSCBasic.ByteString -import struct TSCBasic.RegEx -import struct TSCBasic.SHA256 - -import struct TSCUtility.Version - -extension PackageRegistryCommand { - struct Get: AsyncSwiftCommand { - static let configuration = CommandConfiguration( - abstract: "Get a package registry entry." - ) - - @OptionGroup(visibility: .hidden) - var globalOptions: GlobalOptions - - @Argument(help: .init("The package identifier.", valueName: "package-id")) - var packageIdentity: PackageIdentity - - @Option(help: .init("The package release version being queried.", valueName: "package-version")) - var packageVersion: Version? - - @Flag(help: .init("Fetch the Package.swift manifest of the registry entry", valueName: "manifest")) - var manifest: Bool = false - - @Option(help: .init("Swift tools version of the manifest", valueName: "custom-tools-version")) - var customToolsVersion: String? - - @Flag(name: .customLong("allow-insecure-http"), help: "Allow using a non-HTTPS registry URL.") - var allowInsecureHTTP: Bool = false - - @Option(name: [.customLong("url"), .customLong("registry-url")], help: "Override registry URL.") - var registryURL: URL? - - func run(_ swiftCommandState: SwiftCommandState) async throws { - let packageDirectory = try resolvePackageDirectory(swiftCommandState) - let registryURL = try resolveRegistryURL(swiftCommandState) - let authorizationProvider = try resolveAuthorizationProvider(swiftCommandState) - - let registryClient = RegistryClient( - configuration: try getRegistriesConfig(swiftCommandState, global: false).configuration, - fingerprintStorage: .none, - fingerprintCheckingMode: .strict, - skipSignatureValidation: false, - signingEntityStorage: .none, - signingEntityCheckingMode: .strict, - authorizationProvider: authorizationProvider, - delegate: .none, - checksumAlgorithm: SHA256() - ) - - try await fetchRegistryData(using: registryClient, swiftCommandState: swiftCommandState) - } - - private func resolvePackageDirectory(_ swiftCommandState: SwiftCommandState) throws -> AbsolutePath { - let directory = try self.globalOptions.locations.packageDirectory - ?? swiftCommandState.getPackageRoot() - - guard localFileSystem.isDirectory(directory) else { - throw StringError("No package found at '\(directory)'.") - } - - return directory - } - - private func resolveRegistryURL(_ swiftCommandState: SwiftCommandState) throws -> URL { - let config = try getRegistriesConfig(swiftCommandState, global: false).configuration - guard let identity = self.packageIdentity.registry else { - throw ValidationError.invalidPackageIdentity(self.packageIdentity) - } - - guard let url = self.registryURL ?? config.registry(for: identity.scope)?.url else { - throw ValidationError.unknownRegistry - } - - let allowHTTP = try self.allowInsecureHTTP && (config.authentication(for: url) == nil) - try url.validateRegistryURL(allowHTTP: allowHTTP) - - return url - } - - private func resolveAuthorizationProvider(_ swiftCommandState: SwiftCommandState) throws -> AuthorizationProvider { - guard let provider = try swiftCommandState.getRegistryAuthorizationProvider() else { - throw ValidationError.unknownCredentialStore - } - - return provider - } - - private func fetchToolsVersion() -> ToolsVersion? { - return customToolsVersion.flatMap { ToolsVersion(string: $0) } - } - - private func fetchRegistryData( - using client: RegistryClient, - swiftCommandState: SwiftCommandState - ) async throws { - let scope = swiftCommandState.observabilityScope - - if manifest { - guard let version = packageVersion else { - throw ValidationError.noPackageVersion(packageIdentity) - } - - let toolsVersion = fetchToolsVersion() - let content = try await client.getManifestContent( - package: self.packageIdentity, - version: version, - customToolsVersion: toolsVersion, - observabilityScope: scope - ) - - print(content) - return - } - - if let version = packageVersion { - let metadata = try await client.getPackageVersionMetadata( - package: self.packageIdentity, - version: version, - fileSystem: localFileSystem, - observabilityScope: scope - ) - - print(metadata) - } else { - let metadata = try await client.getPackageMetadata( - package: self.packageIdentity, - observabilityScope: scope - ) - - print(metadata) - } - } - } -} diff --git a/Sources/PackageRegistryCommand/PackageRegistryCommand.swift b/Sources/PackageRegistryCommand/PackageRegistryCommand.swift index 516ed6947a4..1858a16bcd3 100644 --- a/Sources/PackageRegistryCommand/PackageRegistryCommand.swift +++ b/Sources/PackageRegistryCommand/PackageRegistryCommand.swift @@ -31,9 +31,7 @@ public struct PackageRegistryCommand: AsyncParsableCommand { Unset.self, Login.self, Logout.self, - Publish.self, - Get.self, - Discover.self + Publish.self ], helpNames: [.short, .long, .customLong("help", withSingleDash: true)] ) @@ -143,8 +141,6 @@ public struct PackageRegistryCommand: AsyncParsableCommand { case unknownCredentialStore case invalidCredentialStore(Error) case credentialLengthLimitExceeded(Int) - case noPackageVersion(PackageIdentity) - case invalidLookupURL(SourceControlURL) } static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace.Configuration.Registries { @@ -203,10 +199,6 @@ extension PackageRegistryCommand.ValidationError: CustomStringConvertible { return "credential store is invalid: \(error.interpolationDescription)" case .credentialLengthLimitExceeded(let limit): return "password or access token must be \(limit) characters or less" - case .noPackageVersion(let identity): - return "no package version found for '\(identity)'" - case .invalidLookupURL(let url): - return "no package identifier was found in URL: \(url)" } } } diff --git a/Sources/SourceControl/GitRepository.swift b/Sources/SourceControl/GitRepository.swift index d406391f4f9..7baea16d677 100644 --- a/Sources/SourceControl/GitRepository.swift +++ b/Sources/SourceControl/GitRepository.swift @@ -154,8 +154,6 @@ public struct GitRepositoryProvider: RepositoryProvider, Cancellable { ) } - - private func clone( _ repository: RepositorySpecifier, _ origin: String, @@ -234,10 +232,6 @@ public struct GitRepositoryProvider: RepositoryProvider, Cancellable { ) throws -> WorkingCheckout { if editable { - // For editable clones, i.e. the user is expected to directly work on them, first we create - // a clone from our cache of repositories and then we replace the remote to the one originally - // present in the bare repository. - try self.clone( repository, sourcePath.pathString, @@ -254,15 +248,6 @@ public struct GitRepositoryProvider: RepositoryProvider, Cancellable { // FIXME: This is unfortunate that we have to fetch to update remote's data. try clone.fetch() } else { - // Clone using a shared object store with the canonical copy. - // - // We currently expect using shared storage here to be safe because we - // only ever expect to attempt to use the working copy to materialize a - // revision we selected in response to dependency resolution, and if we - // re-resolve such that the objects in this repository changed, we would - // only ever expect to get back a revision that remains present in the - // object storage. - try self.clone( repository, sourcePath.pathString, diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift index 22b34551efd..32f082ac8eb 100644 --- a/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift +++ b/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift @@ -56,6 +56,7 @@ public final class InitPackage { case buildToolPlugin = "build-tool-plugin" case commandPlugin = "command-plugin" case macro = "macro" + public var description: String { return rawValue } diff --git a/Sources/Workspace/Workspace+Dependencies.swift b/Sources/Workspace/Workspace+Dependencies.swift index 80c1410928a..3b603495134 100644 --- a/Sources/Workspace/Workspace+Dependencies.swift +++ b/Sources/Workspace/Workspace+Dependencies.swift @@ -51,7 +51,7 @@ import struct PackageModel.TraitDescription import enum PackageModel.TraitConfiguration import class PackageModel.Manifest -public extension Workspace { +extension Workspace { enum ResolvedFileStrategy { case lockFile case update(forceResolution: Bool) @@ -819,91 +819,6 @@ public extension Workspace { } } - /* - func resolveTemplatePackage( - templateDirectory: AbsolutePath? = nil, - - templateURL: SourceControlURL? = nil, - templatePackageID: PackageIdentity? = nil, - observabilityScope: ObservabilityScope, - revisionParsed: String?, - branchParsed: String?, - exactVersion: Version?, - fromParsed: String?, - toParsed: String?, - upToNextMinorParsed: String? - - ) async throws -> AbsolutePath { - if let path = templateDirectory { - // Local filesystem path - let packageRef = PackageReference.root(identity: .init(path: path), path: path) - let dependency = try ManagedDependency.fileSystem(packageRef: packageRef) - - guard case .fileSystem(let resolvedPath) = dependency.state else { - throw InternalError("invalid file system package state") - } - - await self.state.add(dependency: dependency) - try await self.state.save() - return resolvedPath - - } else if let url = templateURL { - // Git URL - let packageRef = PackageReference.remoteSourceControl(identity: PackageIdentity(url: url), url: url) - - let requirement: PackageStateChange.Requirement - if let revision = revisionParsed { - if let branch = branchParsed { - requirement = .revision(.init(identifier:revision), branch: branch) - } else { - requirement = .revision(.init(identifier: revision), branch: nil) - } - } else if let version = exactVersion { - requirement = .version(version) - } else { - throw InternalError("No usable Git version/revision/branch provided") - } - - return try await self.updateDependency( - package: packageRef, - requirement: requirement, - productFilter: .everything, - observabilityScope: observabilityScope - ) - - } else if let packageID = templatePackageID { - // Registry package - let identity = packageID - let packageRef = PackageReference.registry(identity: identity) - - let requirement: PackageStateChange.Requirement - if let exact = exactVersion { - requirement = .version(exact) - } else if let from = fromParsed, let to = toParsed { - // Not supported in updateDependency – adjust logic if needed - throw InternalError("Version range constraints are not supported here") - } else if let upToMinor = upToNextMinorParsed { - // SwiftPM normally supports this – you may need to expand updateDependency to support it - throw InternalError("upToNextMinorFrom not currently supported") - } else { - throw InternalError("No usable Registry version provided") - } - - return try await self.updateDependency( - package: packageRef, - requirement: requirement, - productFilter: .everything, - observabilityScope: observabilityScope - ) - - } else { - throw InternalError("No template source provided (path, url, or package-id)") - } - } - - */ - - public enum ResolutionPrecomputationResult: Equatable { case required(reason: WorkspaceResolveReason) case notRequired From c332a31ce245253c037d6196275044c659984499 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 26 Aug 2025 13:06:24 -0400 Subject: [PATCH 208/225] added test coverage to showExecutables to make sure template does not appear in output --- .../ShowExecutables/app/Package.swift | 14 +++++++++----- .../TemplateExample/TemplateExample.swift | 19 +++++++++++++++++++ .../app/{ => Sources/dealer}/main.swift | 0 .../app/Templates/TemplateExample/main.swift | 1 + .../deck-of-playing-cards/Package.swift | 2 +- 5 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 Fixtures/Miscellaneous/ShowExecutables/app/Plugins/TemplateExample/TemplateExample.swift rename Fixtures/Miscellaneous/ShowExecutables/app/{ => Sources/dealer}/main.swift (100%) create mode 100644 Fixtures/Miscellaneous/ShowExecutables/app/Templates/TemplateExample/main.swift diff --git a/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift b/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift index 389ab1be39c..8c636cb4845 100644 --- a/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.10 +// swift-tools-version:999.0 import PackageDescription let package = Package( @@ -6,7 +6,7 @@ let package = Package( products: [ .executable( name: "dealer", - targets: ["Dealer"] + targets: ["dealer"] ), ], dependencies: [ @@ -14,8 +14,12 @@ let package = Package( ], targets: [ .executableTarget( - name: "Dealer", - path: "./" + name: "dealer", ), - ] + ] + .template( + name: "TemplateExample", + dependencies: [], + initialPackageType: .executable, + description: "Make your own Swift package template." + ), ) diff --git a/Fixtures/Miscellaneous/ShowExecutables/app/Plugins/TemplateExample/TemplateExample.swift b/Fixtures/Miscellaneous/ShowExecutables/app/Plugins/TemplateExample/TemplateExample.swift new file mode 100644 index 00000000000..9b8864e877c --- /dev/null +++ b/Fixtures/Miscellaneous/ShowExecutables/app/Plugins/TemplateExample/TemplateExample.swift @@ -0,0 +1,19 @@ + +import Foundation + +import PackagePlugin + +@main +struct TemplateExamplePlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws { + let tool = try context.tool(named: "TemplateExample") + process.executableURL = URL(filePath: tool.url.path()) + process.arguments = arguments.filter { $0 != "--" } + + try process.run() + process.waitUntilExit() + } +} diff --git a/Fixtures/Miscellaneous/ShowExecutables/app/main.swift b/Fixtures/Miscellaneous/ShowExecutables/app/Sources/dealer/main.swift similarity index 100% rename from Fixtures/Miscellaneous/ShowExecutables/app/main.swift rename to Fixtures/Miscellaneous/ShowExecutables/app/Sources/dealer/main.swift diff --git a/Fixtures/Miscellaneous/ShowExecutables/app/Templates/TemplateExample/main.swift b/Fixtures/Miscellaneous/ShowExecutables/app/Templates/TemplateExample/main.swift new file mode 100644 index 00000000000..b2459149e57 --- /dev/null +++ b/Fixtures/Miscellaneous/ShowExecutables/app/Templates/TemplateExample/main.swift @@ -0,0 +1 @@ +print("I'm the template") diff --git a/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift b/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift index 0c23f679535..3dea7337eb5 100644 --- a/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift +++ b/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.10 +// swift-tools-version:999.0.0 import PackageDescription let package = Package( From 9df9938d3e978de9b484922235a133b4e1a7bcab Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 26 Aug 2025 17:13:34 -0400 Subject: [PATCH 209/225] added more tests, updated showtemplates test --- .../ShowExecutables/app/Package.swift | 2 +- .../ShowTemplates/app/Package.swift | 31 +- .../plugin.swift | 2 +- .../app/Sources/dealer/main.swift | 1 + .../Template.swift | 0 .../RequirementResolver.swift | 41 +- Tests/CommandsTests/TemplateTests.swift | 378 ++++++++++++++++++ 7 files changed, 428 insertions(+), 27 deletions(-) rename Fixtures/Miscellaneous/ShowTemplates/app/Plugins/{dooPlugin => GenerateFromTemplatePlugin}/plugin.swift (89%) create mode 100644 Fixtures/Miscellaneous/ShowTemplates/app/Sources/dealer/main.swift rename Fixtures/Miscellaneous/ShowTemplates/app/Templates/{doo => GenerateFromTemplate}/Template.swift (100%) create mode 100644 Tests/CommandsTests/TemplateTests.swift diff --git a/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift b/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift index 8c636cb4845..29a83e676a1 100644 --- a/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift @@ -8,7 +8,7 @@ let package = Package( name: "dealer", targets: ["dealer"] ), - ], + ] + .template(name: "TemplateExample"), dependencies: [ .package(path: "../deck-of-playing-cards"), ], diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift index 7680cba36ec..5be9ac6a666 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -2,27 +2,32 @@ import PackageDescription let package = Package( - name: "Dealer", - products: Product.template(name: "doo"), + name: "GenerateFromTemplate", + products: [ + .executable( + name: "dealer", + targets: ["dealer"] + ), + ] + .template(name: "GenerateFromTemplate"), dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2") - ], - targets: Target.template( - name: "doo", + targets: [ + .executableTarget( + name: "dealer", + ), + ] + .template( + name: "GenerateFromTemplate", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "SystemPackage", package: "swift-system") - ], - templateInitializationOptions: .packageInit( - templateType: .executable, - templatePermissions: [ - .allowNetworkConnections(scope: .local(ports: [1200]), reason: "why not") - ], - description: "A template that generates a starter executable package" - ) + initialPackageType: .executable, + templatePermissions: [ + .allowNetworkConnections(scope: .local(ports: [1200]), reason: "") + ], + description: "A template that generates a starter executable package" ) ) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/dooPlugin/plugin.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateFromTemplatePlugin/plugin.swift similarity index 89% rename from Fixtures/Miscellaneous/ShowTemplates/app/Plugins/dooPlugin/plugin.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateFromTemplatePlugin/plugin.swift index 00950ba3014..b74943ccbd4 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/dooPlugin/plugin.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateFromTemplatePlugin/plugin.swift @@ -17,7 +17,7 @@ struct TemplatePlugin: CommandPlugin { context: PluginContext, arguments: [String] ) async throws { - let tool = try context.tool(named: "doo") + let tool = try context.tool(named: "GenerateFromTemplate") let process = Process() process.executableURL = URL(filePath: tool.url.path()) diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Sources/dealer/main.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Sources/dealer/main.swift new file mode 100644 index 00000000000..6e592945d1b --- /dev/null +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Sources/dealer/main.swift @@ -0,0 +1 @@ +print("I am a dealer") diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/doo/Template.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateFromTemplate/Template.swift similarity index 100% rename from Fixtures/Miscellaneous/ShowTemplates/app/Templates/doo/Template.swift rename to Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateFromTemplate/Template.swift diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index 02883294ea8..43e941dda8d 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -18,7 +18,7 @@ import TSCUtility /// based on versioning input (e.g., version, branch, or revision). protocol DependencyRequirementResolving { func resolveSourceControl() throws -> PackageDependency.SourceControl.Requirement - func resolveRegistry() throws -> PackageDependency.Registry.Requirement + func resolveRegistry() throws -> PackageDependency.Registry.Requirement? } @@ -58,7 +58,9 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// - Throws: `StringError` if multiple or no input fields are set, or if `to` is used without `from` or /// `upToNextMinorFrom`. func resolveSourceControl() throws -> PackageDependency.SourceControl.Requirement { + var specifiedRequirements: [PackageDependency.SourceControl.Requirement] = [] + if let v = exact { specifiedRequirements.append(.exact(v)) } if let b = branch { specifiedRequirements.append(.branch(b)) } if let r = revision { specifiedRequirements.append(.revision(r)) } @@ -73,10 +75,16 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { throw DependencyRequirementError.multipleRequirementsSpecified } - if case .range(let range) = specifiedRequirements, let upper = to { - return .range(range.lowerBound ..< upper) - } else if self.to != nil { - throw DependencyRequirementError.invalidToParameterWithoutFrom + if case .range(let range) = specifiedRequirements { + if let to { + return .range(range.lowerBound ..< to) + } else { + return .range(range) + } + } else { + if self.to != nil { + throw DependencyRequirementError.invalidToParameterWithoutFrom + } } return specifiedRequirements @@ -87,7 +95,10 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// - Returns: A valid `PackageDependency.Registry.Requirement`. /// - Throws: `StringError` if more than one registry versioning input is provided or if `to` is used without a base /// range. - func resolveRegistry() throws -> PackageDependency.Registry.Requirement { + func resolveRegistry() throws -> PackageDependency.Registry.Requirement? { + if exact == nil, from == nil, upToNextMinorFrom == nil, to == nil { + return nil + } var specifiedRequirements: [PackageDependency.Registry.Requirement] = [] @@ -103,10 +114,16 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { throw DependencyRequirementError.multipleRequirementsSpecified } - if case .range(let range) = specifiedRequirements, let upper = to { - return .range(range.lowerBound ..< upper) - } else if self.to != nil { - throw DependencyRequirementError.invalidToParameterWithoutFrom + if case .range(let range) = specifiedRequirements { + if let to { + return .range(range.lowerBound ..< to) + } else { + return .range(range) + } + } else { + if self.to != nil { + throw DependencyRequirementError.invalidToParameterWithoutFrom + } } return specifiedRequirements @@ -129,9 +146,9 @@ enum DependencyRequirementError: Error, CustomStringConvertible { var description: String { switch self { case .multipleRequirementsSpecified: - return "Specify exactly one source control version requirement." + return "Specify exactly version requirement." case .noRequirementSpecified: - return "No source control version requirement specified." + return "No exact or lower bound version requirement specified." case .invalidToParameterWithoutFrom: return "--to requires --from or --up-to-next-minor-from" } diff --git a/Tests/CommandsTests/TemplateTests.swift b/Tests/CommandsTests/TemplateTests.swift new file mode 100644 index 00000000000..7dd5e33d115 --- /dev/null +++ b/Tests/CommandsTests/TemplateTests.swift @@ -0,0 +1,378 @@ +//===----------------------------------------------------------------------===// +// +// 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 +@testable import CoreCommands +@testable import Commands +@testable import PackageModel + +import Foundation + +@_spi(DontAdoptOutsideOfSwiftPMExposedForBenchmarksAndTestsOnly) +import PackageGraph +import TSCUtility +import PackageLoading +import SourceControl +import SPMBuildCore +import _InternalTestSupport +import Workspace +import Testing + +import struct TSCBasic.ByteString +import class TSCBasic.BufferedOutputByteStream +import enum TSCBasic.JSON +import class Basics.AsyncProcess + + +@Suite("Template Tests") struct TestTemplates { + + + //maybe add tags + @Test func resolveSourceTests() { + + let resolver = DefaultTemplateSourceResolver() + + let nilSource = resolver.resolveSource( + directory: nil, url: nil, packageID: nil + ) + #expect(nilSource == nil) + + let localSource = resolver.resolveSource( + directory: AbsolutePath("/fake/path/to/template"), url: nil, packageID: nil + ) + #expect(localSource == .local) + + let packageIDSource = resolver.resolveSource( + directory: AbsolutePath("/fake/path/to/template"), url: nil, packageID: "foo.bar" + ) + #expect(packageIDSource == .registry) + + let gitSource = resolver.resolveSource( + directory: AbsolutePath("/fake/path/to/template"), url: "https://github.com/foo/bar", packageID: "foo.bar" + ) + #expect(gitSource == .git) + + } + + @Test func resolveRegistryDependencyTests() throws { + + let lowerBoundVersion = Version(stringLiteral: "1.2.0") + let higherBoundVersion = Version(stringLiteral: "3.0.0") + + // if exact, from, upToNextMinorFrom and to are nil, then should return nil + let nilRegistryDependency = try DependencyRequirementResolver( + exact: nil, + revision: "revision", + branch: "branch", + from: nil, + upToNextMinorFrom: nil, + to: nil + ).resolveRegistry() + + #expect(nilRegistryDependency == nil) + + // test exact specification + let exactRegistryDependency = try DependencyRequirementResolver( + exact: lowerBoundVersion, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: nil + ).resolveRegistry() + + #expect(exactRegistryDependency == PackageDependency.Registry.Requirement.exact(lowerBoundVersion)) + + + // test from to + let fromToRegistryDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: nil, + to: higherBoundVersion + ).resolveRegistry() + + #expect(fromToRegistryDependency == PackageDependency.Registry.Requirement.range(lowerBoundVersion ..< higherBoundVersion)) + + // test up-to-next-minor-from and to + let upToNextMinorFromToRegistryDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: lowerBoundVersion, + to: higherBoundVersion + ).resolveRegistry() + + #expect(upToNextMinorFromToRegistryDependency == PackageDependency.Registry.Requirement.range(lowerBoundVersion ..< higherBoundVersion)) + + // test just from + let fromRegistryDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: nil, + to: nil + ).resolveRegistry() + + #expect(fromRegistryDependency == PackageDependency.Registry.Requirement.range(.upToNextMajor(from: lowerBoundVersion))) + + // test just up-to-next-minor-from + let upToNextMinorFromRegistryDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: lowerBoundVersion, + to: nil + ).resolveRegistry() + + #expect(upToNextMinorFromRegistryDependency == PackageDependency.Registry.Requirement.range(.upToNextMinor(from: lowerBoundVersion))) + + + #expect(throws: DependencyRequirementError.multipleRequirementsSpecified.self) { + try DependencyRequirementResolver( + exact: lowerBoundVersion, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: lowerBoundVersion, + to: nil + ).resolveRegistry() + } + + #expect(throws: DependencyRequirementError.noRequirementSpecified.self) { + try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: lowerBoundVersion + ).resolveRegistry() + } + + #expect(throws: DependencyRequirementError.invalidToParameterWithoutFrom.self) { + try DependencyRequirementResolver( + exact: lowerBoundVersion, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: higherBoundVersion + ).resolveRegistry() + } + } + + // TODO: should we add edge cases to < from and from == from + @Test func resolveSourceControlDependencyTests() throws { + + let lowerBoundVersion = Version(stringLiteral: "1.2.0") + let higherBoundVersion = Version(stringLiteral: "3.0.0") + + let branchSourceControlDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: "master", + from: nil, + upToNextMinorFrom: nil, + to: nil + ).resolveSourceControl() + + #expect(branchSourceControlDependency == PackageDependency.SourceControl.Requirement.branch("master")) + + let revisionSourceControlDependency = try DependencyRequirementResolver( + exact: nil, + revision: "dae86e", + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: nil + ).resolveSourceControl() + + #expect(revisionSourceControlDependency == PackageDependency.SourceControl.Requirement.revision("dae86e")) + + // test exact specification + let exactSourceControlDependency = try DependencyRequirementResolver( + exact: lowerBoundVersion, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: nil + ).resolveSourceControl() + + #expect(exactSourceControlDependency == PackageDependency.SourceControl.Requirement.exact(lowerBoundVersion)) + + // test from to + let fromToSourceControlDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: nil, + to: higherBoundVersion + ).resolveSourceControl() + + #expect(fromToSourceControlDependency == PackageDependency.SourceControl.Requirement.range(lowerBoundVersion ..< higherBoundVersion)) + + // test up-to-next-minor-from and to + let upToNextMinorFromToSourceControlDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: lowerBoundVersion, + to: higherBoundVersion + ).resolveSourceControl() + + #expect(upToNextMinorFromToSourceControlDependency == PackageDependency.SourceControl.Requirement.range(lowerBoundVersion ..< higherBoundVersion)) + + // test just from + let fromSourceControlDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: nil, + to: nil + ).resolveSourceControl() + + #expect(fromSourceControlDependency == PackageDependency.SourceControl.Requirement.range(.upToNextMajor(from: lowerBoundVersion))) + + // test just up-to-next-minor-from + let upToNextMinorFromSourceControlDependency = try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: lowerBoundVersion, + to: nil + ).resolveSourceControl() + + #expect(upToNextMinorFromSourceControlDependency == PackageDependency.SourceControl.Requirement.range(.upToNextMinor(from: lowerBoundVersion))) + + + #expect(throws: DependencyRequirementError.multipleRequirementsSpecified.self) { + try DependencyRequirementResolver( + exact: lowerBoundVersion, + revision: "dae86e", + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: lowerBoundVersion, + to: nil + ).resolveSourceControl() + } + + #expect(throws: DependencyRequirementError.noRequirementSpecified.self) { + try DependencyRequirementResolver( + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: lowerBoundVersion + ).resolveSourceControl() + } + + #expect(throws: DependencyRequirementError.invalidToParameterWithoutFrom.self) { + try DependencyRequirementResolver( + exact: lowerBoundVersion, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: higherBoundVersion + ).resolveSourceControl() + } + } + + // test local + // test git + // test registry + + @Test func localTemplatePathResolver() async throws { + let mockTemplatePath = AbsolutePath("/fake/path/to/template") + let options = try GlobalOptions.parse([]) + + let tool = try SwiftCommandState.makeMockState(options: options) + + let path = try await TemplatePathResolver( + source: .local, + templateDirectory: mockTemplatePath, + templateURL: nil, + sourceControlRequirement: nil, + registryRequirement: nil, + packageIdentity: nil, + swiftCommandState: tool + ).resolve() + + #expect(path == mockTemplatePath) + } + + // Need to add traits of not running on windows, and CI + @Test func gitTemplatePathResolver() async throws { + + try await testWithTemporaryDirectory { path in + + let sourceControlRequirement = PackageDependency.SourceControl.Requirement.branch("main") + let options = try GlobalOptions.parse([]) + + let tool = try SwiftCommandState.makeMockState(options: options) + + let templateRepoPath = path.appending(component: "template-repo") + let sourceControlURL = SourceControlURL(stringLiteral: templateRepoPath.pathString) + let templateRepoURL = sourceControlURL.url + try! makeDirectories(templateRepoPath) + initGitRepo(templateRepoPath, tag: "1.2.3") + + let resolver = try TemplatePathResolver( + source: .git, + templateDirectory: nil, + templateURL: templateRepoURL?.absoluteString, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: nil, + packageIdentity: nil, + swiftCommandState: tool + ) + let path = try await resolver.resolve() + #expect(try localFileSystem.exists(path.appending(component: "file.swift")), "Template was not fetched correctly") + } + } + + @Test func packageRegistryTemplatePathResolver() async throws { + //TODO: im too lazy right now + } + + //should we clean up after?? + @Test func initDirectoryManagerCreateTempDirs() throws { + + let options = try GlobalOptions.parse([]) + + let tool = try SwiftCommandState.makeMockState(options: options) + + let (stagingPath, cleanupPath, tempDir) = try TemplateInitializationDirectoryManager(fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope).createTemporaryDirectories() + + + #expect(stagingPath.parentDirectory == tempDir) + #expect(cleanupPath.parentDirectory == tempDir) + + #expect(stagingPath.basename == "generated-package") + #expect(cleanupPath.basename == "clean-up") + + #expect(tool.fileSystem.exists(stagingPath)) + #expect(tool.fileSystem.exists(cleanupPath)) + } +} From 910a29fb0d772d6f8dc7a737248cd681ebba2ce4 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 27 Aug 2025 16:42:52 -0400 Subject: [PATCH 210/225] added more test coverage --- .../generated-package/.gitignore | 8 ++ .../generated-package/Package.swift | 14 +++ .../generated-package/Sources/main.swift | 4 + .../InferPackageType/Package.swift | 73 ++++++++++++ .../main.swift | 13 ++ .../initialTypeCommandPluginPlugin/main.swift | 13 ++ .../Plugins/initialTypeEmptyPlugin/main.swift | 13 ++ .../initialTypeExecutablePlugin/main.swift | 13 ++ .../initialTypeLibraryPlugin/main.swift | 13 ++ .../Plugins/initialTypeMacroPlugin/main.swift | 13 ++ .../Plugins/initialTypeToolPlugin/main.swift | 13 ++ .../initialTypeBuildToolPlugin/main.swift | 1 + .../initialTypeCommandPlugin/main.swift | 1 + .../Templates/initialTypeEmpty/main.swift | 1 + .../initialTypeExecutable/main.swift | 1 + .../Templates/initialTypeLibrary/main.swift | 1 + .../Templates/initialTypeMacro/main.swift | 1 + .../Templates/initialTypeTool/main.swift | 1 + .../PackageInitializer.swift | 11 +- Tests/CommandsTests/TemplateTests.swift | 111 ++++++++++++++++++ 20 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore create mode 100644 Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Package.swift create mode 100644 Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Sources/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Package.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeBuildToolPluginPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeCommandPluginPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeEmptyPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeExecutablePlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeLibraryPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeMacroPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeToolPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeBuildToolPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeCommandPlugin/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeEmpty/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeExecutable/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeLibrary/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeMacro/main.swift create mode 100644 Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeTool/main.swift diff --git a/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore new file mode 100644 index 00000000000..0023a534063 --- /dev/null +++ b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Package.swift b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Package.swift new file mode 100644 index 00000000000..c8c67f66aad --- /dev/null +++ b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Package.swift @@ -0,0 +1,14 @@ +// swift-tools-version: 6.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "generated-package", + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .executableTarget( + name: "generated-package"), + ] +) diff --git a/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Sources/main.swift b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Sources/main.swift new file mode 100644 index 00000000000..44e20d5acc4 --- /dev/null +++ b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Sources/main.swift @@ -0,0 +1,4 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book + +print("Hello, world!") diff --git a/Fixtures/Miscellaneous/InferPackageType/Package.swift b/Fixtures/Miscellaneous/InferPackageType/Package.swift new file mode 100644 index 00000000000..714cf42da70 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Package.swift @@ -0,0 +1,73 @@ +// swift-tools-version:999.0.0 +import PackageDescription + +let initialLibrary: [Target] = .template( + name: "initialTypeLibrary", + dependencies: [], + initialPackageType: .library, + description: "" + ) + + +let initialExecutable: [Target] = .template( + name: "initialTypeExecutable", + dependencies: [], + initialPackageType: .executable, + description: "" + ) + + +let initialTool: [Target] = .template( + name: "initialTypeTool", + dependencies: [], + initialPackageType: .tool, + description: "" + ) + + +let initialBuildToolPlugin: [Target] = .template( + name: "initialTypeBuildToolPlugin", + dependencies: [], + initialPackageType: .buildToolPlugin, + description: "" + ) + + +let initialCommandPlugin: [Target] = .template( + name: "initialTypeCommandPlugin", + dependencies: [], + initialPackageType: .commandPlugin, + description: "" + ) + + +let initialMacro: [Target] = .template( + name: "initialTypeMacro", + dependencies: [], + initialPackageType: .`macro`, + description: "" + ) + + + +let initialEmpty: [Target] = .template( + name: "initialTypeEmpty", + dependencies: [], + initialPackageType: .empty, + description: "" + ) + +var products: [Product] = .template(name: "initialTypeLibrary") + +products += .template(name: "initialTypeExecutable") +products += .template(name: "initialTypeTool") +products += .template(name: "initialTypeBuildToolPlugin") +products += .template(name: "initialTypeCommandPlugin") +products += .template(name: "initialTypeMacro") +products += .template(name: "initialTypeEmpty") + +let package = Package( + name: "InferPackageType", + products: products, + targets: initialLibrary + initialExecutable + initialTool + initialBuildToolPlugin + initialCommandPlugin + initialMacro + initialEmpty +) diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeBuildToolPluginPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeBuildToolPluginPlugin/main.swift new file mode 100644 index 00000000000..aa73dd6b8ae --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeBuildToolPluginPlugin/main.swift @@ -0,0 +1,13 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct FooPlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws {} +} + diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeCommandPluginPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeCommandPluginPlugin/main.swift new file mode 100644 index 00000000000..aa73dd6b8ae --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeCommandPluginPlugin/main.swift @@ -0,0 +1,13 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct FooPlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws {} +} + diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeEmptyPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeEmptyPlugin/main.swift new file mode 100644 index 00000000000..aa73dd6b8ae --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeEmptyPlugin/main.swift @@ -0,0 +1,13 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct FooPlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws {} +} + diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeExecutablePlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeExecutablePlugin/main.swift new file mode 100644 index 00000000000..aa73dd6b8ae --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeExecutablePlugin/main.swift @@ -0,0 +1,13 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct FooPlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws {} +} + diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeLibraryPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeLibraryPlugin/main.swift new file mode 100644 index 00000000000..aa73dd6b8ae --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeLibraryPlugin/main.swift @@ -0,0 +1,13 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct FooPlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws {} +} + diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeMacroPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeMacroPlugin/main.swift new file mode 100644 index 00000000000..aa73dd6b8ae --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeMacroPlugin/main.swift @@ -0,0 +1,13 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct FooPlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws {} +} + diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeToolPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeToolPlugin/main.swift new file mode 100644 index 00000000000..aa73dd6b8ae --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeToolPlugin/main.swift @@ -0,0 +1,13 @@ +import Foundation + +import PackagePlugin + +/// plugin that will kickstart the template executable +@main +struct FooPlugin: CommandPlugin { + func performCommand( + context: PluginContext, + arguments: [String] + ) async throws {} +} + diff --git a/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeBuildToolPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeBuildToolPlugin/main.swift new file mode 100644 index 00000000000..3d3d53a1627 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeBuildToolPlugin/main.swift @@ -0,0 +1 @@ +print("foo") diff --git a/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeCommandPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeCommandPlugin/main.swift new file mode 100644 index 00000000000..3d3d53a1627 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeCommandPlugin/main.swift @@ -0,0 +1 @@ +print("foo") diff --git a/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeEmpty/main.swift b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeEmpty/main.swift new file mode 100644 index 00000000000..3d3d53a1627 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeEmpty/main.swift @@ -0,0 +1 @@ +print("foo") diff --git a/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeExecutable/main.swift b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeExecutable/main.swift new file mode 100644 index 00000000000..3d3d53a1627 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeExecutable/main.swift @@ -0,0 +1 @@ +print("foo") diff --git a/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeLibrary/main.swift b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeLibrary/main.swift new file mode 100644 index 00000000000..3d3d53a1627 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeLibrary/main.swift @@ -0,0 +1 @@ +print("foo") diff --git a/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeMacro/main.swift b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeMacro/main.swift new file mode 100644 index 00000000000..3d3d53a1627 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeMacro/main.swift @@ -0,0 +1 @@ +print("foo") diff --git a/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeTool/main.swift b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeTool/main.swift new file mode 100644 index 00000000000..3d3d53a1627 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Templates/initialTypeTool/main.swift @@ -0,0 +1 @@ +print("foo") diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index d0a20ece22b..c1cb78c4a67 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -53,7 +53,7 @@ struct TemplatePackageInitializer: PackageInitializer { let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) let (stagingPath, cleanupPath, tempDir) = try directoryManager.createTemporaryDirectories() - let packageType = try await inferPackageType(from: resolvedTemplatePath) + let packageType = try await TemplatePackageInitializer.inferPackageType(from: resolvedTemplatePath, templateName: templateName, swiftCommandState: swiftCommandState) let builder = DefaultPackageDependencyBuilder( templateSource: templateSource, @@ -111,11 +111,8 @@ struct TemplatePackageInitializer: PackageInitializer { } } - private func inferPackageType(from templatePath: Basics.AbsolutePath) async throws -> InitPackage.PackageType { - try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { _, _ in - let workspace = try swiftCommandState.getActiveWorkspace() - let root = try swiftCommandState.getWorkspaceRoot() - + static func inferPackageType(from templatePath: Basics.AbsolutePath, templateName: String?, swiftCommandState: SwiftCommandState) async throws -> InitPackage.PackageType { + try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { workspace, root in let rootManifests = try await workspace.loadRootManifests( packages: root.packages, observabilityScope: swiftCommandState.observabilityScope @@ -143,7 +140,7 @@ struct TemplatePackageInitializer: PackageInitializer { } } - private func findTemplateName(from manifest: Manifest) throws -> String { + static func findTemplateName(from manifest: Manifest) throws -> String { let templateTargets = manifest.targets.compactMap { target -> String? in if let options = target.templateInitializationOptions, case .packageInit = options { diff --git a/Tests/CommandsTests/TemplateTests.swift b/Tests/CommandsTests/TemplateTests.swift index 7dd5e33d115..0c902cb5416 100644 --- a/Tests/CommandsTests/TemplateTests.swift +++ b/Tests/CommandsTests/TemplateTests.swift @@ -11,6 +11,8 @@ //===----------------------------------------------------------------------===// import Basics + +@_spi(SwiftPMInternal) @testable import CoreCommands @testable import Commands @testable import PackageModel @@ -18,6 +20,7 @@ import Basics import Foundation @_spi(DontAdoptOutsideOfSwiftPMExposedForBenchmarksAndTestsOnly) + import PackageGraph import TSCUtility import PackageLoading @@ -27,6 +30,7 @@ import _InternalTestSupport import Workspace import Testing + import struct TSCBasic.ByteString import class TSCBasic.BufferedOutputByteStream import enum TSCBasic.JSON @@ -375,4 +379,111 @@ import class Basics.AsyncProcess #expect(tool.fileSystem.exists(stagingPath)) #expect(tool.fileSystem.exists(cleanupPath)) } + + @Test func initDirectoryManagerFinalize() async throws { + + try await fixture(name: "Miscellaneous/DirectoryManagerFinalize", createGitRepo: false) { fixturePath in + let stagingPath = fixturePath.appending("generated-package") + let cleanupPath = fixturePath.appending("clean-up") + let cwd = fixturePath.appending("cwd") + + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + // Build it. TODO: CHANGE THE XCTAsserts build to the swift testing helper function instead + await XCTAssertBuilds(stagingPath) + + + let stagingBuildPath = stagingPath.appending(".build") + let binFile = stagingBuildPath.appending(components: try UserToolchain.default.targetTriple.platformBuildPathComponent, "debug", "generated-package") + #expect(localFileSystem.exists(binFile)) + #expect(localFileSystem.isDirectory(stagingBuildPath)) + + + try await TemplateInitializationDirectoryManager( + fileSystem: tool.fileSystem, + observabilityScope: tool.observabilityScope + ).finalize(cwd: cwd, stagingPath: stagingPath, cleanupPath: cleanupPath, swiftCommandState: tool) + + let cwdBuildPath = cwd.appending(".build") + let cwdBinaryFile = cwdBuildPath.appending(components: try UserToolchain.default.targetTriple.platformBuildPathComponent, "debug", "generated-package") + + // Postcondition checks + #expect(localFileSystem.exists(cwd), "cwd should exist after finalize") + #expect(localFileSystem.exists(cwdBinaryFile) == false, "Binary should have been cleaned before copying to cwd") + } + } + + @Test func initPackageInitializer() throws { + + let globalOptions = try GlobalOptions.parse([]) + let testLibraryOptions = try TestLibraryOptions.parse([]) + let buildOptions = try BuildCommandOptions.parse([]) + let directoryPath = AbsolutePath("/") + let tool = try SwiftCommandState.makeMockState(options: globalOptions) + + + let templatePackageInitializer = try PackageInitConfiguration( + swiftCommandState: tool, + name: "foo", + initMode: "template", + testLibraryOptions: testLibraryOptions, + buildOptions: buildOptions, + globalOptions: globalOptions, + validatePackage: true, + args: ["--foobar foo"], + directory: directoryPath, + url: nil, + packageID: "foo.bar", + versionFlags: VersionFlags(exact: nil, revision: nil, branch: "master", from: nil, upToNextMinorFrom: nil ,to: nil) + ).makeInitializer() + + #expect(templatePackageInitializer is TemplatePackageInitializer) + + + let standardPackageInitalizer = try PackageInitConfiguration( + swiftCommandState: tool, + name: "foo", + initMode: "template", + testLibraryOptions: testLibraryOptions, + buildOptions: buildOptions, + globalOptions: globalOptions, + validatePackage: true, + args: ["--foobar foo"], + directory: nil, + url: nil, + packageID: nil, + versionFlags: VersionFlags(exact: nil, revision: nil, branch: "master", from: nil, upToNextMinorFrom: nil ,to: nil) + ).makeInitializer() + + #expect(standardPackageInitalizer is StandardPackageInitializer) + } + + //tests: + // infer package type + // set up the template package + + //TODO: Fix here, as mocking swiftCommandState resolves to linux triple, but if testing on Darwin, runs into precondition error. + /* + @Test func inferInitialPackageType() async throws { + + try await fixture(name: "Miscellaneous/InferPackageType") { fixturePath in + + let options = try GlobalOptions.parse([]) + let tool = try SwiftCommandState.makeMockState(options: options) + + + let libraryType = try await TemplatePackageInitializer.inferPackageType(from: fixturePath, templateName: "initialTypeLibrary", swiftCommandState: tool) + + + #expect(libraryType.rawValue == "library") + } + + } + */ } + + + + + From fce6acf3ba8736123b6826f83520ab42eb29dcc0 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 27 Aug 2025 17:14:01 -0400 Subject: [PATCH 211/225] added observability scope to templatepathresolver + ssh error message for special case where authentication is needed --- .../TemplatePathResolver.swift | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index 74565b1b2a7..a5e57a35699 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -74,18 +74,21 @@ struct TemplatePathResolver { switch source { case .local: guard let path = templateDirectory else { + swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingLocalTemplatePath) throw TemplatePathResolverError.missingLocalTemplatePath } self.fetcher = LocalTemplateFetcher(path: path) case .git: guard let url = templateURL, let requirement = sourceControlRequirement else { + swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingGitURLOrRequirement) throw TemplatePathResolverError.missingGitURLOrRequirement } - self.fetcher = GitTemplateFetcher(source: url, requirement: requirement) + self.fetcher = GitTemplateFetcher(source: url, requirement: requirement, swiftCommandState: swiftCommandState) case .registry: guard let identity = packageIdentity, let requirement = registryRequirement else { + swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingRegistryIdentityOrRequirement) throw TemplatePathResolverError.missingRegistryIdentityOrRequirement } self.fetcher = RegistryTemplateFetcher( @@ -95,6 +98,7 @@ struct TemplatePathResolver { ) case .none: + swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingTemplateType) throw TemplatePathResolverError.missingTemplateType } } @@ -151,12 +155,14 @@ struct LocalTemplateFetcher: TemplateFetcher { /// The template is cloned into a temporary directory, checked out, and returned. struct GitTemplateFetcher: TemplateFetcher { + /// The Git URL of the remote repository. let source: String /// The source control requirement used to determine which version/branch/revision to check out. let requirement: PackageDependency.SourceControl.Requirement + let swiftCommandState: SwiftCommandState /// Fetches the repository and returns the path to the checked-out working copy. /// /// - Returns: A path to the directory containing the fetched template. @@ -196,14 +202,27 @@ struct GitTemplateFetcher: TemplateFetcher { do { try provider.fetch(repository: repositorySpecifier, to: path) } catch { + if isSSHPermissionError(error) { + swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.sshAuthenticationRequired(source: source)) + throw GitTemplateFetcherError.sshAuthenticationRequired(source: source) + } + swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.cloneFailed(source: source, underlyingError: error)) throw GitTemplateFetcherError.cloneFailed(source: source, underlyingError: error) } } + private func isSSHPermissionError(_ error: Error) -> Bool { + let errorString = String(describing: error).lowercased() + return errorString.contains("permission denied") && + errorString.contains("publickey") && + source.hasPrefix("git@") + } + /// Validates that the directory contains a valid Git repository. private func validateBareRepository(at path: Basics.AbsolutePath) throws { let provider = GitRepositoryProvider() guard try provider.isValidDirectory(path) else { + swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.invalidRepositoryDirectory(path: path)) throw GitTemplateFetcherError.invalidRepositoryDirectory(path: path) } } @@ -223,6 +242,7 @@ struct GitTemplateFetcher: TemplateFetcher { editable: true ) } catch { + swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.createWorkingCopyFailed(path: workingCopyPath, underlyingError: error)) throw GitTemplateFetcherError.createWorkingCopyFailed(path: workingCopyPath, underlyingError: error) } } @@ -247,6 +267,7 @@ struct GitTemplateFetcher: TemplateFetcher { let versions = tags.compactMap { Version($0) } let filteredVersions = versions.filter { range.contains($0) } guard let latestVersion = filteredVersions.max() else { + swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.noMatchingTagInRange(range)) throw GitTemplateFetcherError.noMatchingTagInRange(range) } try repository.checkout(tag: latestVersion.description) @@ -259,11 +280,12 @@ struct GitTemplateFetcher: TemplateFetcher { case createWorkingCopyFailed(path: Basics.AbsolutePath, underlyingError: Error) case checkoutFailed(requirement: PackageDependency.SourceControl.Requirement, underlyingError: Error) case noMatchingTagInRange(Range) + case sshAuthenticationRequired(source: String) var errorDescription: String? { switch self { case .cloneFailed(let source, let error): - return "Failed to clone repository from '\(source)': \(error.localizedDescription)" + return "Failed to clone repository from '\(source)': \(error)" case .invalidRepositoryDirectory(let path): return "Invalid Git repository at path: \(path.pathString)" case .createWorkingCopyFailed(let path, let error): @@ -272,6 +294,8 @@ struct GitTemplateFetcher: TemplateFetcher { return "Failed to checkout using requirement '\(requirement)': \(error.localizedDescription)" case .noMatchingTagInRange(let range): return "No Git tags found within version range \(range)" + case .sshAuthenticationRequired(let source): + return "SSH authentication required for '\(source)'.\nEnsure SSH agent is running and key is loaded:\n\nhttps://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent" } } } @@ -359,6 +383,7 @@ struct RegistryTemplateFetcher: TemplateFetcher { sharedRegistriesFile: sharedFile ) } catch { + swiftCommandState.observabilityScope.emit(RegistryConfigError.failedToLoadConfiguration(file: sharedFile, underlyingError: error)) throw RegistryConfigError.failedToLoadConfiguration(file: sharedFile, underlyingError: error) } } From 63ee710f577f48909f827b699ff901aee298b8ac Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 2 Sep 2025 11:10:49 -0400 Subject: [PATCH 212/225] solved bug where test templates would not work as current directory was erroneous if ran in different context --- .../TestCommands/TestTemplateCommand.swift | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index f4e0e972e63..3a6771cf055 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -105,6 +105,11 @@ extension SwiftTestCommand { let commandPlugin = try pluginManager.loadTemplatePlugin() let commandLineFragments = try await pluginManager.run() + + if dryRun { + print(commandLineFragments) + return + } let packageType = try await inferPackageType(swiftCommandState: swiftCommandState, from: cwd) @@ -114,7 +119,7 @@ extension SwiftTestCommand { let folderName = commandLine.fullPathKey - buildMatrix[folderName] = try await testDecisionTreeBranch(folderName: folderName, commandLine: commandLine, swiftCommandState: swiftCommandState, packageType: packageType, commandPlugin: commandPlugin) + buildMatrix[folderName] = try await testDecisionTreeBranch(folderName: folderName, commandLine: commandLine.commandChain, swiftCommandState: swiftCommandState, packageType: packageType, commandPlugin: commandPlugin, cwd: cwd) } @@ -126,7 +131,7 @@ extension SwiftTestCommand { } } - private func testDecisionTreeBranch(folderName: String, commandLine: CommandPath, swiftCommandState: SwiftCommandState, packageType: InitPackage.PackageType, commandPlugin: ResolvedModule) async throws -> BuildInfo { + private func testDecisionTreeBranch(folderName: String, commandLine: [CommandComponent], swiftCommandState: SwiftCommandState, packageType: InitPackage.PackageType, commandPlugin: ResolvedModule, cwd: AbsolutePath) async throws -> BuildInfo { let destinationPath = outputDirectory.appending(component: folderName) swiftCommandState.observabilityScope.emit(debug: "Generating \(folderName)") @@ -139,7 +144,8 @@ extension SwiftTestCommand { destinationAbsolutePath: destinationPath, testingFolderName: folderName, argumentPath: commandLine, - initialPackageType: packageType + initialPackageType: packageType, + cwd: cwd ) } @@ -238,8 +244,9 @@ extension SwiftTestCommand { buildOptions: BuildCommandOptions, destinationAbsolutePath: AbsolutePath, testingFolderName: String, - argumentPath: CommandPath, - initialPackageType: InitPackage.PackageType + argumentPath: [CommandComponent], + initialPackageType: InitPackage.PackageType, + cwd: AbsolutePath ) async throws -> BuildInfo { let startGen = DispatchTime.now() @@ -249,6 +256,7 @@ extension SwiftTestCommand { var buildDuration: DispatchTimeInterval = .never var logPath: String? = nil + var pluginOutput = "" do { let log = destinationAbsolutePath.appending("generation-output.log").pathString let (origOut, origErr) = try redirectStdoutAndStderr(to: log) @@ -256,8 +264,8 @@ extension SwiftTestCommand { let initTemplate = try InitTemplatePackage( name: testingFolderName, - initMode: .fileSystem(name: templateName, path: swiftCommandState.originalWorkingDirectory.pathString), - templatePath: swiftCommandState.originalWorkingDirectory, + initMode: .fileSystem(name: templateName, path: cwd.pathString), + templatePath: cwd, fileSystem: swiftCommandState.fileSystem, packageType: initialPackageType, supportedTestingLibraries: [], @@ -271,26 +279,38 @@ extension SwiftTestCommand { try await swiftCommandState.loadPackageGraph() } - for (index, command) in argumentPath.commandChain.enumerated() { + try await TemplateBuildSupport.buildForTesting(swiftCommandState: swiftCommandState, buildOptions: buildOptions, testingFolder: destinationAbsolutePath) + + var subCommandPath: [String] = [] + for (index, command) in argumentPath.enumerated() { + + subCommandPath.append(contentsOf: (index == 0 ? [] : [command.commandName])) + let commandArgs = command.arguments.flatMap { $0.commandLineFragments } - let fullCommand = (index == 0) ? [] : Array(argumentPath.commandChain.prefix(index + 1).map(\.commandName)) + commandArgs + let fullCommand = subCommandPath + commandArgs print("Running plugin with args:", fullCommand) try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in - _ = try await TemplatePluginRunner.run( + let output = try await TemplatePluginRunner.run( plugin: commandPlugin, package: graph.rootPackages.first!, packageGraph: graph, arguments: fullCommand, swiftCommandState: swiftCommandState ) + pluginOutput = String(data: output, encoding: .utf8) ?? "[Invalid UTF-8 output]" + print(pluginOutput) } } genDuration = startGen.distance(to: .now()) genSuccess = true - try FileManager.default.removeItem(atPath: log) + + if genSuccess { + try FileManager.default.removeItem(atPath: log) + } + } catch { genDuration = startGen.distance(to: .now()) genSuccess = false From ffbf2a74e14cf45edad3dfd8c031e1580a458a8b Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 2 Sep 2025 13:08:02 -0400 Subject: [PATCH 213/225] fixed bug where answers to arguments of a specific argument branch were not shared outside that branch + improved dry-run output to print command line arguments --- .../TestCommands/TestTemplateCommand.swift | 6 +- .../TemplateTesterManager.swift | 72 +++++++++++++++++-- Tests/CommandsTests/TemplateTests.swift | 5 -- 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 3a6771cf055..2cea98e1064 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -107,7 +107,9 @@ extension SwiftTestCommand { let commandLineFragments = try await pluginManager.run() if dryRun { - print(commandLineFragments) + for commandLine in commandLineFragments { + print(commandLine.displayFormat()) + } return } let packageType = try await inferPackageType(swiftCommandState: swiftCommandState, from: cwd) @@ -310,7 +312,7 @@ extension SwiftTestCommand { if genSuccess { try FileManager.default.removeItem(atPath: log) } - + } catch { genDuration = startGen.distance(to: .now()) genSuccess = false diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index 3860cc8baff..6e0ea9e49cb 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -80,6 +80,52 @@ public struct CommandComponent { let arguments: [TemplateTestPromptingSystem.ArgumentResponse] } +extension CommandPath { + func displayFormat() -> String { + let commandNames = commandChain.map { $0.commandName } + let fullPath = commandNames.joined(separator: " ") + + var result = "Command Path: \(fullPath) \nExecution Steps: \n\n" + + // Build progressive commands + for i in 0.. String { + let formattedArgs = argumentResponses.compactMap { response -> + String? in + guard let preferredName = + response.argument.preferredName?.name else { return nil } + + let values = response.values.joined(separator: " ") + return values.isEmpty ? nil : " --\(preferredName) \(values)" + } + + return formattedArgs.joined(separator: " \\\n") + } +} + + public class TemplateTestPromptingSystem { @@ -131,18 +177,31 @@ public class TemplateTestPromptingSystem { let allArgs = try convertArguments(from: command) - let currentArgs = allArgs.filter { arg in - !visitedArgs.contains(where: {$0.argument.valueName == arg.valueName}) - } + // Separate args into already answered and new ones + var finalArgs: [TemplateTestPromptingSystem.ArgumentResponse] = [] + var newArgs: [ArgumentInfoV0] = [] + + for arg in allArgs { + if let existingArg = visitedArgs.first(where: { $0.argument.valueName == arg.valueName }) { + // Reuse the previously answered argument + finalArgs.append(existingArg) + } else { + // This is a new argument that needs prompting + newArgs.append(arg) + } + } + // Only prompt for new arguments var collected: [String: ArgumentResponse] = [:] - let resolvedArgs = UserPrompter.prompt(for: currentArgs, collected: &collected) + let newResolvedArgs = UserPrompter.prompt(for: newArgs, collected: &collected) - resolvedArgs.forEach { visitedArgs.insert($0) } + // Add new arguments to final list and visited set + finalArgs.append(contentsOf: newResolvedArgs) + newResolvedArgs.forEach { visitedArgs.insert($0) } let currentComponent = CommandComponent( - commandName: command.commandName, arguments: resolvedArgs + commandName: command.commandName, arguments: finalArgs ) var newPath = path @@ -166,7 +225,6 @@ public class TemplateTestPromptingSystem { path.map { $0.commandName }.joined(separator: "-") } - } diff --git a/Tests/CommandsTests/TemplateTests.swift b/Tests/CommandsTests/TemplateTests.swift index 0c902cb5416..715d5916a43 100644 --- a/Tests/CommandsTests/TemplateTests.swift +++ b/Tests/CommandsTests/TemplateTests.swift @@ -482,8 +482,3 @@ import class Basics.AsyncProcess } */ } - - - - - From 961494fa674dd97bb4964095a23736cc4ca4f995 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 2 Sep 2025 14:03:24 -0400 Subject: [PATCH 214/225] added predefined arguments to testing, however will need to revisit to get clarity on handling of subcommands --- .../TestCommands/TestTemplateCommand.swift | 2 +- .../TemplateTesterManager.swift | 89 +++++++++++++++++-- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 2cea98e1064..5c31fe92499 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -73,7 +73,7 @@ extension SwiftTestCommand { /// Predetermined arguments specified by the consumer. @Argument( - help: "Predetermined arguments to pass to the template." + help: "Predetermined arguments to pass for testing template." ) var args: [String] = [] diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index 6e0ea9e49cb..c694c96f0b9 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -51,7 +51,7 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { } func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [CommandPath] { - try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command) + try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command, args: args) } public func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { @@ -164,19 +164,95 @@ public class TemplateTestPromptingSystem { // if subcommands exist, then for each subcommand, pass the function again, where we deepCopy a path // if not, then jointhe command names of all the paths, and append CommandPath() - public func generateCommandPaths(rootCommand: CommandInfoV0) throws -> [CommandPath] { + private func parseAndMatchArguments(_ input: [String], definedArgs: [ArgumentInfoV0]) throws -> (Set, [String]) { + var responses = Set() + var providedMap: [String: [String]] = [:] + + var leftover: [String] = [] + + var index = 0 + + while index < input.count { + let token = input[index] + + if token.starts(with: "--") { + let name = String(token.dropFirst(2)) + + guard let arg = definedArgs.first(where : {$0.valueName == name}) else { + // Unknown — defer for potential subcommand + leftover.append(token) + index += 1 + if index < input.count && !input[index].starts(with: "--") { + leftover.append(input[index]) + index += 1 + } + continue + } + + switch arg.kind { + case .flag: + providedMap[name] = ["true"] + case .option: + index += 1 + guard index < input.count else { + throw TemplateError.missingValueForOption(name: name) + } + providedMap[name] = [input[index]] + default: + throw TemplateError.unexpectedNamedArgument(name: name) + } + } else { + leftover.append(token) + } + index += 1 + + } + + for arg in definedArgs { + let name = arg.valueName ?? "__positional" + + guard let values = providedMap[name] else {continue} + + if let allowed = arg.allValues { + let invalid = values.filter {!allowed.contains($0)} + + if !invalid.isEmpty { + throw TemplateError.invalidValue( + argument: name, + invalidValues: invalid, + allowed: allowed + ) + + } + } + responses.insert(ArgumentResponse(argument: arg, values: values)) + providedMap[name] = nil + + } + + return (responses, leftover) + + } + + public func generateCommandPaths(rootCommand: CommandInfoV0, args: [String]) throws -> [CommandPath] { var paths: [CommandPath] = [] var visitedArgs = Set() - try dfs(command: rootCommand, path: [], visitedArgs: &visitedArgs, paths: &paths) + try dfs(command: rootCommand, path: [], visitedArgs: &visitedArgs, paths: &paths, predefinedArgs: args) return paths } - func dfs(command: CommandInfoV0, path: [CommandComponent], visitedArgs: inout Set, paths: inout [CommandPath]) throws{ + func dfs(command: CommandInfoV0, path: [CommandComponent], visitedArgs: inout Set, paths: inout [CommandPath], predefinedArgs: [String]) throws{ let allArgs = try convertArguments(from: command) + var currentPredefinedArgs = predefinedArgs + + let (answeredArgs, leftoverArgs) = try + parseAndMatchArguments(currentPredefinedArgs, definedArgs: allArgs) + + visitedArgs.formUnion(answeredArgs) // Separate args into already answered and new ones var finalArgs: [TemplateTestPromptingSystem.ArgumentResponse] = [] @@ -210,7 +286,7 @@ public class TemplateTestPromptingSystem { if let subcommands = getSubCommand(from: command) { for sub in subcommands { - try dfs(command: sub, path: newPath, visitedArgs: &visitedArgs, paths: &paths) + try dfs(command: sub, path: newPath, visitedArgs: &visitedArgs, paths: &paths, predefinedArgs: leftoverArgs) } } else { let fullPathKey = joinCommandNames(newPath) @@ -403,6 +479,7 @@ private enum TemplateError: Swift.Error { case unexpectedNamedArgument(name: String) case missingValueForOption(name: String) case invalidValue(argument: String, invalidValues: [String], allowed: [String]) + case unexpectedSubcommand(name: String) } extension TemplateError: CustomStringConvertible { @@ -425,6 +502,8 @@ extension TemplateError: CustomStringConvertible { "Missing value for option: \(name)" case .invalidValue(argument: let argument, invalidValues: let invalidValues, allowed: let allowed): "Invalid value \(argument). Valid values are: \(allowed.joined(separator: ", ")). \(invalidValues.isEmpty ? "" : "Also, \(invalidValues.joined(separator: ", ")) are not valid.")" + case .unexpectedSubcommand(name: let name): + "Invalid subcommand \(name) provided in arguments, arguments only accepts flags, options, or positional arguments. Subcommands are treated via the --branch option" } } } From 33d3aa67a558e2a6fa72b930d96700d7d2551681 Mon Sep 17 00:00:00 2001 From: John Bute Date: Tue, 2 Sep 2025 15:56:02 -0400 Subject: [PATCH 215/225] added ability to test specific branches of decision tree, need to flesh out however --- .../TestCommands/TestTemplateCommand.swift | 11 ++++++- .../TemplatePluginCoordinator.swift | 1 + .../TemplatePluginManager.swift | 3 +- .../TemplateTesterManager.swift | 32 +++++++++++++------ 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 5c31fe92499..2597c56aa43 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -77,6 +77,14 @@ extension SwiftTestCommand { ) var args: [String] = [] + @Option( + + name: .customLong("branches"), + parsing: .upToNextOption, + help: "Specify the branch of the template you want to test.", + ) + public var branches: [String] = [] + @Flag(help: "Dry-run to display argument tree") var dryRun: Bool = false @@ -100,7 +108,8 @@ extension SwiftTestCommand { swiftCommandState: swiftCommandState, template: templateName, scratchDirectory: cwd, - args: args + args: args, + branches: branches ) let commandPlugin = try pluginManager.loadTemplatePlugin() diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift index d45496ba94d..aa1e009a58c 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift @@ -21,6 +21,7 @@ struct TemplatePluginCoordinator { let scratchDirectory: Basics.AbsolutePath let template: String? let args: [String] + let branches: [String] let EXPERIMENTAL_DUMP_HELP = ["--", "--experimental-dump-help"] diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 113a9168d6e..00048f1e332 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -38,7 +38,8 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { swiftCommandState: swiftCommandState, scratchDirectory: scratchDirectory, template: template, - args: args + args: args, + branches: [] ) self.packageGraph = try await coordinator.loadPackageGraph() diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index c694c96f0b9..d541685cb9c 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -18,6 +18,7 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { public let scratchDirectory: Basics.AbsolutePath public let args: [String] public let packageGraph: ModulesGraph + public let branches: [String] let coordinator: TemplatePluginCoordinator public var rootPackage: ResolvedPackage { @@ -27,12 +28,13 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { return root } - init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String]) async throws { + init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String], branches: [String]) async throws { let coordinator = TemplatePluginCoordinator( swiftCommandState: swiftCommandState, scratchDirectory: scratchDirectory, template: template, - args: args + args: args, + branches: branches ) self.packageGraph = try await coordinator.loadPackageGraph() @@ -41,6 +43,7 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { self.scratchDirectory = scratchDirectory self.args = args self.coordinator = coordinator + self.branches = branches } func run() async throws -> [CommandPath] { @@ -51,7 +54,7 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { } func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [CommandPath] { - try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command, args: args) + try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command, args: args, branches: branches) } public func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { @@ -234,23 +237,21 @@ public class TemplateTestPromptingSystem { } - public func generateCommandPaths(rootCommand: CommandInfoV0, args: [String]) throws -> [CommandPath] { + public func generateCommandPaths(rootCommand: CommandInfoV0, args: [String], branches: [String]) throws -> [CommandPath] { var paths: [CommandPath] = [] var visitedArgs = Set() - try dfs(command: rootCommand, path: [], visitedArgs: &visitedArgs, paths: &paths, predefinedArgs: args) + try dfs(command: rootCommand, path: [], visitedArgs: &visitedArgs, paths: &paths, predefinedArgs: args, branches: branches, branchDepth: 0) return paths } - func dfs(command: CommandInfoV0, path: [CommandComponent], visitedArgs: inout Set, paths: inout [CommandPath], predefinedArgs: [String]) throws{ + func dfs(command: CommandInfoV0, path: [CommandComponent], visitedArgs: inout Set, paths: inout [CommandPath], predefinedArgs: [String], branches: [String], branchDepth: Int = 0) throws{ let allArgs = try convertArguments(from: command) - var currentPredefinedArgs = predefinedArgs - let (answeredArgs, leftoverArgs) = try - parseAndMatchArguments(currentPredefinedArgs, definedArgs: allArgs) + parseAndMatchArguments(predefinedArgs, definedArgs: allArgs) visitedArgs.formUnion(answeredArgs) @@ -286,7 +287,18 @@ public class TemplateTestPromptingSystem { if let subcommands = getSubCommand(from: command) { for sub in subcommands { - try dfs(command: sub, path: newPath, visitedArgs: &visitedArgs, paths: &paths, predefinedArgs: leftoverArgs) + let shouldTraverse: Bool + if branches.isEmpty { + shouldTraverse = true + } else if branchDepth < (branches.count - 1) { + shouldTraverse = sub.commandName == branches[branchDepth + 1] + } else { + shouldTraverse = true + } + + if shouldTraverse { + try dfs(command: sub, path: newPath, visitedArgs: &visitedArgs, paths: &paths, predefinedArgs: leftoverArgs, branches: branches, branchDepth: branchDepth + 1) + } } } else { let fullPathKey = joinCommandNames(newPath) From 9b8535c878d31950784a0896513951a464117458 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 3 Sep 2025 12:41:36 -0400 Subject: [PATCH 216/225] changes to usability to resolve to latest registry version --- Sources/Commands/PackageCommands/Init.swift | 2 + .../PackageCommands/ShowTemplates.swift | 19 +++++- .../PackageInitializer.swift | 19 ++++-- .../RequirementResolver.swift | 59 ++++++++++++++++++- .../TemplatePathResolver.swift | 2 +- 5 files changed, 91 insertions(+), 10 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index f02569f003f..1b082e5a900 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -224,6 +224,8 @@ struct PackageInitConfiguration { if templateSource != nil { self.versionResolver = DependencyRequirementResolver( + packageIdentity: packageID, + swiftCommandState: swiftCommandState, exact: versionFlags.exact, revision: versionFlags.revision, branch: versionFlags.branch, diff --git a/Sources/Commands/PackageCommands/ShowTemplates.swift b/Sources/Commands/PackageCommands/ShowTemplates.swift index e51ed7530e9..f8d1de880eb 100644 --- a/Sources/Commands/PackageCommands/ShowTemplates.swift +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -100,6 +100,8 @@ struct ShowTemplates: AsyncSwiftCommand { private func resolveTemplatePath(using swiftCommandState: SwiftCommandState, source: InitTemplatePackage.TemplateSource) async throws -> Basics.AbsolutePath { let requirementResolver = DependencyRequirementResolver( + packageIdentity: templatePackageID, + swiftCommandState: swiftCommandState, exact: exact, revision: revision, branch: branch, @@ -108,8 +110,21 @@ struct ShowTemplates: AsyncSwiftCommand { to: to ) - let registryRequirement = try? requirementResolver.resolveRegistry() - let sourceControlRequirement = try? requirementResolver.resolveSourceControl() + var sourceControlRequirement: PackageDependency.SourceControl.Requirement? + var registryRequirement: PackageDependency.Registry.Requirement? + + + switch source { + case .local: + sourceControlRequirement = nil + registryRequirement = nil + case .git: + sourceControlRequirement = try? requirementResolver.resolveSourceControl() + registryRequirement = nil + case .registry: + sourceControlRequirement = nil + registryRequirement = try? await requirementResolver.resolveRegistry() + } return try await TemplatePathResolver( source: source, diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index c1cb78c4a67..500b4ac3246 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -36,10 +36,22 @@ struct TemplatePackageInitializer: PackageInitializer { func run() async throws { try precheck() - // Resolve version requirements - let sourceControlRequirement = try? versionResolver.resolveSourceControl() - let registryRequirement = try? versionResolver.resolveRegistry() + var sourceControlRequirement: PackageDependency.SourceControl.Requirement? + var registryRequirement: PackageDependency.Registry.Requirement? + + switch templateSource { + case .local: + sourceControlRequirement = nil + registryRequirement = nil + case .git: + sourceControlRequirement = try? versionResolver.resolveSourceControl() + registryRequirement = nil + case .registry: + sourceControlRequirement = nil + registryRequirement = try? await versionResolver.resolveRegistry() + } + // Resolve version requirements let resolvedTemplatePath = try await TemplatePathResolver( source: templateSource, templateDirectory: templateDirectory, @@ -65,7 +77,6 @@ struct TemplatePackageInitializer: PackageInitializer { resolvedTemplatePath: resolvedTemplatePath ) - let templatePackage = try setUpPackage(builder: builder, packageType: packageType, stagingPath: stagingPath) swiftCommandState.observabilityScope.emit(debug: "Set up initial package: \(templatePackage.packageName)") diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift index 43e941dda8d..f6f478878bb 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -11,14 +11,19 @@ //===----------------------------------------------------------------------===// import PackageModel +import PackageRegistry import TSCBasic import TSCUtility +import CoreCommands +import Workspace +import PackageFingerprint +import PackageSigning /// A protocol defining interfaces for resolving package dependency requirements /// based on versioning input (e.g., version, branch, or revision). protocol DependencyRequirementResolving { func resolveSourceControl() throws -> PackageDependency.SourceControl.Requirement - func resolveRegistry() throws -> PackageDependency.Registry.Requirement? + func resolveRegistry() async throws -> PackageDependency.Registry.Requirement? } @@ -34,6 +39,10 @@ protocol DependencyRequirementResolving { /// `from`. struct DependencyRequirementResolver: DependencyRequirementResolving { + /// Package-id for registry + let packageIdentity: String? + /// SwiftCommandstate + let swiftCommandState: SwiftCommandState /// An exact version to use. let exact: Version? @@ -95,9 +104,29 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { /// - Returns: A valid `PackageDependency.Registry.Requirement`. /// - Throws: `StringError` if more than one registry versioning input is provided or if `to` is used without a base /// range. - func resolveRegistry() throws -> PackageDependency.Registry.Requirement? { + func resolveRegistry() async throws -> PackageDependency.Registry.Requirement? { if exact == nil, from == nil, upToNextMinorFrom == nil, to == nil { - return nil + let config = try RegistryTemplateFetcher.getRegistriesConfig(self.swiftCommandState, global: true) + let auth = try swiftCommandState.getRegistryAuthorizationProvider() + + guard let stringIdentity = self.packageIdentity else { + throw DependencyRequirementError.noRequirementSpecified + } + let identity = PackageIdentity.plain(stringIdentity) + let registryClient = RegistryClient( + configuration: config.configuration, + fingerprintStorage: .none, + fingerprintCheckingMode: .strict, + skipSignatureValidation: false, + signingEntityStorage: .none, + signingEntityCheckingMode: .strict, + authorizationProvider: auth, + delegate: .none, + checksumAlgorithm: SHA256() + ) + + let resolvedVersion = try await resolveVersion(for: identity, using: registryClient) + return (.exact(resolvedVersion)) } var specifiedRequirements: [PackageDependency.Registry.Requirement] = [] @@ -128,6 +157,23 @@ struct DependencyRequirementResolver: DependencyRequirementResolving { return specifiedRequirements } + + /// Resolves the version to use for registry packages, fetching latest if none specified + /// + /// - Parameters: + /// - packageIdentity: The package identity to resolve version for + /// - registryClient: The registry client to use for fetching metadata + /// - Returns: The resolved version to use + /// - Throws: Error if version resolution fails + func resolveVersion(for packageIdentity: PackageIdentity, using registryClient: RegistryClient) async throws -> Version { + let metadata = try await registryClient.getPackageMetadata(package: packageIdentity, observabilityScope: swiftCommandState.observabilityScope) + + guard let maxVersion = metadata.versions.max() else { + throw DependencyRequirementError.failedToFetchLatestVersion(metadata: metadata, packageIdentity: packageIdentity) + } + + return maxVersion + } } /// Enum representing the type of dependency to resolve. @@ -142,6 +188,7 @@ enum DependencyRequirementError: Error, CustomStringConvertible { case multipleRequirementsSpecified case noRequirementSpecified case invalidToParameterWithoutFrom + case failedToFetchLatestVersion(metadata: RegistryClient.PackageMetadata, packageIdentity: PackageIdentity) var description: String { switch self { @@ -151,6 +198,12 @@ enum DependencyRequirementError: Error, CustomStringConvertible { return "No exact or lower bound version requirement specified." case .invalidToParameterWithoutFrom: return "--to requires --from or --up-to-next-minor-from" + case .failedToFetchLatestVersion(let metadata, let packageIdentity): + return """ + Failed to fetch latest version of \(packageIdentity) + Here is the metadata of the package you were trying to query: + \(metadata) + """ } } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index a5e57a35699..795330b176b 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -372,7 +372,7 @@ struct RegistryTemplateFetcher: TemplateFetcher { /// /// - Returns: Registry configuration to use for fetching packages. /// - Throws: If configurations are missing or unreadable. - private static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace + public static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace .Configuration.Registries { let sharedFile = Workspace.DefaultLocations .registriesConfigurationFile(at: swiftCommandState.sharedConfigurationDirectory) From ef80b312c5fa86b4a3750cec5dee64ebe64a6cb9 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 3 Sep 2025 15:27:42 -0400 Subject: [PATCH 217/225] enforcing templates to have a corresponding product and plugin --- .../PackageLoading/ManifestLoader+Validation.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/PackageLoading/ManifestLoader+Validation.swift b/Sources/PackageLoading/ManifestLoader+Validation.swift index cecc121d5bf..4f5cb3fa0aa 100644 --- a/Sources/PackageLoading/ManifestLoader+Validation.swift +++ b/Sources/PackageLoading/ManifestLoader+Validation.swift @@ -60,6 +60,16 @@ public struct ManifestValidator { diagnostics.append(.duplicateTargetName(targetName: name)) } + let targetsInProducts = Set(self.manifest.products.flatMap { $0.targets }) + + let templateTargetsWithoutProducts = self.manifest.targets.filter { target in + target.templateInitializationOptions != nil && !targetsInProducts.contains(target.name) + } + + for target in templateTargetsWithoutProducts { + diagnostics.append(.templateTargetWithoutProduct(targetName: target.name)) + } + return diagnostics } @@ -288,6 +298,10 @@ extension Basics.Diagnostic { .error("product '\(productName)' doesn't reference any targets") } + static func templateTargetWithoutProduct(targetName: String) -> Self { + .error("template target named '\(targetName) must be referenced by a product'") + } + static func productTargetNotFound(productName: String, targetName: String, validTargets: [String]) -> Self { .error("target '\(targetName)' referenced in product '\(productName)' could not be found; valid targets are: '\(validTargets.joined(separator: "', '"))'") } From 54395443e9295a51f1466281e01fced6d6ee6a69 Mon Sep 17 00:00:00 2001 From: John Bute Date: Wed, 3 Sep 2025 16:50:09 -0400 Subject: [PATCH 218/225] error handling more gracefully, with reporting from observability scope --- .../PackageDependencyBuilder.swift | 20 +-- ...ackageInitializationDirectoryManager.swift | 4 - .../PackageInitializer.swift | 137 ++++++++++-------- .../_InternalInitSupport/TemplateBuild.swift | 2 - .../TemplatePathResolver.swift | 10 -- .../TemplatePluginManager.swift | 20 ++- 6 files changed, 101 insertions(+), 92 deletions(-) diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift index b3b18d4a782..ab7987c31a3 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift @@ -77,19 +77,19 @@ struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { case .git: guard let url = templateURL else { - throw StringError("Missing Git url") + throw PackageDependencyBuilderError.missingGitURLOrPath } guard let requirement = sourceControlRequirement else { - throw StringError("Missing Git requirement") + throw PackageDependencyBuilderError.missingGitRequirement } return .sourceControl(name: self.packageName, location: url, requirement: requirement) case .registry: guard let id = templatePackageID else { - throw StringError("Missing Package ID") + throw PackageDependencyBuilderError.missingRegistryIdentity } guard let requirement = registryRequirement else { - throw StringError("Missing Registry requirement") + throw PackageDependencyBuilderError.missingRegistryRequirement } return .registry(id: id, requirement: requirement) } @@ -98,21 +98,21 @@ struct DefaultPackageDependencyBuilder: PackageDependencyBuilder { /// Errors thrown by `TemplatePathResolver` during initialization. enum PackageDependencyBuilderError: LocalizedError, Equatable { - case missingGitURL + case missingGitURLOrPath case missingGitRequirement case missingRegistryIdentity case missingRegistryRequirement var errorDescription: String? { switch self { - case .missingGitURL: - return "Missing Git URL for git template." + case .missingGitURLOrPath: + return "Missing Git URL or path for template from git." case .missingGitRequirement: - return "Missing version requirement for template in git." + return "Missing version requirement for template from git." case .missingRegistryIdentity: - return "Missing registry package identity for template in registry." + return "Missing registry package identity for template from registry." case .missingRegistryRequirement: - return "Missing version requirement for template in registry ." + return "Missing version requirement for template from registry ." } } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift index 213c5372d7c..f70af672e9c 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift @@ -60,10 +60,6 @@ public struct TemplateInitializationDirectoryManager { } } catch { - observabilityScope.emit( - error: DirectoryManagerError.cleanupFailed(path: temporaryDirectory, underlying: error), - underlyingError: error - ) throw DirectoryManagerError.cleanupFailed(path: temporaryDirectory, underlying: error) } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index 500b4ac3246..1026a5c7d13 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -34,80 +34,92 @@ struct TemplatePackageInitializer: PackageInitializer { let swiftCommandState: SwiftCommandState func run() async throws { - try precheck() - - var sourceControlRequirement: PackageDependency.SourceControl.Requirement? - var registryRequirement: PackageDependency.Registry.Requirement? - - switch templateSource { - case .local: - sourceControlRequirement = nil - registryRequirement = nil - case .git: - sourceControlRequirement = try? versionResolver.resolveSourceControl() - registryRequirement = nil - case .registry: - sourceControlRequirement = nil - registryRequirement = try? await versionResolver.resolveRegistry() - } - - // Resolve version requirements - let resolvedTemplatePath = try await TemplatePathResolver( - source: templateSource, - templateDirectory: templateDirectory, - templateURL: templateURL, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - packageIdentity: templatePackageID, - swiftCommandState: swiftCommandState - ).resolve() - - let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) - let (stagingPath, cleanupPath, tempDir) = try directoryManager.createTemporaryDirectories() - - let packageType = try await TemplatePackageInitializer.inferPackageType(from: resolvedTemplatePath, templateName: templateName, swiftCommandState: swiftCommandState) - - let builder = DefaultPackageDependencyBuilder( - templateSource: templateSource, - packageName: packageName, - templateURL: templateURL, - templatePackageID: templatePackageID, - sourceControlRequirement: sourceControlRequirement, - registryRequirement: registryRequirement, - resolvedTemplatePath: resolvedTemplatePath - ) + do { + try precheck() + var sourceControlRequirement: PackageDependency.SourceControl.Requirement? + var registryRequirement: PackageDependency.Registry.Requirement? + + swiftCommandState.observabilityScope.emit(debug: "Fetching versioning requirements and resolving path of template on local disk.") + + switch templateSource { + case .local: + sourceControlRequirement = nil + registryRequirement = nil + case .git: + sourceControlRequirement = try? versionResolver.resolveSourceControl() + registryRequirement = nil + case .registry: + sourceControlRequirement = nil + registryRequirement = try? await versionResolver.resolveRegistry() + } - let templatePackage = try setUpPackage(builder: builder, packageType: packageType, stagingPath: stagingPath) + // Resolve version requirements + let resolvedTemplatePath = try await TemplatePathResolver( + source: templateSource, + templateDirectory: templateDirectory, + templateURL: templateURL, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() + + let directoryManager = TemplateInitializationDirectoryManager(fileSystem: swiftCommandState.fileSystem, observabilityScope: swiftCommandState.observabilityScope) + let (stagingPath, cleanupPath, tempDir) = try directoryManager.createTemporaryDirectories() + + swiftCommandState.observabilityScope.emit(debug: "Inferring initial type of consumer's package based on template's specifications.") + + let packageType = try await TemplatePackageInitializer.inferPackageType(from: resolvedTemplatePath, templateName: templateName, swiftCommandState: swiftCommandState) + let builder = DefaultPackageDependencyBuilder( + templateSource: templateSource, + packageName: packageName, + templateURL: templateURL, + templatePackageID: templatePackageID, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ) - swiftCommandState.observabilityScope.emit(debug: "Set up initial package: \(templatePackage.packageName)") - try await TemplateBuildSupport.build( - swiftCommandState: swiftCommandState, - buildOptions: buildOptions, - globalOptions: globalOptions, - cwd: stagingPath, - transitiveFolder: stagingPath - ) + let templatePackage = try setUpPackage(builder: builder, packageType: packageType, stagingPath: stagingPath) - try await TemplateInitializationPluginManager( - swiftCommandState: swiftCommandState, - template: templateName, - scratchDirectory: stagingPath, - args: args - ).run() + swiftCommandState.observabilityScope.emit(debug: "Finished setting up initial package: \(templatePackage.packageName).") - try await directoryManager.finalize(cwd: cwd, stagingPath: stagingPath, cleanupPath: cleanupPath, swiftCommandState: swiftCommandState) + swiftCommandState.observabilityScope.emit(debug: "Building package with dependency on template.") - if validatePackage { try await TemplateBuildSupport.build( swiftCommandState: swiftCommandState, buildOptions: buildOptions, globalOptions: globalOptions, - cwd: cwd + cwd: stagingPath, + transitiveFolder: stagingPath ) - } - try directoryManager.cleanupTemporary(templateSource: templateSource, path: resolvedTemplatePath, temporaryDirectory: tempDir) + swiftCommandState.observabilityScope.emit(debug: "Running plugin steps, including prompting and running the template package's plugin.") + + try await TemplateInitializationPluginManager( + swiftCommandState: swiftCommandState, + template: templateName, + scratchDirectory: stagingPath, + args: args + ).run() + + try await directoryManager.finalize(cwd: cwd, stagingPath: stagingPath, cleanupPath: cleanupPath, swiftCommandState: swiftCommandState) + + if validatePackage { + try await TemplateBuildSupport.build( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + globalOptions: globalOptions, + cwd: cwd + ) + } + + try directoryManager.cleanupTemporary(templateSource: templateSource, path: resolvedTemplatePath, temporaryDirectory: tempDir) + + } catch { + swiftCommandState.observabilityScope.emit(error) + } } //Will have to add checking for git + registry too @@ -270,6 +282,5 @@ struct StandardPackageInitializer: PackageInitializer { } } } - } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift index e0985730164..8bc5b2f13e8 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -60,7 +60,6 @@ enum TemplateBuildSupport { do { try await buildSystem.build(subset: subset) } catch let diagnostics as Diagnostics { - swiftCommandState.observabilityScope.emit(diagnostics) throw ExitCode.failure } } @@ -94,7 +93,6 @@ enum TemplateBuildSupport { do { try await buildSystem.build(subset: subset) } catch let diagnostics as Diagnostics { - swiftCommandState.observabilityScope.emit(diagnostics) throw ExitCode.failure } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index 795330b176b..ab246b8ab5e 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -74,21 +74,18 @@ struct TemplatePathResolver { switch source { case .local: guard let path = templateDirectory else { - swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingLocalTemplatePath) throw TemplatePathResolverError.missingLocalTemplatePath } self.fetcher = LocalTemplateFetcher(path: path) case .git: guard let url = templateURL, let requirement = sourceControlRequirement else { - swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingGitURLOrRequirement) throw TemplatePathResolverError.missingGitURLOrRequirement } self.fetcher = GitTemplateFetcher(source: url, requirement: requirement, swiftCommandState: swiftCommandState) case .registry: guard let identity = packageIdentity, let requirement = registryRequirement else { - swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingRegistryIdentityOrRequirement) throw TemplatePathResolverError.missingRegistryIdentityOrRequirement } self.fetcher = RegistryTemplateFetcher( @@ -98,7 +95,6 @@ struct TemplatePathResolver { ) case .none: - swiftCommandState.observabilityScope.emit(TemplatePathResolverError.missingTemplateType) throw TemplatePathResolverError.missingTemplateType } } @@ -203,10 +199,8 @@ struct GitTemplateFetcher: TemplateFetcher { try provider.fetch(repository: repositorySpecifier, to: path) } catch { if isSSHPermissionError(error) { - swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.sshAuthenticationRequired(source: source)) throw GitTemplateFetcherError.sshAuthenticationRequired(source: source) } - swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.cloneFailed(source: source, underlyingError: error)) throw GitTemplateFetcherError.cloneFailed(source: source, underlyingError: error) } } @@ -222,7 +216,6 @@ struct GitTemplateFetcher: TemplateFetcher { private func validateBareRepository(at path: Basics.AbsolutePath) throws { let provider = GitRepositoryProvider() guard try provider.isValidDirectory(path) else { - swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.invalidRepositoryDirectory(path: path)) throw GitTemplateFetcherError.invalidRepositoryDirectory(path: path) } } @@ -242,7 +235,6 @@ struct GitTemplateFetcher: TemplateFetcher { editable: true ) } catch { - swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.createWorkingCopyFailed(path: workingCopyPath, underlyingError: error)) throw GitTemplateFetcherError.createWorkingCopyFailed(path: workingCopyPath, underlyingError: error) } } @@ -267,7 +259,6 @@ struct GitTemplateFetcher: TemplateFetcher { let versions = tags.compactMap { Version($0) } let filteredVersions = versions.filter { range.contains($0) } guard let latestVersion = filteredVersions.max() else { - swiftCommandState.observabilityScope.emit(GitTemplateFetcherError.noMatchingTagInRange(range)) throw GitTemplateFetcherError.noMatchingTagInRange(range) } try repository.checkout(tag: latestVersion.description) @@ -383,7 +374,6 @@ struct RegistryTemplateFetcher: TemplateFetcher { sharedRegistriesFile: sharedFile ) } catch { - swiftCommandState.observabilityScope.emit(RegistryConfigError.failedToLoadConfiguration(file: sharedFile, underlyingError: error)) throw RegistryConfigError.failedToLoadConfiguration(file: sharedFile, underlyingError: error) } } diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 00048f1e332..04fdb130fb5 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -27,10 +27,12 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { var rootPackage: ResolvedPackage { - guard let root = packageGraph.rootPackages.first else { - fatalError("No root package found in the package graph.") + get throws { + guard let root = packageGraph.rootPackages.first else { + throw TemplateInitializationError.missingPackageGraph + } + return root } - return root } init(swiftCommandState: SwiftCommandState, template: String?, scratchDirectory: Basics.AbsolutePath, args: [String]) async throws { @@ -114,4 +116,16 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { func loadTemplatePlugin() throws -> ResolvedModule { try coordinator.loadTemplatePlugin(from: packageGraph) } + + enum TemplateInitializationError: Error, CustomStringConvertible { + case missingPackageGraph + + var description: String { + switch self { + case .missingPackageGraph: + return "No root package was found in package graph." + } + } + } + } From b59119ad8e01439698cca4eac0982c4d8ce3489d Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 4 Sep 2025 11:16:32 -0400 Subject: [PATCH 219/225] fixed permissions to query for them only once during process + refactored coordinator + plugin managers --- .../TestCommands/TestTemplateCommand.swift | 8 ++- .../TemplatePluginCoordinator.swift | 5 +- .../TemplatePluginManager.swift | 60 +++++++++++++------ .../TemplatePluginRunner.swift | 21 ++++--- .../TemplateTesterManager.swift | 32 ++++------ 5 files changed, 72 insertions(+), 54 deletions(-) diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 2597c56aa43..f8ea6124524 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -303,12 +303,14 @@ extension SwiftTestCommand { print("Running plugin with args:", fullCommand) try await swiftCommandState.withTemporaryWorkspace(switchingTo: destinationAbsolutePath) { _, _ in - let output = try await TemplatePluginRunner.run( + + let output = try await TemplatePluginExecutor.execute( plugin: commandPlugin, - package: graph.rootPackages.first!, + rootPackage: graph.rootPackages.first!, packageGraph: graph, arguments: fullCommand, - swiftCommandState: swiftCommandState + swiftCommandState: swiftCommandState, + requestPermission: false ) pluginOutput = String(data: output, encoding: .utf8) ?? "[Invalid UTF-8 output]" print(pluginOutput) diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift index aa1e009a58c..18180dda02c 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift @@ -23,7 +23,7 @@ struct TemplatePluginCoordinator { let args: [String] let branches: [String] - let EXPERIMENTAL_DUMP_HELP = ["--", "--experimental-dump-help"] + private let EXPERIMENTAL_DUMP_HELP = ["--", "--experimental-dump-help"] func loadPackageGraph() async throws -> ModulesGraph { try await swiftCommandState.withTemporaryWorkspace(switchingTo: scratchDirectory) { _, _ in @@ -64,7 +64,8 @@ struct TemplatePluginCoordinator { package: rootPackage, packageGraph: packageGraph, arguments: EXPERIMENTAL_DUMP_HELP, - swiftCommandState: swiftCommandState + swiftCommandState: swiftCommandState, + requestPermission: true ) do { diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 04fdb130fb5..9551c49fe82 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -10,7 +10,27 @@ import PackageGraph public protocol TemplatePluginManager { func loadTemplatePlugin() throws -> ResolvedModule - func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data +} + +/// Utility for executing template plugins with common patterns. +enum TemplatePluginExecutor { + static func execute( + plugin: ResolvedModule, + rootPackage: ResolvedPackage, + packageGraph: ModulesGraph, + arguments: [String], + swiftCommandState: SwiftCommandState, + requestPermission: Bool = false + ) async throws -> Data { + return try await TemplatePluginRunner.run( + plugin: plugin, + package: rootPackage, + packageGraph: packageGraph, + arguments: arguments, + swiftCommandState: swiftCommandState, + requestPermission: requestPermission + ) + } } /// A utility for obtaining and running a template's plugin . @@ -18,15 +38,14 @@ public protocol TemplatePluginManager { /// `TemplateIntiializationPluginManager` encapsulates the logic needed to fetch, /// and run templates' plugins given arguments, based on the template initialization workflow. struct TemplateInitializationPluginManager: TemplatePluginManager { - let swiftCommandState: SwiftCommandState - let template: String? - let scratchDirectory: Basics.AbsolutePath - let args: [String] - let packageGraph: ModulesGraph - let coordinator: TemplatePluginCoordinator - - - var rootPackage: ResolvedPackage { + private let swiftCommandState: SwiftCommandState + private let template: String? + private let scratchDirectory: Basics.AbsolutePath + private let args: [String] + private let packageGraph: ModulesGraph + private let coordinator: TemplatePluginCoordinator + + private var rootPackage: ResolvedPackage { get throws { guard let root = packageGraph.rootPackages.first else { throw TemplateInitializationError.missingPackageGraph @@ -60,13 +79,13 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// - `TemplatePluginError.execu` func run() async throws { - let plugin = try coordinator.loadTemplatePlugin(from: packageGraph) + let plugin = try loadTemplatePlugin() let toolInfo = try await coordinator.dumpToolInfo(using: plugin, from: packageGraph, rootPackage: rootPackage) let cliResponses: [[String]] = try promptUserForTemplateArguments(using: toolInfo) for response in cliResponses { - _ = try await executeTemplatePlugin(plugin, with: response) + _ = try await runTemplatePlugin(plugin, with: response) } } @@ -78,8 +97,10 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// - Throws: /// - Any other errors thrown during the prompting of the user. /// - /// - Returns: A 2D array of the arguments given by the user, that will be consumed by the template during the project generation phase. - func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [[String]] { + /// - Parameter toolInfo: The JSON representation of the template's decision tree + /// - Returns: A 2D array of arguments provided by the user for template generation + /// - Throws: Any errors during user prompting + private func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [[String]] { return try TemplatePromptingSystem().promptUser(command: toolInfo.command, arguments: args) } @@ -95,13 +116,14 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// /// - Returns: A data representation of the result of the execution of the template's plugin. - func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { - return try await TemplatePluginRunner.run( + private func runTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { + return try await TemplatePluginExecutor.execute( plugin: plugin, - package: rootPackage, + rootPackage: rootPackage, packageGraph: packageGraph, arguments: arguments, - swiftCommandState: swiftCommandState + swiftCommandState: swiftCommandState, + requestPermission: false ) } @@ -114,7 +136,7 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// - Returns: A data representation of the result of the execution of the template's plugin. func loadTemplatePlugin() throws -> ResolvedModule { - try coordinator.loadTemplatePlugin(from: packageGraph) + return try coordinator.loadTemplatePlugin(from: packageGraph) } enum TemplateInitializationError: Error, CustomStringConvertible { diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift index 6bd4329d261..d7910cb0f6f 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift @@ -65,7 +65,8 @@ enum TemplatePluginRunner { packageGraph: ModulesGraph, arguments: [String], swiftCommandState: SwiftCommandState, - allowNetworkConnections: [SandboxNetworkPermission] = [] + allowNetworkConnections: [SandboxNetworkPermission] = [], + requestPermission: Bool ) async throws -> Data { let pluginTarget = try castToPlugin(plugin) let pluginsDir = try pluginDirectory(for: plugin.name, in: swiftCommandState) @@ -75,14 +76,16 @@ enum TemplatePluginRunner { var writableDirs = [outputDir, package.path] var allowedNetworkConnections = allowNetworkConnections - try requestPluginPermissions( - from: pluginTarget, - pluginName: plugin.name, - packagePath: package.path, - writableDirectories: &writableDirs, - allowNetworkConnections: &allowedNetworkConnections, - state: swiftCommandState - ) + if requestPermission { + try requestPluginPermissions( + from: pluginTarget, + pluginName: plugin.name, + packagePath: package.path, + writableDirectories: &writableDirs, + allowNetworkConnections: &allowedNetworkConnections, + state: swiftCommandState + ) + } let readOnlyDirs = writableDirs .contains(where: { package.path.isDescendantOfOrEqual(to: $0) }) ? [] : [package.path] diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index d541685cb9c..c99d2314436 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -13,15 +13,15 @@ import PackageGraph /// `TemplateTesterPluginManager` encapsulates the logic needed to fetch, /// and run templates' plugins given arguments, based on the template initialization workflow. public struct TemplateTesterPluginManager: TemplatePluginManager { - public let swiftCommandState: SwiftCommandState - public let template: String? - public let scratchDirectory: Basics.AbsolutePath - public let args: [String] - public let packageGraph: ModulesGraph - public let branches: [String] - let coordinator: TemplatePluginCoordinator - - public var rootPackage: ResolvedPackage { + private let swiftCommandState: SwiftCommandState + private let template: String? + private let scratchDirectory: Basics.AbsolutePath + private let args: [String] + private let packageGraph: ModulesGraph + private let branches: [String] + private let coordinator: TemplatePluginCoordinator + + private var rootPackage: ResolvedPackage { guard let root = packageGraph.rootPackages.first else { fatalError("No root package found.") } @@ -53,22 +53,12 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { return try promptUserForTemplateArguments(using: toolInfo) } - func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [CommandPath] { + private func promptUserForTemplateArguments(using toolInfo: ToolInfoV0) throws -> [CommandPath] { try TemplateTestPromptingSystem().generateCommandPaths(rootCommand: toolInfo.command, args: args, branches: branches) } - public func executeTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { - try await TemplatePluginRunner.run( - plugin: plugin, - package: rootPackage, - packageGraph: packageGraph, - arguments: arguments, - swiftCommandState: swiftCommandState - ) - } - public func loadTemplatePlugin() throws -> ResolvedModule { - try coordinator.loadTemplatePlugin(from: packageGraph) + return try coordinator.loadTemplatePlugin(from: packageGraph) } } From 8ca8d8f114e38013e633947be979cd19ed4fcde5 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 2 Oct 2025 00:55:34 -0400 Subject: [PATCH 220/225] reverted swift-version --- .swift-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.swift-version b/.swift-version index 6abaeb2f907..4ac4fded49f 100644 --- a/.swift-version +++ b/.swift-version @@ -1 +1 @@ -6.2.0 +6.2.0 \ No newline at end of file From c80736789138763aa0738a491cf87051db45ca9d Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 2 Oct 2025 01:01:51 -0400 Subject: [PATCH 221/225] reducing PR size by getting rid of unwanted changes --- .../DirectoryManagerFinalize/generated-package/.gitignore | 8 -------- Sources/Commands/PackageCommands/PluginCommand.swift | 8 ++------ .../PackageRegistryCommand/PackageRegistryCommand.swift | 2 +- .../TemplateWorkspaceUtilities/InitPackage.swift | 3 +-- 4 files changed, 4 insertions(+), 17 deletions(-) delete mode 100644 Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore diff --git a/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore deleted file mode 100644 index 0023a534063..00000000000 --- a/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc diff --git a/Sources/Commands/PackageCommands/PluginCommand.swift b/Sources/Commands/PackageCommands/PluginCommand.swift index 042e99b77e9..0d23a09b978 100644 --- a/Sources/Commands/PackageCommands/PluginCommand.swift +++ b/Sources/Commands/PackageCommands/PluginCommand.swift @@ -424,19 +424,15 @@ struct PluginCommand: AsyncSwiftCommand { } } - static func findPlugins(matching verb: String?, in graph: ModulesGraph, limitedTo packageIdentity: String?) -> [ResolvedModule] { + static func findPlugins(matching verb: String, in graph: ModulesGraph, limitedTo packageIdentity: String?) -> [ResolvedModule] { // Find and return the command plugins that match the command. - let plugins = Self.availableCommandPlugins(in: graph, limitedTo: packageIdentity).filter { + Self.availableCommandPlugins(in: graph, limitedTo: packageIdentity).filter { let plugin = $0.underlying as! PluginModule // Filter out any non-command plugins and any whose verb is different. guard case .command(let intent, _) = plugin.capability else { return false } - guard let verb else { return true } - return verb == intent.invocationVerb } - - return plugins } } diff --git a/Sources/PackageRegistryCommand/PackageRegistryCommand.swift b/Sources/PackageRegistryCommand/PackageRegistryCommand.swift index 1858a16bcd3..f41004fcd8e 100644 --- a/Sources/PackageRegistryCommand/PackageRegistryCommand.swift +++ b/Sources/PackageRegistryCommand/PackageRegistryCommand.swift @@ -31,7 +31,7 @@ public struct PackageRegistryCommand: AsyncParsableCommand { Unset.self, Login.self, Logout.self, - Publish.self + Publish.self, ], helpNames: [.short, .long, .customLong("help", withSingleDash: true)] ) diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift index 32f082ac8eb..bf82424481a 100644 --- a/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift +++ b/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift @@ -20,8 +20,7 @@ import protocol TSCBasic.OutputByteStream /// Create an initial template package. public final class InitPackage { /// The tool version to be used for new packages. - public static let newPackageToolsVersion = ToolsVersion.v6_1 //TODO: JOHN CHANGE ME BACK TO: - // - public static let newPackageToolsVersion = ToolsVersion.current + public static let newPackageToolsVersion = ToolsVersion.current /// Options for the template package. public struct InitPackageOptions { From 9b37c4e3b0ba2ab95d84e8ad59f8c4d3df1479bc Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 2 Oct 2025 01:17:10 -0400 Subject: [PATCH 222/225] reducing PR --- .../Commands/PackageCommands/AddTarget.swift | 2 - .../PackageCommands/PluginCommand.swift | 1 - Sources/PackageModelSyntax/AddTarget.swift | 439 ------------------ .../ProductDescription+Syntax.swift | 62 --- .../TargetDescription+Syntax.swift | 99 ---- Tests/FunctionalTests/PluginTests.swift | 4 - 6 files changed, 607 deletions(-) delete mode 100644 Sources/PackageModelSyntax/AddTarget.swift delete mode 100644 Sources/PackageModelSyntax/ProductDescription+Syntax.swift delete mode 100644 Sources/PackageModelSyntax/TargetDescription+Syntax.swift diff --git a/Sources/Commands/PackageCommands/AddTarget.swift b/Sources/Commands/PackageCommands/AddTarget.swift index dc470614bd9..089f148675f 100644 --- a/Sources/Commands/PackageCommands/AddTarget.swift +++ b/Sources/Commands/PackageCommands/AddTarget.swift @@ -114,8 +114,6 @@ extension SwiftPackageCommand { case .executable: .executable case .test: .test case .macro: .macro - default: - throw StringError("unexpected target type: \(self.type)") } // Map dependencies diff --git a/Sources/Commands/PackageCommands/PluginCommand.swift b/Sources/Commands/PackageCommands/PluginCommand.swift index 0d23a09b978..8bc238af22a 100644 --- a/Sources/Commands/PackageCommands/PluginCommand.swift +++ b/Sources/Commands/PackageCommands/PluginCommand.swift @@ -430,7 +430,6 @@ struct PluginCommand: AsyncSwiftCommand { let plugin = $0.underlying as! PluginModule // Filter out any non-command plugins and any whose verb is different. guard case .command(let intent, _) = plugin.capability else { return false } - return verb == intent.invocationVerb } } diff --git a/Sources/PackageModelSyntax/AddTarget.swift b/Sources/PackageModelSyntax/AddTarget.swift deleted file mode 100644 index b6a081a7d67..00000000000 --- a/Sources/PackageModelSyntax/AddTarget.swift +++ /dev/null @@ -1,439 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 Basics -import Foundation -import PackageModel -import SwiftParser -import SwiftSyntax -import SwiftSyntaxBuilder -import struct TSCUtility.Version - -/// Add a target to a manifest's source code. -public enum AddTarget { - /// The set of argument labels that can occur after the "targets" - /// 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 argumentLabelsAfterTargets: Set = [ - "swiftLanguageVersions", - "cLanguageStandard", - "cxxLanguageStandard", - ] - - /// The kind of test harness to use. This isn't part of the manifest - /// itself, but is used to guide the generation process. - public enum TestHarness: String, Codable { - /// Don't use any library - case none - - /// Create a test using the XCTest library. - case xctest - - /// Create a test using the swift-testing package. - case swiftTesting = "swift-testing" - - /// The default testing library to use. - public static var `default`: TestHarness = .xctest - } - - /// Additional configuration information to guide the package editing - /// process. - public struct Configuration { - /// The test harness to use. - public var testHarness: TestHarness - - public init(testHarness: TestHarness = .default) { - self.testHarness = testHarness - } - } - - // Check if the package has a single target with that target's sources located - // directly in `./Sources`. If so, move the sources into a folder named after - // the target before adding a new target. - package static func moveSingleTargetSources( - packagePath: AbsolutePath, - manifest: SourceFileSyntax, - fileSystem: any FileSystem, - verbose: Bool = false - ) throws { - // 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 - } - - if let arg = packageCall.findArgument(labeled: "targets") { - guard let argArray = arg.expression.findArrayArgument() else { - throw ManifestEditError.cannotFindArrayLiteralArgument( - argumentName: "targets", - node: Syntax(arg.expression) - ) - } - - // Check the contents of the `targets` array to see if there is only one target defined. - guard argArray.elements.count == 1, - let firstTarget = argArray.elements.first?.expression.as(FunctionCallExprSyntax.self), - let targetStringLiteral = firstTarget.arguments.first?.expression.as(StringLiteralExprSyntax.self) else { - return - } - - let targetName = targetStringLiteral.segments.description - let sourcesFolder = packagePath.appending("Sources") - let expectedTargetFolder = sourcesFolder.appending(targetName) - - // If there is one target then pull its name out and use that to look for a folder in `Sources/TargetName`. - // If the folder doesn't exist then we know we have a single target package and we need to migrate files - // into this folder before we can add a new target. - if !fileSystem.isDirectory(expectedTargetFolder) { - if verbose { - print( - """ - Moving existing files from \( - sourcesFolder.relative(to: packagePath) - ) to \( - expectedTargetFolder.relative(to: packagePath) - )... - """, - terminator: "" - ) - } - let contentsToMove = try fileSystem.getDirectoryContents(sourcesFolder) - try fileSystem.createDirectory(expectedTargetFolder) - for file in contentsToMove { - let source = sourcesFolder.appending(file) - let destination = expectedTargetFolder.appending(file) - try fileSystem.move(from: source, to: destination) - } - if verbose { - print(" done.") - } - } - } - } - - /// Add the given target to the manifest, producing a set of edit results - /// that updates the manifest and adds some source files to stub out the - /// new target. - public static func addTarget( - _ target: TargetDescription, - to manifest: SourceFileSyntax, - configuration: Configuration = .init(), - installedSwiftPMConfiguration: InstalledSwiftPMConfiguration = .default - ) 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 - } - - // Create a mutable version of target to which we can add more - // content when needed. - var target = target - - // Add dependencies needed for various targets. - switch target.type { - case .macro: - // Macro targets need to depend on a couple of libraries from - // SwiftSyntax. - target.dependencies.append(contentsOf: macroTargetDependencies) - - default: - break - } - - var newPackageCall = try packageCall.appendingToArrayArgument( - label: "targets", - trailingLabels: Self.argumentLabelsAfterTargets, - newElement: target.asSyntax() - ) - - let outerDirectory: String? = switch target.type { - case .binary, .plugin, .system: nil - case .executable, .regular, .macro: "Sources" - case .test: "Tests" - } - - guard let outerDirectory else { - return PackageEditResult( - manifestEdits: [ - .replace(packageCall, with: newPackageCall.description), - ] - ) - } - - let outerPath = try RelativePath(validating: outerDirectory) - - /// The set of auxiliary files this refactoring will create. - var auxiliaryFiles: AuxiliaryFiles = [] - - // Add the primary source file. Every target type has this. - self.addPrimarySourceFile( - outerPath: outerPath, - target: target, - configuration: configuration, - to: &auxiliaryFiles - ) - - // Perform any other actions that are needed for this target type. - var extraManifestEdits: [SourceEdit] = [] - switch target.type { - case .macro: - self.addProvidedMacrosSourceFile( - outerPath: outerPath, - target: target, - to: &auxiliaryFiles - ) - - if !manifest.description.contains("swift-syntax") { - newPackageCall = try AddPackageDependency - .addPackageDependencyLocal( - .swiftSyntax( - configuration: installedSwiftPMConfiguration - ), - to: newPackageCall - ) - - // Look for the first import declaration and insert an - // import of `CompilerPluginSupport` there. - let newImport = "import CompilerPluginSupport\n" - for node in manifest.statements { - if let importDecl = node.item.as(ImportDeclSyntax.self) { - let insertPos = importDecl - .positionAfterSkippingLeadingTrivia - extraManifestEdits.append( - SourceEdit( - range: insertPos ..< insertPos, - replacement: newImport - ) - ) - break - } - } - } - - default: break - } - - return PackageEditResult( - manifestEdits: [ - .replace(packageCall, with: newPackageCall.description), - ] + extraManifestEdits, - auxiliaryFiles: auxiliaryFiles - ) - } - - /// Add the primary source file for a target to the list of auxiliary - /// source files. - fileprivate static func addPrimarySourceFile( - outerPath: RelativePath, - target: TargetDescription, - configuration: Configuration, - to auxiliaryFiles: inout AuxiliaryFiles - ) { - let sourceFilePath = outerPath.appending( - components: [target.name, "\(target.name).swift"] - ) - - // Introduce imports for each of the dependencies that were specified. - var importModuleNames = target.dependencies.map(\.name) - - // Add appropriate test module dependencies. - if target.type == .test { - switch configuration.testHarness { - case .none: - break - - case .xctest: - importModuleNames.append("XCTest") - - case .swiftTesting: - importModuleNames.append("Testing") - } - } - - let importDecls = importModuleNames.lazy.sorted().map { name in - DeclSyntax("import \(raw: name)").with(\.trailingTrivia, .newline) - } - - let imports = CodeBlockItemListSyntax { - for importDecl in importDecls { - importDecl - } - } - - let sourceFileText: SourceFileSyntax = switch target.type { - case .binary, .plugin, .system: - fatalError("should have exited above") - - case .macro: - """ - \(imports) - struct \(raw: target.sanitizedName): Macro { - /// TODO: Implement one or more of the protocols that inherit - /// from Macro. The appropriate macro protocol is determined - /// by the "macro" declaration that \(raw: target.sanitizedName) implements. - /// Examples include: - /// @freestanding(expression) macro --> ExpressionMacro - /// @attached(member) macro --> MemberMacro - } - """ - - case .test: - switch configuration.testHarness { - case .none: - """ - \(imports) - // Test code here - """ - - case .xctest: - """ - \(imports) - class \(raw: target.sanitizedName)Tests: XCTestCase { - func test\(raw: target.sanitizedName)() { - XCTAssertEqual(42, 17 + 25) - } - } - """ - - case .swiftTesting: - """ - \(imports) - @Suite - struct \(raw: target.sanitizedName)Tests { - @Test("\(raw: target.sanitizedName) tests") - func example() { - #expect(42 == 17 + 25) - } - } - """ - } - - case .regular: - """ - \(imports) - """ - - case .executable: - """ - \(imports) - @main - struct \(raw: target.sanitizedName)Main { - static func main() { - print("Hello, world") - } - } - """ - } - - auxiliaryFiles.addSourceFile( - path: sourceFilePath, - sourceCode: sourceFileText - ) - } - - /// Add a file that introduces the main entrypoint and provided macros - /// for a macro target. - fileprivate static func addProvidedMacrosSourceFile( - outerPath: RelativePath, - target: TargetDescription, - to auxiliaryFiles: inout AuxiliaryFiles - ) { - auxiliaryFiles.addSourceFile( - path: outerPath.appending( - components: [target.name, "ProvidedMacros.swift"] - ), - sourceCode: """ - import SwiftCompilerPlugin - - @main - struct \(raw: target.sanitizedName)Macros: CompilerPlugin { - let providingMacros: [Macro.Type] = [ - \(raw: target.sanitizedName).self, - ] - } - """ - ) - } -} - -extension TargetDescription.Dependency { - /// Retrieve the name of the dependency - fileprivate var name: String { - switch self { - case .target(name: let name, condition: _), - .byName(name: let name, condition: _), - .product(name: let name, package: _, moduleAliases: _, condition: _): - name - } - } -} - -/// The array of auxiliary files that can be added by a package editing -/// operation. -private typealias AuxiliaryFiles = [(RelativePath, SourceFileSyntax)] - -extension AuxiliaryFiles { - /// Add a source file to the list of auxiliary files. - fileprivate mutating func addSourceFile( - path: RelativePath, - sourceCode: SourceFileSyntax - ) { - self.append((path, sourceCode)) - } -} - -/// The set of dependencies we need to introduce to a newly-created macro -/// target. -private let macroTargetDependencies: [TargetDescription.Dependency] = [ - .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), - .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), -] - -/// The package dependency for swift-syntax, for use in macros. -extension MappablePackageDependency.Kind { - /// Source control URL for the swift-syntax package. - fileprivate static var swiftSyntaxURL: SourceControlURL { - "https://github.com/swiftlang/swift-syntax.git" - } - - /// Package dependency on the swift-syntax package. - fileprivate static func swiftSyntax( - configuration: InstalledSwiftPMConfiguration - ) -> MappablePackageDependency.Kind { - let swiftSyntaxVersionDefault = configuration - .swiftSyntaxVersionForMacroTemplate - let swiftSyntaxVersion = Version(swiftSyntaxVersionDefault.description)! - - return .sourceControl( - name: nil, - location: self.swiftSyntaxURL.absoluteString, - requirement: .range(.upToNextMajor(from: swiftSyntaxVersion)) - ) - } -} - -extension TargetDescription { - fileprivate var sanitizedName: String { - self.name - .spm_mangledToC99ExtendedIdentifier() - .localizedFirstWordCapitalized() - } -} - -extension String { - fileprivate func localizedFirstWordCapitalized() -> String { prefix(1).localizedCapitalized + dropFirst() } -} diff --git a/Sources/PackageModelSyntax/ProductDescription+Syntax.swift b/Sources/PackageModelSyntax/ProductDescription+Syntax.swift deleted file mode 100644 index 614d5db8bf4..00000000000 --- a/Sources/PackageModelSyntax/ProductDescription+Syntax.swift +++ /dev/null @@ -1,62 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 SwiftSyntaxBuilder -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/TargetDescription+Syntax.swift b/Sources/PackageModelSyntax/TargetDescription+Syntax.swift deleted file mode 100644 index eed59e5fcae..00000000000 --- a/Sources/PackageModelSyntax/TargetDescription+Syntax.swift +++ /dev/null @@ -1,99 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 SwiftSyntaxBuilder -import SwiftParser - -extension TargetDescription: ManifestSyntaxRepresentable { - /// The function name in the package manifest. - private var functionName: String { - switch type { - case .binary: "binaryTarget" - case .executable: "executableTarget" - case .macro: "macro" - case .plugin: "plugin" - case .regular: "target" - case .system: "systemLibrary" - case .test: "testTarget" - } - } - - func asSyntax() -> ExprSyntax { - var arguments: [LabeledExprSyntax] = [] - arguments.append(label: "name", stringLiteral: name) - // FIXME: pluginCapability - - arguments.appendIfNonEmpty( - label: "dependencies", - arrayLiteral: dependencies - ) - - arguments.appendIf(label: "path", stringLiteral: path) - arguments.appendIf(label: "url", stringLiteral: url) - arguments.appendIfNonEmpty(label: "exclude", arrayLiteral: exclude) - arguments.appendIf(label: "sources", arrayLiteral: sources) - - // FIXME: resources - - arguments.appendIf( - label: "publicHeadersPath", - stringLiteral: publicHeadersPath - ) - - if !packageAccess { - arguments.append( - label: "packageAccess", - expression: "false" - ) - } - - // FIXME: cSettings - // FIXME: cxxSettings - // FIXME: swiftSettings - // FIXME: linkerSettings - // FIXME: plugins - - arguments.appendIf(label: "pkgConfig", stringLiteral: pkgConfig) - // FIXME: providers - - // Only for plugins - arguments.appendIf(label: "checksum", stringLiteral: checksum) - - let separateParen: String = arguments.count > 1 ? "\n" : "" - let argumentsSyntax = LabeledExprListSyntax(arguments) - return ".\(raw: functionName)(\(argumentsSyntax)\(raw: separateParen))" - } -} - -extension TargetDescription.Dependency: ManifestSyntaxRepresentable { - func asSyntax() -> ExprSyntax { - switch self { - case .byName(name: let name, condition: nil): - "\(literal: name)" - - case .target(name: let name, condition: nil): - ".target(name: \(literal: name))" - - case .product(name: let name, package: nil, moduleAliases: nil, condition: nil): - ".product(name: \(literal: name))" - - case .product(name: let name, package: let package, moduleAliases: nil, condition: nil): - ".product(name: \(literal: name), package: \(literal: package))" - - default: - fatalError() - } - } -} diff --git a/Tests/FunctionalTests/PluginTests.swift b/Tests/FunctionalTests/PluginTests.swift index 793ff457beb..03f4af842b8 100644 --- a/Tests/FunctionalTests/PluginTests.swift +++ b/Tests/FunctionalTests/PluginTests.swift @@ -518,10 +518,6 @@ final class PluginTests { .disabled() ) func testCommandPluginInvocation() async throws { - try XCTSkipIf(true, "test is disabled because it isn't stable, see rdar://117870608") - - // Only run the test if the environment in which we're running actually supports Swift concurrency (which the plugin APIs require). - try XCTSkipIf(!UserToolchain.default.supportsSwiftConcurrency(), "skipping because test environment doesn't support concurrency") // FIXME: This test is getting quite long — we should add some support functionality for creating synthetic plugin tests and factor this out into separate tests. try await testWithTemporaryDirectory { tmpPath in // Create a sample package with a library target and a plugin. It depends on a sample package. From c3003f5ca23a42fb4028b5111ec0a7ba90ca1dd1 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 2 Oct 2025 01:21:20 -0400 Subject: [PATCH 223/225] removed source conflicts in fixtures --- Fixtures/Miscellaneous/InferPackageType/Package.swift | 4 ---- .../InitTemplates/ExecutableTemplate/Package.swift | 4 ---- Fixtures/Miscellaneous/ShowExecutables/app/Package.swift | 4 ---- Fixtures/Miscellaneous/ShowTemplates/app/Package.swift | 4 ---- 4 files changed, 16 deletions(-) diff --git a/Fixtures/Miscellaneous/InferPackageType/Package.swift b/Fixtures/Miscellaneous/InferPackageType/Package.swift index c8ddc44a4ba..47f8992e439 100644 --- a/Fixtures/Miscellaneous/InferPackageType/Package.swift +++ b/Fixtures/Miscellaneous/InferPackageType/Package.swift @@ -1,8 +1,4 @@ -<<<<<<< HEAD -// swift-tools-version:999.0.0 -======= // swift-tools-version: 6.3.0 ->>>>>>> inbetween import PackageDescription let initialLibrary: [Target] = .template( diff --git a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift index 48960f3ff9a..a2983f0ae0e 100644 --- a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift +++ b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift @@ -1,8 +1,4 @@ -<<<<<<< HEAD -// swift-tools-version:999.0.0 -======= // swift-tools-version:6.3.0 ->>>>>>> inbetween import PackageDescription diff --git a/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift b/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift index 502cd61dcaa..7945bacab8a 100644 --- a/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift @@ -1,8 +1,4 @@ -<<<<<<< HEAD -// swift-tools-version:999.0 -======= // swift-tools-version:6.3.0 ->>>>>>> inbetween import PackageDescription let package = Package( diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift index 1bc6cd1185b..c3059b14209 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -1,8 +1,4 @@ -<<<<<<< HEAD -// swift-tools-version:999.0.0 -======= // swift-tools-version:6.3.0 ->>>>>>> inbetween import PackageDescription let package = Package( From 94dc8d012ddeb27a4dfc6e56e770e60261abbf31 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 2 Oct 2025 01:33:08 -0400 Subject: [PATCH 224/225] formatting --- Examples/init-templates/Package.swift | 28 +- .../PartsServicePlugin.swift | 2 +- .../ServerTemplatePlugin.swift | 9 +- .../Template1Plugin/Template1Plugin.swift | 6 +- .../Template2Plugin/Template2Plugin.swift | 11 +- .../Templates/PartsService/main.swift | 114 +++-- .../Templates/ServerTemplate/main.swift | 477 +++++++++--------- .../Templates/Template1/Template.swift | 32 +- .../Templates/Template2/Template.swift | 113 ++--- .../Tests/PartsServiceTests.swift | 38 +- .../ServerTemplateTests.swift | 20 +- .../init-templates/Tests/TemplateTest.swift | 40 +- .../generated-package/Package.swift | 3 +- .../InferPackageType/Package.swift | 82 ++- .../main.swift | 1 - .../initialTypeCommandPluginPlugin/main.swift | 1 - .../Plugins/initialTypeEmptyPlugin/main.swift | 1 - .../initialTypeExecutablePlugin/main.swift | 1 - .../initialTypeLibraryPlugin/main.swift | 1 - .../Plugins/initialTypeMacroPlugin/main.swift | 1 - .../Plugins/initialTypeToolPlugin/main.swift | 1 - .../ExecutableTemplate/Package.swift | 5 +- .../ExecutableTemplatePlugin.swift | 6 +- .../ExecutableTemplate/Template.swift | 32 +- .../deck-of-playing-cards/Package.swift | 2 +- .../ShowTemplates/app/Package.swift | 7 +- .../GenerateFromTemplate/Template.swift | 24 +- Sources/Commands/PackageCommands/Init.swift | 41 +- .../TestCommands/TestTemplateCommand.swift | 32 +- .../PackageInitializer.swift | 11 +- .../TemplatePathResolver.swift | 10 +- .../TemplatePluginManager.swift | 3 +- .../TemplateTesterManager.swift | 44 +- .../InitTemplatePackage.swift | 180 ++++--- .../TemplateDirectoryManager.swift | 14 +- Tests/CommandsTests/TemplateTests.swift | 18 +- 36 files changed, 704 insertions(+), 707 deletions(-) diff --git a/Examples/init-templates/Package.swift b/Examples/init-templates/Package.swift index d58da541839..7daaaf380b6 100644 --- a/Examples/init-templates/Package.swift +++ b/Examples/init-templates/Package.swift @@ -1,7 +1,6 @@ // swift-tools-version:6.3.0 import PackageDescription - let testTargets: [Target] = [.testTarget( name: "ServerTemplateTests", dependencies: [ @@ -9,14 +8,13 @@ let testTargets: [Target] = [.testTarget( ] )] - let package = Package( name: "SimpleTemplateExample", products: - .template(name: "PartsService") + - .template(name: "Template1") + - .template(name: "Template2") + - .template(name: "ServerTemplate"), + .template(name: "PartsService") + + .template(name: "Template1") + + .template(name: "Template2") + + .template(name: "ServerTemplate"), dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser", branch: "main"), .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2"), @@ -28,37 +26,37 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "SystemPackage", package: "swift-system"), ], - + initialPackageType: .executable, description: "This template generates a simple parts management service using Hummingbird, and Fluent!" - + ) + .template( name: "Template1", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "SystemPackage", package: "swift-system"), ], - + initialPackageType: .executable, templatePermissions: [ - .allowNetworkConnections(scope: .none, reason: "Need network access to help generate a template") + .allowNetworkConnections(scope: .none, reason: "Need network access to help generate a template"), ], description: "This is a simple template that uses Swift string interpolation." - + ) + .template( name: "Template2", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "SystemPackage", package: "swift-system"), - .product(name: "Stencil", package: "Stencil") - + .product(name: "Stencil", package: "Stencil"), + ], resources: [ - .process("StencilTemplates") + .process("StencilTemplates"), ], initialPackageType: .executable, description: "This is a template that uses Stencil templating." - + ) + .template( name: "ServerTemplate", dependencies: [ diff --git a/Examples/init-templates/Plugins/PartsServicePlugin/PartsServicePlugin.swift b/Examples/init-templates/Plugins/PartsServicePlugin/PartsServicePlugin.swift index 88e85839309..75cbda45f14 100644 --- a/Examples/init-templates/Plugins/PartsServicePlugin/PartsServicePlugin.swift +++ b/Examples/init-templates/Plugins/PartsServicePlugin/PartsServicePlugin.swift @@ -17,7 +17,7 @@ struct PartsServiceTemplatePlugin: CommandPlugin { process.executableURL = URL(filePath: tool.url.path()) process.arguments = ["--pkg-dir", packageDirectory, "--name", packageName] + arguments.filter { $0 != "--" } - + try process.run() process.waitUntilExit() } diff --git a/Examples/init-templates/Plugins/ServerTemplatePlugin/ServerTemplatePlugin.swift b/Examples/init-templates/Plugins/ServerTemplatePlugin/ServerTemplatePlugin.swift index 5730b3ab2d5..64387ae4a97 100644 --- a/Examples/init-templates/Plugins/ServerTemplatePlugin/ServerTemplatePlugin.swift +++ b/Examples/init-templates/Plugins/ServerTemplatePlugin/ServerTemplatePlugin.swift @@ -22,7 +22,7 @@ struct ServerTemplatePlugin: CommandPlugin { try process.run() process.waitUntilExit() - + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() let stderrOutput = String(data: stderrData, encoding: .utf8) ?? "" @@ -30,17 +30,17 @@ struct ServerTemplatePlugin: CommandPlugin { throw PluginError.executionFailed(code: process.terminationStatus, stderrOutput: stderrOutput) } } - + enum PluginError: Error, CustomStringConvertible { case executionFailed(code: Int32, stderrOutput: String) var description: String { switch self { case .executionFailed(let code, let stderrOutput): - return """ + """ Plugin subprocess failed with exit code \(code). - + Output: \(stderrOutput) @@ -48,5 +48,4 @@ struct ServerTemplatePlugin: CommandPlugin { } } } - } diff --git a/Examples/init-templates/Plugins/Template1Plugin/Template1Plugin.swift b/Examples/init-templates/Plugins/Template1Plugin/Template1Plugin.swift index bf90f6127a3..ded60788c5d 100644 --- a/Examples/init-templates/Plugins/Template1Plugin/Template1Plugin.swift +++ b/Examples/init-templates/Plugins/Template1Plugin/Template1Plugin.swift @@ -32,18 +32,18 @@ struct TemplatePlugin: CommandPlugin { if process.terminationStatus != 0 { throw PluginError.executionFailed(code: process.terminationStatus, stderrOutput: stderrOutput) } - } + enum PluginError: Error, CustomStringConvertible { case executionFailed(code: Int32, stderrOutput: String) var description: String { switch self { case .executionFailed(let code, let stderrOutput): - return """ + """ Plugin subprocess failed with exit code \(code). - + Output: \(stderrOutput) diff --git a/Examples/init-templates/Plugins/Template2Plugin/Template2Plugin.swift b/Examples/init-templates/Plugins/Template2Plugin/Template2Plugin.swift index 9cdddda7850..be69323e597 100644 --- a/Examples/init-templates/Plugins/Template2Plugin/Template2Plugin.swift +++ b/Examples/init-templates/Plugins/Template2Plugin/Template2Plugin.swift @@ -1,5 +1,5 @@ // -// Untitled.swift +// Template2Plugin.swift // TemplateWorkflow // // Created by John Bute on 2025-04-23. @@ -21,15 +21,14 @@ struct DeclarativeTemplatePlugin: CommandPlugin { func performCommand( context: PluginContext, arguments: [String] - ) async throws{ + ) async throws { let tool = try context.tool(named: "Template2") let process = Process() - + process.executableURL = URL(filePath: tool.url.path()) - process.arguments = arguments.filter({$0 != "--"}) - + process.arguments = arguments.filter { $0 != "--" } + try process.run() process.waitUntilExit() } - } diff --git a/Examples/init-templates/Templates/PartsService/main.swift b/Examples/init-templates/Templates/PartsService/main.swift index d69e3471fbf..4a58c0d164b 100644 --- a/Examples/init-templates/Templates/PartsService/main.swift +++ b/Examples/init-templates/Templates/PartsService/main.swift @@ -1,8 +1,8 @@ import ArgumentParser -import SystemPackage import Foundation +import SystemPackage -struct fs { +enum fs { static var shared: FileManager { FileManager.default } } @@ -32,7 +32,10 @@ extension String { } func indenting(_ level: Int) -> String { - self.split(separator: "\n", omittingEmptySubsequences: false).joined(separator: "\n" + String(repeating: " ", count: level)) + self.split(separator: "\n", omittingEmptySubsequences: false).joined(separator: "\n" + String( + repeating: " ", + count: level + )) } } @@ -122,9 +125,9 @@ enum Database: String, ExpressibleByArgument, CaseIterable { func packageSwift(db: Database, name: String) -> String { """ // swift-tools-version: 6.1 - + import PackageDescription - + let package = Package( name: "part-service", platforms: [ @@ -161,29 +164,29 @@ func packageSwift(db: Database, name: String) -> String { func genReadme(db: Database) -> String { """ # Parts Management - + Manage your parts using the power of Swift, Hummingbird, and Fluent! - + \(db.taskListItem) [x] - Add a Hummingbird app server, router, and endpoint for parts (`Sources/App/main.swift`) [x] - Create a model for part (`Sources/Models/Part.swift`) - + ## Getting Started - + Create the part database if you haven't already done so. - + ``` ./Scripts/create-db.sh ``` - + Start the application. - + ``` swift run ``` - + Curl the parts endpoint to see the list of parts: - + ``` curl http://127.0.0.1:8080/parts ``` @@ -194,88 +197,90 @@ func appServer(db: Database, migration: Bool) -> String { """ import ArgumentParser import Hummingbird - \( db == .sqlite3 ? + \(db == .sqlite3 ? "import FluentSQLiteDriver" : "import FluentPostgresDriver" ) import HummingbirdFluent import Models - \( migration ? - """ - // An example migration. - struct CreatePartMigration: Migration { - func prepare(on database: Database) -> EventLoopFuture { - fatalError("Implement part migration prepare") - } + \(migration ? + """ + // An example migration. + struct CreatePartMigration: Migration { + func prepare(on database: Database) -> EventLoopFuture { + fatalError("Implement part migration prepare") + } - func revert(on database: Database) -> EventLoopFuture { - fatalError("Implement part migration revert") + func revert(on database: Database) -> EventLoopFuture { + fatalError("Implement part migration revert") + } } - } - """: "" ) - + """ : "" + ) + @main struct PartServiceGenerator: AsyncParsableCommand { - \( migration ? "@Flag var migrate: Bool = false" : "" ) + \(migration ? "@Flag var migrate: Bool = false" : "") mutating func run() async throws { var logger = Logger(label: "PartService") logger.logLevel = .debug let fluent = Fluent(logger: logger) - + \(db.appServerUse) - \( migration ? - """ - await fluent.migrations.add(CreatePartMigration()) + \(migration ? + """ + await fluent.migrations.add(CreatePartMigration()) + + // migrate + if self.migrate { + try await fluent.migrate() + } + """.indenting(2) : "" + ) - // migrate - if self.migrate { - try await fluent.migrate() - } - """.indenting(2) : "" ) - // create router and add a single GET /parts route let router = Router() router.get("parts") { request, _ -> [Part] in return try await Part.query(on: fluent.db()).all() } - + // create application using router let app = Application( router: router, configuration: .init(address: .hostname("127.0.0.1", port: 8080)) ) - + // run hummingbird application try await app.runService() } } - """ + """ } func partModel(db: Database) -> String { """ - \( db == .sqlite3 ? + \(db == .sqlite3 ? "import FluentSQLiteDriver" : "import FluentPostgresDriver" ) - + public final class Part: Model, @unchecked Sendable { // Name of the table or collection. public static let schema = "part" - + // Unique identifier for this Part. @ID(key: .id) public var id: UUID? - + // The Part's description. @Field(key: "description") public var description: String - + // Creates a new, empty Part. public init() { } - + // Creates a new Part with all properties set. public init(id: UUID? = nil, description: String) { self.id = id @@ -288,14 +293,14 @@ func partModel(db: Database) -> String { func createDbScript(db: Database) -> String { """ #!/bin/bash - + \(db.commandLineCreate) """ } @main struct PartServiceGenerator: ParsableCommand { - public static let configuration = CommandConfiguration( + static let configuration = CommandConfiguration( abstract: "This template gets you started with a service to track your parts with app server and database." ) @@ -310,7 +315,7 @@ struct PartServiceGenerator: ParsableCommand { @Flag(help: "Add a starting database migration routine.") var migration: Bool = false - + @Option(help: .init(visibility: .hidden)) var name: String = "App" @@ -328,13 +333,14 @@ struct PartServiceGenerator: ParsableCommand { // Start from scratch with the Package.swift try? fs.shared.rm(atPath: pkgDir / "Package.swift") - try packageSwift(db: self.database, name: name).write(toFile: pkgDir / "Package.swift") + try packageSwift(db: self.database, name: self.name).write(toFile: pkgDir / "Package.swift") if self.readme { try genReadme(db: self.database).write(toFile: pkgDir / "README.md") } - - try? fs.shared.rm(atPath: pkgDir / "Sources/\(name)") - try appServer(db: self.database, migration: self.migration).write(toFile: pkgDir / "Sources/\(name)/main.swift") + + try? fs.shared.rm(atPath: pkgDir / "Sources/\(self.name)") + try appServer(db: self.database, migration: self.migration) + .write(toFile: pkgDir / "Sources/\(self.name)/main.swift") try partModel(db: self.database).write(toFile: pkgDir / "Sources/Models/Part.swift") let script = pkgDir / "Scripts/create-db.sh" diff --git a/Examples/init-templates/Templates/ServerTemplate/main.swift b/Examples/init-templates/Templates/ServerTemplate/main.swift index 99f9a3d3526..57fa8bb5d31 100644 --- a/Examples/init-templates/Templates/ServerTemplate/main.swift +++ b/Examples/init-templates/Templates/ServerTemplate/main.swift @@ -1,18 +1,22 @@ import ArgumentParser -import SystemPackage import Foundation +import SystemPackage -struct fs { +enum fs { static var shared: FileManager { FileManager.default } } + extension FileManager { func rm(atPath path: FilePath) throws { try self.removeItem(atPath: path.string) } - + func csl(atPath linkPath: FilePath, pointTo relativeTarget: FilePath) throws { let linkURL = URL(fileURLWithPath: linkPath.string) - let destinationURL = URL(fileURLWithPath: relativeTarget.string, relativeTo: linkURL.deletingLastPathComponent()) + let destinationURL = URL( + fileURLWithPath: relativeTarget.string, + relativeTo: linkURL.deletingLastPathComponent() + ) try self.createSymbolicLink(at: linkURL, withDestinationURL: destinationURL) } } @@ -39,8 +43,9 @@ extension URL { var index = 0 while index < targetComponents.count && - index < baseComponents.count && - targetComponents[index] == baseComponents[index] { + index < baseComponents.count && + targetComponents[index] == baseComponents[index] + { index += 1 } @@ -51,7 +56,6 @@ extension URL { } } - extension String { func write(toFile: FilePath) throws { // Create the directory if it doesn't yet exist @@ -66,7 +70,10 @@ extension String { } func indenting(_ level: Int) -> String { - self.split(separator: "\n", omittingEmptySubsequences: false).joined(separator: "\n" + String(repeating: " ", count: level)) + self.split(separator: "\n", omittingEmptySubsequences: false).joined(separator: "\n" + String( + repeating: " ", + count: level + )) } } @@ -86,17 +93,17 @@ extension Data { enum ServerType: String, ExpressibleByArgument, CaseIterable { case crud, bare - + var description: String { switch self { case .crud: - return "CRUD" + "CRUD" case .bare: - return "Bare" + "Bare" } } - //Package.swift manifest file writing + // Package.swift manifest file writing var packageDep: String { switch self { case .crud: @@ -107,7 +114,7 @@ enum ServerType: String, ExpressibleByArgument, CaseIterable { .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.0.0"), .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.0.0"), .package(url: "https://github.com/swift-server/swift-openapi-vapor", from: "1.0.0"), - + // Telemetry .package(url: "https://github.com/apple/swift-log", .upToNextMajor(from: "1.5.2")), .package(url: "https://github.pie.apple.com/swift-server/swift-logback", from: "2.3.1"), @@ -115,14 +122,14 @@ enum ServerType: String, ExpressibleByArgument, CaseIterable { .package(url: "https://github.com/swift-server/swift-prometheus", from: "2.1.0"), .package(url: "https://github.com/apple/swift-distributed-tracing", from: "1.2.0"), .package(url: "https://github.com/swift-otel/swift-otel", .upToNextMinor(from: "0.11.0")), - + // Database .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"), .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"), - + // HTTP client .package(url: "https://github.com/swift-server/async-http-client", from: "1.25.0"), - + """ case .bare: """ @@ -131,17 +138,16 @@ enum ServerType: String, ExpressibleByArgument, CaseIterable { """ } } - + var targetName: String { switch self { case .bare: "BareHTTPServer" case .crud: "CRUDHTTPServer" - } } - + var platform: String { switch self { case .bare: @@ -184,7 +190,7 @@ enum ServerType: String, ExpressibleByArgument, CaseIterable { """ } } - + var plugin: String { switch self { case .bare: @@ -195,13 +201,11 @@ enum ServerType: String, ExpressibleByArgument, CaseIterable { .plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator") ] """ - } } - - - //Readme items - + + // Readme items + var features: String { switch self { case .bare: @@ -218,11 +222,10 @@ enum ServerType: String, ExpressibleByArgument, CaseIterable { - PostgreSQL database - HTTP client for making upstream calls """ - } } - - var callingLocally : String { + + var callingLocally: String { switch self { case .bare: """ @@ -231,13 +234,13 @@ enum ServerType: String, ExpressibleByArgument, CaseIterable { case .crud: """ ### Health check - + ```sh curl -f http://localhost:8080/health ``` - + ### Create a TODO - + ```sh curl -X POST http://localhost:8080/api/todos --json '{"contents":"Smile more :)"}' { @@ -245,9 +248,9 @@ enum ServerType: String, ExpressibleByArgument, CaseIterable { "id" : "066E0A57-B67B-4C41-9DFF-99C738664EBD" } ``` - + ### List TODOs - + ```sh curl -X GET http://localhost:8080/api/todos { @@ -259,9 +262,9 @@ enum ServerType: String, ExpressibleByArgument, CaseIterable { ] } ``` - + ### Get a single TODO - + ```sh curl -X GET http://localhost:8080/api/todos/066E0A57-B67B-4C41-9DFF-99C738664EBD { @@ -269,72 +272,72 @@ enum ServerType: String, ExpressibleByArgument, CaseIterable { "id" : "A8E02E7C-1451-4CF9-B5C5-A33E92417454" } ``` - + ### Delete a TODO - + ```sh curl -X DELETE http://localhost:8080/api/todos/066E0A57-B67B-4C41-9DFF-99C738664EBD ``` - + ### Triggering a synthetic crash - + For easier testing of crash log uploading behavior, this template server also includes an operation for intentionally crashing the server. - + > Warning: Consider removing this endpoint or guarding it with admin auth before deploying to production. - + ```sh curl -f -X POST http://localhost:8080/api/crash ``` - + The JSON crash log then appears in the `/logs` directory in the container. - + ## Viewing the API docs - + Run: `open http://localhost:8080/openapi.html`, from where you can make test HTTP requests to the local server. - + ## Viewing telemetry - + Run (and leave running) `docker-compose -f Deploy/Local/docker-compose.yaml up`, and make a few test requests in a separate Terminal window. - + Afterwards, this is how you can view the emitted logs, metrics, and traces. - + ### Logs - + If running from `docker-compose`: - + ```sh docker exec local-crud-1 tail -f /tmp/crud_server.log ``` - + If running in VS Code/Xcode, logs will be emitted in the IDE's console. - + ### Metrics - + Run: - + ```sh open http://localhost:9090/graph?g0.expr=http_requests_total&g0.tab=1&g0.display_mode=lines&g0.show_exemplars=0&g0.range_input=1h ``` - + to see the `http_requests_total` metric counts. - + ### Traces - + Run: - + ```sh open http://localhost:16686/search?limit=20&lookback=1h&service=CRUDHTTPServer ``` - + to see traces, which you can click on to reveal the individual spans with attributes. - + ## Configuration - + The service is configured using the following environment variables, all of which are optional with defaults. - + Some of these values are overriden in `docker-compose.yaml` for running locally, but if you're deploying in a production environment, you'd want to customize them further for easier operations. - + - `SERVER_ADDRESS` (default: `"0.0.0.0"`): The local address the server listens on. - `SERVER_PORT` (default: `8080`): The local post the server listens on. - `LOG_FORMAT` (default: `json`, possible values: `json`, `keyValue`): The output log format used for both file and console logging. @@ -351,22 +354,19 @@ enum ServerType: String, ExpressibleByArgument, CaseIterable { """ } } - + var deployToKube: String { switch self { case .crud: "" case .bare: - """ ## Deploying to Kube - + Check out [`Deploy/Kube`](Deploy/Kube) for instructions on deploying to Apple Kube. - + """ - } - } } @@ -374,9 +374,9 @@ func packageSwift(serverType: ServerType) -> String { """ // swift-tools-version: 6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. - + import PackageDescription - + let package = Package( name: "\(serverType.targetName.indenting(1))", platforms: [ @@ -393,7 +393,7 @@ func packageSwift(serverType: ServerType) -> String { ], path: "Sources", \(serverType.plugin.indenting(3)) - + ), ] ) @@ -404,13 +404,13 @@ func genRioTemplatePkl(serverType: ServerType) -> String { """ /// For more information on how to configure this module, visit: \(serverType == .crud ? - """ - /// https://pkl.apple.com/apple-package-docs/artifacts.apple.com/pkl/pkl/rio/1.3.3/Rio/index.html - /// https://pkl.apple.com/apple-package-docs/artifacts.apple.com/pkl/pkl/rio/current/Rio/index.html#_overview - """ : - """ - /// - """ + """ + /// https://pkl.apple.com/apple-package-docs/artifacts.apple.com/pkl/pkl/rio/1.3.3/Rio/index.html + /// https://pkl.apple.com/apple-package-docs/artifacts.apple.com/pkl/pkl/rio/current/Rio/index.html#_overview + """ : + """ + /// + """ ) @ModuleInfo { minPklVersion = "0.24.0" } amends "package://artifacts.apple.com/pkl/pkl/rio@1.3.1#/Rio.pkl" @@ -489,7 +489,7 @@ func genRioTemplatePkl(serverType: ServerType) -> String { } \(serverType == .crud ? """ - + new { group = "validate-openapi" branchRules { @@ -510,7 +510,8 @@ func genRioTemplatePkl(serverType: ServerType) -> String { } } } - """ : "}") + """ : "}" + ) notify { pullRequestComment { @@ -521,11 +522,10 @@ func genRioTemplatePkl(serverType: ServerType) -> String { enabled = true } } - """ + """ } func genDockerFile(serverType: ServerType) -> String { - """ ARG SWIFT_VERSION=6.1 ARG UBI_VERSION=9 @@ -565,12 +565,13 @@ func genDockerFile(serverType: ServerType) -> String { """ } + func genReadMe(serverType: ServerType) -> String { """ # \(serverType.targetName.uppercased()) - + A simple starter project for a server with the following features: - + \(serverType.features) ## Configuration/secrets @@ -601,12 +602,12 @@ func genReadMe(serverType: ServerType) -> String { This sample project comes with a `rio.template.pkl`, where you can just update the docker.apple.com repository you'd like to publish your service to, and rename the file to `rio.pkl` - and be ready to go to onboard to Rio. - + \(serverType.deployToKube) """ } -func genDockerCompose(server:ServerType) -> String { +func genDockerCompose(server: ServerType) -> String { switch server { case .bare: """ @@ -896,9 +897,7 @@ func writeHelloWorld() -> String { """ } - enum CrudServerFiles { - static func genTelemetryFile(logLevel: LogLevel, logPath: URL, logFormat: LogFormat, logBufferSize: Int) -> String { """ import ServiceLifecycle @@ -1044,7 +1043,7 @@ enum CrudServerFiles { extension Logger { @TaskLocal static var _current: Logger? - + static var current: Logger { get throws { guard let _current else { @@ -1066,6 +1065,7 @@ enum CrudServerFiles { """ } + static func getServerService() -> String { """ import Vapor @@ -1119,7 +1119,7 @@ enum CrudServerFiles { } """ } - + static func getOpenAPIConfig() -> String { """ generate: @@ -1129,87 +1129,86 @@ enum CrudServerFiles { """ } - + static func genAPIHandler() -> String { """ - import OpenAPIRuntime - import HTTPTypes - import Fluent - import Foundation - - /// The implementation of the API described by the OpenAPI document. - /// - /// To make changes, add a new operation in the openapi.yaml file, then rebuild - /// and add the suggested corresponding method in this type. - struct APIHandler: APIProtocol { - - var db: Database - - func listTODOs( - _ input: Operations.ListTODOs.Input - ) async throws -> Operations.ListTODOs.Output { - let dbTodos = try await db.query(DB.TODO.self).all() - let apiTodos = try dbTodos.map { todo in - Components.Schemas.TODODetail( - id: try todo.requireID(), - contents: todo.contents - ) + import OpenAPIRuntime + import HTTPTypes + import Fluent + import Foundation + + /// The implementation of the API described by the OpenAPI document. + /// + /// To make changes, add a new operation in the openapi.yaml file, then rebuild + /// and add the suggested corresponding method in this type. + struct APIHandler: APIProtocol { + + var db: Database + + func listTODOs( + _ input: Operations.ListTODOs.Input + ) async throws -> Operations.ListTODOs.Output { + let dbTodos = try await db.query(DB.TODO.self).all() + let apiTodos = try dbTodos.map { todo in + Components.Schemas.TODODetail( + id: try todo.requireID(), + contents: todo.contents + ) + } + return .ok(.init(body: .json(.init(items: apiTodos)))) } - return .ok(.init(body: .json(.init(items: apiTodos)))) - } - - func createTODO( - _ input: Operations.CreateTODO.Input - ) async throws -> Operations.CreateTODO.Output { - switch input.body { - case .json(let todo): - let newId = UUID().uuidString - let contents = todo.contents - let dbTodo = DB.TODO() - dbTodo.id = newId - dbTodo.contents = contents - try await dbTodo.save(on: db) - return .created(.init(body: .json(.init( - id: newId, - contents: contents + + func createTODO( + _ input: Operations.CreateTODO.Input + ) async throws -> Operations.CreateTODO.Output { + switch input.body { + case .json(let todo): + let newId = UUID().uuidString + let contents = todo.contents + let dbTodo = DB.TODO() + dbTodo.id = newId + dbTodo.contents = contents + try await dbTodo.save(on: db) + return .created(.init(body: .json(.init( + id: newId, + contents: contents + )))) + } + } + + func getTODODetail( + _ input: Operations.GetTODODetail.Input + ) async throws -> Operations.GetTODODetail.Output { + let id = input.path.todoId + guard let foundTodo = try await DB.TODO.find(id, on: db) else { + return .notFound + } + return .ok(.init(body: .json(.init( + id: id, + contents: foundTodo.contents )))) } - } - - func getTODODetail( - _ input: Operations.GetTODODetail.Input - ) async throws -> Operations.GetTODODetail.Output { - let id = input.path.todoId - guard let foundTodo = try await DB.TODO.find(id, on: db) else { - return .notFound + + func deleteTODO( + _ input: Operations.DeleteTODO.Input + ) async throws -> Operations.DeleteTODO.Output { + try await db.query(DB.TODO.self).filter(\\.$id == input.path.todoId).delete() + return .noContent(.init()) + } + + // Warning: Remove this endpoint in production, or guard it by admin auth. + // It's here for easy testing of crash log uploading. + func crash(_ input: Operations.Crash.Input) async throws -> Operations.Crash.Output { + // Trigger a fatal error for crash testing + fatalError("Crash endpoint triggered for testing purposes - this is intentional crash handling behavior") } - return .ok(.init(body: .json(.init( - id: id, - contents: foundTodo.contents - )))) - } - - func deleteTODO( - _ input: Operations.DeleteTODO.Input - ) async throws -> Operations.DeleteTODO.Output { - try await db.query(DB.TODO.self).filter(\\.$id == input.path.todoId).delete() - return .noContent(.init()) - } - - // Warning: Remove this endpoint in production, or guard it by admin auth. - // It's here for easy testing of crash log uploading. - func crash(_ input: Operations.Crash.Input) async throws -> Operations.Crash.Output { - // Trigger a fatal error for crash testing - fatalError("Crash endpoint triggered for testing purposes - this is intentional crash handling behavior") } - } - """ - + """ } - + static func genEntryPointFile( - serverAddress: String, - serverPort: Int + serverAddress: String, + serverPort: Int ) -> String { """ import Vapor @@ -1266,38 +1265,35 @@ enum CrudServerFiles { """ } - } enum DatabaseFile { - static func genDatabaseFileWithMTLS( mtlsPath: URL, mtlsKeyPath: URL, mtlsAdditionalTrustRoots: [URL], postgresURL: URL ) -> String { - func escape(_ string: String) -> String { - return string + string .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") } - + let postgresURLString = escape(postgresURL.absoluteString) let certPathString = escape(mtlsPath.path) let keyPathString = escape(mtlsKeyPath.path) let trustRootsStrings = mtlsAdditionalTrustRoots .map { "\"\(escape($0.path))\"" } .joined(separator: ", ") - + return """ import FluentPostgresDriver import PostgresKit import Fluent import Vapor import Foundation - + func configureDatabase(app: Application) async throws { let postgresURL = URL(string:"\(postgresURLString)")! var postgresConfiguration = try SQLPostgresConfiguration(url: postgresURL) @@ -1392,15 +1388,13 @@ enum DatabaseFile { """ } - static func genDatabaseFileWithoutMTLS(postgresURL: URL) -> String { - func escape(_ string: String) -> String { - return string + string .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") } - + let postgresURLString = escape(postgresURL.absoluteString) return """ @@ -1409,7 +1403,7 @@ enum DatabaseFile { import Fluent import Vapor import Foundation - + func configureDatabase(app: Application) async throws { let postgresURL = "\(postgresURLString)" var postgresConfiguration = try SQLPostgresConfiguration(url: postgresURL) @@ -1424,19 +1418,19 @@ enum DatabaseFile { throw error } } - + enum DB { final class TODO: Model, @unchecked Sendable { static let schema = "todos" - + @ID(custom: "id", generatedBy: .user) var id: String? - + @Field(key: "contents") var contents: String } } - + enum Migrations { struct CreateTODOs: AsyncMigration { func prepare(on database: Database) async throws { @@ -1445,7 +1439,7 @@ enum DatabaseFile { .field("contents", .string, .required) .create() } - + func revert(on database: Database) async throws { try await database .schema(DB.TODO.schema) @@ -1455,13 +1449,12 @@ enum DatabaseFile { } """ } - } enum BareServerFiles { static func genEntryPointFile( - serverAddress: String, - serverPort: Int + serverAddress: String, + serverPort: Int ) -> String { """ import Vapor @@ -1483,7 +1476,7 @@ enum BareServerFiles { """ } - + static func genServerFile() -> String { """ import Vapor @@ -1499,16 +1492,14 @@ enum BareServerFiles { } } - - @main struct ServerGenerator: ParsableCommand { - public static let configuration = CommandConfiguration( + static let configuration = CommandConfiguration( commandName: "server-generator", abstract: "This template gets you started with starting to experiment with servers in swift.", subcommands: [ CRUD.self, - Bare.self + Bare.self, ], ) @@ -1526,6 +1517,7 @@ struct ServerGenerator: ParsableCommand { } // MARK: - CRUD Command + public struct CRUD: ParsableCommand { public static let configuration = CommandConfiguration( commandName: "crud", @@ -1541,7 +1533,6 @@ public struct CRUD: ParsableCommand { @Option(help: "Set the logging format.") var logFormat: LogFormat = .json - @Option(help: "Set the logging file path.") var logPath: String = "/tmp/crud_server.log" @@ -1550,12 +1541,10 @@ public struct CRUD: ParsableCommand { @OptionGroup var serverOptions: SharedOptionsServers - - + public init() {} - mutating public func run() throws { - - try serverGenerator.run() + public mutating func run() throws { + try self.serverGenerator.run() guard let pkgDir = self.serverGenerator.packageOptions.pkgDir else { throw ValidationError("No --pkg-dir was provided.") @@ -1563,9 +1552,8 @@ public struct CRUD: ParsableCommand { let packageDir = FilePath(pkgDir) - guard let url = URL(string: logPath) else { - throw ValidationError("Invalid log path: \(logPath)") + throw ValidationError("Invalid log path: \(self.logPath)") } let logURLPath = CLIURL(url) @@ -1576,28 +1564,39 @@ public struct CRUD: ParsableCommand { // Create base package try packageSwift(serverType: .crud).write(toFile: packageDir / "Package.swift") - if serverOptions.readMe.readMe { + if self.serverOptions.readMe.readMe { try genReadMe(serverType: .crud).write(toFile: packageDir / "README.md") } try genRioTemplatePkl(serverType: .crud).write(toFile: packageDir / "rio.template.pkl") try genDockerFile(serverType: .crud).write(toFile: packageDir / "Dockerfile.txt") - //Create files for local folder - + // Create files for local folder + try genDockerCompose(server: .crud).write(toFile: packageDir / "Deploy/Local/docker-compose.yaml") try genOtelCollectorConfig().write(toFile: packageDir / "Deploy/Local/otel-collector-config.yaml") try genPrometheus().write(toFile: packageDir / "Deploy/Local/prometheus.yaml") - - //Create files for public folder + + // Create files for public folder try genOpenAPIBackend().write(toFile: packageDir / "Public/openapi.yaml") try genOpenAPIFrontend().write(toFile: packageDir / "Public/openapi.html") - - //Create source files - try CrudServerFiles.genAPIHandler().write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/APIHandler.swift") - try CrudServerFiles.getOpenAPIConfig().write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/openapi-generator-config.yaml") - try CrudServerFiles.getServerService().write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/ServerService.swift") - try CrudServerFiles.genEntryPointFile(serverAddress: self.serverOptions.host, serverPort: self.serverOptions.port).write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/EntryPoint.swift") - try CrudServerFiles.genTelemetryFile(logLevel: self.logLevel, logPath: logURLPath.url, logFormat: self.logFormat, logBufferSize: self.logBufferSize).write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/Telemetry.swift") + + // Create source files + try CrudServerFiles.genAPIHandler() + .write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/APIHandler.swift") + try CrudServerFiles.getOpenAPIConfig() + .write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/openapi-generator-config.yaml") + try CrudServerFiles.getServerService() + .write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/ServerService.swift") + try CrudServerFiles.genEntryPointFile( + serverAddress: self.serverOptions.host, + serverPort: self.serverOptions.port + ).write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/EntryPoint.swift") + try CrudServerFiles.genTelemetryFile( + logLevel: self.logLevel, + logPath: logURLPath.url, + logFormat: self.logFormat, + logBufferSize: self.logBufferSize + ).write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/Telemetry.swift") let targetPath = packageDir / "Public/openapi.yaml" let linkPath = packageDir / "Sources/\(ServerType.crud.targetName)/openapi.yaml" @@ -1610,6 +1609,7 @@ public struct CRUD: ParsableCommand { } // MARK: - MTLS Subcommand + struct MTLS: ParsableCommand { static let configuration = CommandConfiguration( commandName: "mtls", @@ -1631,24 +1631,27 @@ struct MTLS: ParsableCommand { var postgresURL: String = "postgres://postgres@localhost:5432/postgres?sslmode=disable" mutating func run() throws { - - try crud.run() + try self.crud.run() guard let pkgDir = self.crud.serverGenerator.packageOptions.pkgDir else { throw ValidationError("No --pkg-dir was provided.") } - + guard let url = URL(string: postgresURL) else { - throw ValidationError("Invalid URL: \(postgresURL)") + throw ValidationError("Invalid URL: \(self.postgresURL)") } let postgresURLComponents = CLIURL(url) - let packageDir = FilePath(pkgDir) - - let urls = self.mtlsAdditionalTrustRoots.map { $0.url } - try DatabaseFile.genDatabaseFileWithMTLS(mtlsPath: self.mtlsPath.url, mtlsKeyPath: self.mtlsKeyPath.url, mtlsAdditionalTrustRoots: urls, postgresURL: postgresURLComponents.url).write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/Database.swift") + let urls = self.mtlsAdditionalTrustRoots.map(\.url) + + try DatabaseFile.genDatabaseFileWithMTLS( + mtlsPath: self.mtlsPath.url, + mtlsKeyPath: self.mtlsKeyPath.url, + mtlsAdditionalTrustRoots: urls, + postgresURL: postgresURLComponents.url + ).write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/Database.swift") } } @@ -1664,29 +1667,27 @@ struct NoMTLS: ParsableCommand { var postgresURL: String = "postgres://postgres@localhost:5432/postgres?sslmode=disable" mutating func run() throws { - - - try crud.run() + try self.crud.run() guard let pkgDir = self.crud.serverGenerator.packageOptions.pkgDir else { throw ValidationError("No --pkg-dir was provided.") } guard let url = URL(string: postgresURL) else { - throw ValidationError("Invalid URL: \(postgresURL)") + throw ValidationError("Invalid URL: \(self.postgresURL)") } let postgresURLComponents = CLIURL(url) - let packageDir = FilePath(pkgDir) - - try DatabaseFile.genDatabaseFileWithoutMTLS(postgresURL: postgresURLComponents.url).write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/Database.swift") + + try DatabaseFile.genDatabaseFileWithoutMTLS(postgresURL: postgresURLComponents.url) + .write(toFile: packageDir / "Sources/\(ServerType.crud.targetName)/Database.swift") } } - // MARK: - Bare Command + struct Bare: ParsableCommand { static let configuration = CommandConfiguration( commandName: "bare", @@ -1699,35 +1700,35 @@ struct Bare: ParsableCommand { var serverOptions: SharedOptionsServers mutating func run() throws { - try self.serverGenerator.run() - + guard let pkgDir = self.serverGenerator.packageOptions.pkgDir else { throw ValidationError("No --pkg-dir was provided.") } - + let packageDir = FilePath(pkgDir) // Start from scratch with the Package.swift try? fs.shared.rm(atPath: packageDir / "Package.swift") - //Generate base package + // Generate base package try packageSwift(serverType: .bare).write(toFile: packageDir / "Package.swift") - if serverOptions.readMe.readMe { + if self.serverOptions.readMe.readMe { try genReadMe(serverType: .bare).write(toFile: packageDir / "README.md") } try genRioTemplatePkl(serverType: .bare).write(toFile: packageDir / "rio.template.pkl") try genDockerFile(serverType: .bare).write(toFile: packageDir / "Dockerfile.txt") - - //Generate files for Deployment + // Generate files for Deployment try genDockerCompose(server: .bare).write(toFile: packageDir / "Deploy/Local/docker-compose.yaml") - // Generate sources files for bare http server - try BareServerFiles.genEntryPointFile(serverAddress: self.serverOptions.host, serverPort: self.serverOptions.port).write(toFile: packageDir / "Sources/\(ServerType.bare.targetName)/Entrypoint.swift") - try BareServerFiles.genServerFile().write(toFile: packageDir / "Sources/\(ServerType.bare.targetName)/Server.swift") - + try BareServerFiles.genEntryPointFile( + serverAddress: self.serverOptions.host, + serverPort: self.serverOptions.port + ).write(toFile: packageDir / "Sources/\(ServerType.bare.targetName)/Entrypoint.swift") + try BareServerFiles.genServerFile() + .write(toFile: packageDir / "Sources/\(ServerType.bare.targetName)/Server.swift") } } @@ -1756,8 +1757,8 @@ struct CLIURL: ExpressibleByArgument, Decodable { } } - // MARK: - Shared option commands that are used to show inheritances of arguments and flags + struct PkgDir: ParsableArguments { @Option(help: .hidden) var pkgDir: String? @@ -1771,10 +1772,10 @@ struct readMe: ParsableArguments { struct SharedOptionsServers: ParsableArguments { @OptionGroup var readMe: readMe - + @Option(help: "Server Port") var port: Int = 8080 - + @Option(help: "Server Host") var host: String = "0.0.0.0" } diff --git a/Examples/init-templates/Templates/Template1/Template.swift b/Examples/init-templates/Templates/Template1/Template.swift index 68f3fc1efb4..719d6812449 100644 --- a/Examples/init-templates/Templates/Template1/Template.swift +++ b/Examples/init-templates/Templates/Template1/Template.swift @@ -14,54 +14,52 @@ extension String { } } -//basic structure of a template that uses string interpolation +// basic structure of a template that uses string interpolation @main struct HelloTemplateTool: ParsableCommand { - @OptionGroup(visibility: .hidden) var packageOptions: PkgDir - - //swift argument parser needed to expose arguments to template generator + + // swift argument parser needed to expose arguments to template generator @Option(help: "The name of your app") var name: String @Flag(help: "Include a README?") var includeReadme: Bool = false - //entrypoint of the template executable, that generates just a main.swift and a readme.md + // entrypoint of the template executable, that generates just a main.swift and a readme.md func run() throws { - guard let pkgDir = packageOptions.pkgDir else { throw ValidationError("No --pkg-dir was provided.") } - + let fs = FileManager.default let packageDir = FilePath(pkgDir) - let mainFile = packageDir / "Sources" / name / "main.swift" + let mainFile = packageDir / "Sources" / self.name / "main.swift" try fs.createDirectory(atPath: mainFile.removingLastComponent().string, withIntermediateDirectories: true) try """ - // This is the entry point to your command-line app - print("Hello, \(name)!") + // This is the entry point to your command-line app + print("Hello, \(self.name)!") - """.write(toFile: mainFile) + """.write(toFile: mainFile) - if includeReadme { + if self.includeReadme { try """ - # \(name) - This is a new Swift app! - """.write(toFile: packageDir / "README.md") + # \(self.name) + This is a new Swift app! + """.write(toFile: packageDir / "README.md") } - + print("Project generated at \(packageDir)") } } - // MARK: - Shared option commands that are used to show inheritances of arguments and flags + struct PkgDir: ParsableArguments { @Option(help: .hidden) var pkgDir: String? diff --git a/Examples/init-templates/Templates/Template2/Template.swift b/Examples/init-templates/Templates/Template2/Template.swift index 22dd1b7f41b..d60e7a69303 100644 --- a/Examples/init-templates/Templates/Template2/Template.swift +++ b/Examples/init-templates/Templates/Template2/Template.swift @@ -1,23 +1,21 @@ -//TEMPLATE: TemplateCLI +// TEMPLATE: TemplateCLI import ArgumentParser import Foundation -import Stencil import PathKit -import Foundation -//basic structure of a template that uses string interpolation +import Stencil +// basic structure of a template that uses string interpolation import ArgumentParser @main struct TemplateDeclarative: ParsableCommand { - enum Template: String, ExpressibleByArgument, CaseIterable { case EnumExtension case StructColors case StaticColorSets - + var path: String { switch self { case .EnumExtension: @@ -28,7 +26,7 @@ struct TemplateDeclarative: ParsableCommand { "StaticColorSets.stencil" } } - + var name: String { switch self { case .EnumExtension: @@ -40,80 +38,80 @@ struct TemplateDeclarative: ParsableCommand { } } } - //swift argument parser needed to expose arguments to template generator - @Option(name: [.customLong("template")], help: "Choose one template: \(Template.allCases.map(\.rawValue).joined(separator: ", "))") - var template: Template - + + // swift argument parser needed to expose arguments to template generator + @Option( + name: [.customLong("template")], + help: "Choose one template: \(Template.allCases.map(\.rawValue).joined(separator: ", "))" + ) + var template: Template + @Option(name: [.customLong("enumName"), .long], help: "Name of the generated enum") var enumName: String = "AppColors" @Flag(name: .shortAndLong, help: "Use public access modifier") var publicAccess: Bool = false - @Option(name: [.customLong("palette"), .long], parsing: .upToNextOption, help: "Palette name of the format PaletteName:name=#RRGGBBAA") + @Option( + name: [.customLong("palette"), .long], + parsing: .upToNextOption, + help: "Palette name of the format PaletteName:name=#RRGGBBAA" + ) var palettes: [String] - var templatesDirectory = "./MustacheTemplates" func run() throws { - let parsedPalettes: [[String: Any]] = try palettes.map { paletteString in - let parts = paletteString.split(separator: ":", maxSplits: 1) - guard parts.count == 2 else { - throw ValidationError("Each --palette must be in the format PaletteName:name=#RRGGBBAA,...") + let parts = paletteString.split(separator: ":", maxSplits: 1) + guard parts.count == 2 else { + throw ValidationError("Each --palette must be in the format PaletteName:name=#RRGGBBAA,...") + } + + let paletteName = String(parts[0]) + let colorEntries = parts[1].split(separator: ",") + + let colors = try colorEntries.map { entry in + let colorParts = entry.split(separator: "=") + guard colorParts.count == 2 else { + throw ValidationError("Color entry must be in format name=#RRGGBBAA") } - let paletteName = String(parts[0]) - let colorEntries = parts[1].split(separator: ",") - - let colors = try colorEntries.map { entry in - let colorParts = entry.split(separator: "=") - guard colorParts.count == 2 else { - throw ValidationError("Color entry must be in format name=#RRGGBBAA") - } - - let name = String(colorParts[0]) - let hex = colorParts[1].trimmingCharacters(in: CharacterSet(charactersIn: "#")) - guard hex.count == 8 else { - throw ValidationError("Hex must be 8 characters (RRGGBBAA)") - } - - return [ - "name": name, - "red": String(hex.prefix(2)), - "green": String(hex.dropFirst(2).prefix(2)), - "blue": String(hex.dropFirst(4).prefix(2)), - "alpha": String(hex.dropFirst(6)) - ] + let name = String(colorParts[0]) + let hex = colorParts[1].trimmingCharacters(in: CharacterSet(charactersIn: "#")) + guard hex.count == 8 else { + throw ValidationError("Hex must be 8 characters (RRGGBBAA)") } return [ - "name": paletteName, - "colors": colors + "name": name, + "red": String(hex.prefix(2)), + "green": String(hex.dropFirst(2).prefix(2)), + "blue": String(hex.dropFirst(4).prefix(2)), + "alpha": String(hex.dropFirst(6)), ] } - let context: [String: Any] = [ - - "enumName": enumName, - "publicAccess": publicAccess, - - "palettes": parsedPalettes + return [ + "name": paletteName, + "colors": colors, ] + } + + let context: [String: Any] = [ + "enumName": enumName, + "publicAccess": publicAccess, + + "palettes": parsedPalettes, + ] - - if let url = Bundle.module.url(forResource: "\(template.name)", withExtension: "stencil") { print("Template URL: \(url)") - - + let path = url.deletingLastPathComponent() let environment = Environment(loader: FileSystemLoader(paths: [Path(path.path)])) - - - let rendered = try environment.renderTemplate(name: "\(template.path)", context: context) + let rendered = try environment.renderTemplate(name: "\(self.template.path)", context: context) print(rendered) try rendered.write(toFile: "User.swift", atomically: true, encoding: .utf8) @@ -121,12 +119,5 @@ struct TemplateDeclarative: ParsableCommand { } else { print("Template not found.") } - - - - } - - } - diff --git a/Examples/init-templates/Tests/PartsServiceTests.swift b/Examples/init-templates/Tests/PartsServiceTests.swift index ecc5e570750..8524b0cdfea 100644 --- a/Examples/init-templates/Tests/PartsServiceTests.swift +++ b/Examples/init-templates/Tests/PartsServiceTests.swift @@ -1,45 +1,44 @@ -import Testing import Foundation +import Testing @Suite final class PartsServiceTemplateTests { - - //Struct to collect output from a process + // Struct to collect output from a process struct processOutput { let terminationStatus: Int32 let output: String - + init(terminationStatus: Int32, output: String) { self.terminationStatus = terminationStatus self.output = output } } - - //function for running a process given arguments, executable, and a directory - func run(executableURL: URL, args: [String], directory: URL? = nil) throws -> processOutput{ + + // function for running a process given arguments, executable, and a directory + func run(executableURL: URL, args: [String], directory: URL? = nil) throws -> processOutput { let process = Process() process.executableURL = executableURL process.arguments = args - + process.currentDirectoryURL = directory - + let pipe = Pipe() process.standardOutput = pipe process.standardOutput = pipe - + try process.run() process.waitUntilExit() - + let outputData = pipe.fileHandleForReading.readDataToEndOfFile() - + let output = String(decoding: outputData, as: UTF8.self) - + return processOutput(terminationStatus: process.terminationStatus, output: output) } - + // test case for your template @Test - func testTemplate1_generatesExpectedFilesAndCompiles() throws { + func template1_generatesExpectedFilesAndCompiles() throws { // Setup temp directory for generating template let fileManager = FileManager.default let tempDir = fileManager.temporaryDirectory.appendingPathComponent("TemplateTest-\(UUID())") @@ -50,20 +49,21 @@ final class PartsServiceTemplateTests { try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true) // Path to built parts-service executable - let binary = productsDirectory.appendingPathComponent("parts-service") + let binary = self.productsDirectory.appendingPathComponent("parts-service") let output = try run(executableURL: binary, args: ["--pkg-dir", tempDir.path, "--readme"], directory: tempDir) #expect(output.terminationStatus == 0, "parts-service should exit cleanly") - let buildOutput = try run(executableURL: URL(fileURLWithPath: "/usr/bin/env"), args: ["swift", "build", "--package-path", tempDir.path]) + let buildOutput = try run( + executableURL: URL(fileURLWithPath: "/usr/bin/env"), + args: ["swift", "build", "--package-path", tempDir.path] + ) #expect(buildOutput.terminationStatus == 0, "swift package builds") } - // Find the built products directory when using SwiftPM test var productsDirectory: URL { URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(".build/debug") } } - diff --git a/Examples/init-templates/Tests/ServerTemplateTests/ServerTemplateTests.swift b/Examples/init-templates/Tests/ServerTemplateTests/ServerTemplateTests.swift index 4cd5fbbd753..f7f02e72c7d 100644 --- a/Examples/init-templates/Tests/ServerTemplateTests/ServerTemplateTests.swift +++ b/Examples/init-templates/Tests/ServerTemplateTests/ServerTemplateTests.swift @@ -1,14 +1,14 @@ -import Testing import Foundation @testable import ServerTemplate +import Testing struct CrudServerFilesTests { @Test - func testGenTelemetryFileContainsLoggingConfig() { + func genTelemetryFileContainsLoggingConfig() { let logPath = URL(fileURLWithPath: "/tmp/test.log") let logURLPath = CLIURL(logPath) - + let generated = CrudServerFiles.genTelemetryFile( logLevel: .info, logPath: logPath, @@ -23,12 +23,9 @@ struct CrudServerFilesTests { } } - - struct EntryPointTests { @Test - func testGenEntryPointFileContainsServerAddressAndPort() { - + func genEntryPointFileContainsServerAddressAndPort() { let serverAddress = "127.0.0.1" let serverPort = 9090 let code = CrudServerFiles.genEntryPointFile(serverAddress: serverAddress, serverPort: serverPort) @@ -39,10 +36,9 @@ struct EntryPointTests { } } - struct OpenAPIConfigTests { @Test - func testOpenAPIConfigContainsGenerateSection() { + func openAPIConfigContainsGenerateSection() { let config = CrudServerFiles.getOpenAPIConfig() #expect(config.contains("generate:")) #expect(config.contains("- types")) @@ -50,10 +46,9 @@ struct OpenAPIConfigTests { } } - struct APIHandlerTests { @Test - func testGenAPIHandlerIncludesOperations() { + func genAPIHandlerIncludesOperations() { let code = CrudServerFiles.genAPIHandler() #expect(code.contains("func listTODOs")) #expect(code.contains("func createTODO")) @@ -62,6 +57,3 @@ struct APIHandlerTests { #expect(code.contains("func crash")) } } - - - diff --git a/Examples/init-templates/Tests/TemplateTest.swift b/Examples/init-templates/Tests/TemplateTest.swift index a5fe1307e4f..be7662df5d0 100644 --- a/Examples/init-templates/Tests/TemplateTest.swift +++ b/Examples/init-templates/Tests/TemplateTest.swift @@ -1,46 +1,45 @@ -import Testing import Foundation +import Testing -//a possible look into how to test templates +// a possible look into how to test templates @Suite final class TemplateCLITests { - - //Struct to collect output from a process + // Struct to collect output from a process struct processOutput { let terminationStatus: Int32 let output: String - + init(terminationStatus: Int32, output: String) { self.terminationStatus = terminationStatus self.output = output } } - - //function for running a process given arguments, executable, and a directory - func run(executableURL: URL, args: [String], directory: URL? = nil) throws -> processOutput{ + + // function for running a process given arguments, executable, and a directory + func run(executableURL: URL, args: [String], directory: URL? = nil) throws -> processOutput { let process = Process() process.executableURL = executableURL process.arguments = args - + process.currentDirectoryURL = directory - + let pipe = Pipe() process.standardOutput = pipe process.standardOutput = pipe - + try process.run() process.waitUntilExit() - + let outputData = pipe.fileHandleForReading.readDataToEndOfFile() - + let output = String(decoding: outputData, as: UTF8.self) - + return processOutput(terminationStatus: process.terminationStatus, output: output) } - + // test case for your template @Test - func testTemplate1_generatesExpectedFilesAndCompiles() throws { + func template1_generatesExpectedFilesAndCompiles() throws { // Setup temp directory for generating template let fileManager = FileManager.default let tempDir = fileManager.temporaryDirectory.appendingPathComponent("Template1Test-\(UUID())") @@ -52,7 +51,7 @@ final class TemplateCLITests { try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true) // Path to built TemplateCLI executable - let binary = productsDirectory.appendingPathComponent("simple-template1-tool") + let binary = self.productsDirectory.appendingPathComponent("simple-template1-tool") let output = try run(executableURL: binary, args: ["--name", appName, "--include-readme"], directory: tempDir) #expect(output.terminationStatus == 0, "TemplateCLI should exit cleanly") @@ -66,15 +65,16 @@ final class TemplateCLITests { let outputBinary = tempDir.appendingPathComponent("main_executable") - let compileOutput = try run(executableURL: URL(fileURLWithPath: "/usr/bin/env"), args: ["swiftc", mainSwift.path, "-o", outputBinary.path]) + let compileOutput = try run( + executableURL: URL(fileURLWithPath: "/usr/bin/env"), + args: ["swiftc", mainSwift.path, "-o", outputBinary.path] + ) #expect(compileOutput.terminationStatus == 0, "swift file compiles") } - // Find the built products directory when using SwiftPM test var productsDirectory: URL { URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(".build/debug") } } - diff --git a/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Package.swift b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Package.swift index c8c67f66aad..fb2f054ddbf 100644 --- a/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Package.swift +++ b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Package.swift @@ -9,6 +9,7 @@ let package = Package( // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .executableTarget( - name: "generated-package"), + name: "generated-package" + ), ] ) diff --git a/Fixtures/Miscellaneous/InferPackageType/Package.swift b/Fixtures/Miscellaneous/InferPackageType/Package.swift index 47f8992e439..35e35bd52ad 100644 --- a/Fixtures/Miscellaneous/InferPackageType/Package.swift +++ b/Fixtures/Miscellaneous/InferPackageType/Package.swift @@ -2,62 +2,55 @@ import PackageDescription let initialLibrary: [Target] = .template( - name: "initialTypeLibrary", - dependencies: [], - initialPackageType: .library, - description: "" - ) - + name: "initialTypeLibrary", + dependencies: [], + initialPackageType: .library, + description: "" +) let initialExecutable: [Target] = .template( - name: "initialTypeExecutable", - dependencies: [], - initialPackageType: .executable, - description: "" - ) - + name: "initialTypeExecutable", + dependencies: [], + initialPackageType: .executable, + description: "" +) let initialTool: [Target] = .template( - name: "initialTypeTool", - dependencies: [], - initialPackageType: .tool, - description: "" - ) - + name: "initialTypeTool", + dependencies: [], + initialPackageType: .tool, + description: "" +) let initialBuildToolPlugin: [Target] = .template( - name: "initialTypeBuildToolPlugin", - dependencies: [], - initialPackageType: .buildToolPlugin, - description: "" - ) - + name: "initialTypeBuildToolPlugin", + dependencies: [], + initialPackageType: .buildToolPlugin, + description: "" +) let initialCommandPlugin: [Target] = .template( - name: "initialTypeCommandPlugin", - dependencies: [], - initialPackageType: .commandPlugin, - description: "" - ) - + name: "initialTypeCommandPlugin", + dependencies: [], + initialPackageType: .commandPlugin, + description: "" +) let initialMacro: [Target] = .template( - name: "initialTypeMacro", - dependencies: [], - initialPackageType: .`macro`, - description: "" - ) - - + name: "initialTypeMacro", + dependencies: [], + initialPackageType: .macro, + description: "" +) let initialEmpty: [Target] = .template( - name: "initialTypeEmpty", - dependencies: [], - initialPackageType: .empty, - description: "" - ) + name: "initialTypeEmpty", + dependencies: [], + initialPackageType: .empty, + description: "" +) -var products: [Product] = .template(name: "initialTypeLibrary") +var products: [Product] = .template(name: "initialTypeLibrary") products += .template(name: "initialTypeExecutable") products += .template(name: "initialTypeTool") @@ -69,5 +62,6 @@ products += .template(name: "initialTypeEmpty") let package = Package( name: "InferPackageType", products: products, - targets: initialLibrary + initialExecutable + initialTool + initialBuildToolPlugin + initialCommandPlugin + initialMacro + initialEmpty + targets: initialLibrary + initialExecutable + initialTool + initialBuildToolPlugin + initialCommandPlugin + + initialMacro + initialEmpty ) diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeBuildToolPluginPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeBuildToolPluginPlugin/main.swift index aa73dd6b8ae..938a1073da2 100644 --- a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeBuildToolPluginPlugin/main.swift +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeBuildToolPluginPlugin/main.swift @@ -10,4 +10,3 @@ struct FooPlugin: CommandPlugin { arguments: [String] ) async throws {} } - diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeCommandPluginPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeCommandPluginPlugin/main.swift index aa73dd6b8ae..938a1073da2 100644 --- a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeCommandPluginPlugin/main.swift +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeCommandPluginPlugin/main.swift @@ -10,4 +10,3 @@ struct FooPlugin: CommandPlugin { arguments: [String] ) async throws {} } - diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeEmptyPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeEmptyPlugin/main.swift index aa73dd6b8ae..938a1073da2 100644 --- a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeEmptyPlugin/main.swift +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeEmptyPlugin/main.swift @@ -10,4 +10,3 @@ struct FooPlugin: CommandPlugin { arguments: [String] ) async throws {} } - diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeExecutablePlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeExecutablePlugin/main.swift index aa73dd6b8ae..938a1073da2 100644 --- a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeExecutablePlugin/main.swift +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeExecutablePlugin/main.swift @@ -10,4 +10,3 @@ struct FooPlugin: CommandPlugin { arguments: [String] ) async throws {} } - diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeLibraryPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeLibraryPlugin/main.swift index aa73dd6b8ae..938a1073da2 100644 --- a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeLibraryPlugin/main.swift +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeLibraryPlugin/main.swift @@ -10,4 +10,3 @@ struct FooPlugin: CommandPlugin { arguments: [String] ) async throws {} } - diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeMacroPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeMacroPlugin/main.swift index aa73dd6b8ae..938a1073da2 100644 --- a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeMacroPlugin/main.swift +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeMacroPlugin/main.swift @@ -10,4 +10,3 @@ struct FooPlugin: CommandPlugin { arguments: [String] ) async throws {} } - diff --git a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeToolPlugin/main.swift b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeToolPlugin/main.swift index aa73dd6b8ae..938a1073da2 100644 --- a/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeToolPlugin/main.swift +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeToolPlugin/main.swift @@ -10,4 +10,3 @@ struct FooPlugin: CommandPlugin { arguments: [String] ) async throws {} } - diff --git a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift index a2983f0ae0e..9cc1e41cac4 100644 --- a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift +++ b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift @@ -1,11 +1,10 @@ // swift-tools-version:6.3.0 import PackageDescription - let package = Package( name: "SimpleTemplateExample", products: - .template(name: "ExecutableTemplate"), + .template(name: "ExecutableTemplate"), dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2"), @@ -16,7 +15,7 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "SystemPackage", package: "swift-system"), ], - + initialPackageType: .executable, description: "This is a simple template that uses Swift string interpolation." ) diff --git a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Plugins/ExecutableTemplate/ExecutableTemplatePlugin.swift b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Plugins/ExecutableTemplate/ExecutableTemplatePlugin.swift index 63aa3ed64c6..3e9df21fa0e 100644 --- a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Plugins/ExecutableTemplate/ExecutableTemplatePlugin.swift +++ b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Plugins/ExecutableTemplate/ExecutableTemplatePlugin.swift @@ -32,18 +32,18 @@ struct TemplatePlugin: CommandPlugin { if process.terminationStatus != 0 { throw PluginError.executionFailed(code: process.terminationStatus, stderrOutput: stderrOutput) } - } + enum PluginError: Error, CustomStringConvertible { case executionFailed(code: Int32, stderrOutput: String) var description: String { switch self { case .executionFailed(let code, let stderrOutput): - return """ + """ Plugin subprocess failed with exit code \(code). - + Output: \(stderrOutput) diff --git a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Templates/ExecutableTemplate/Template.swift b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Templates/ExecutableTemplate/Template.swift index 68f3fc1efb4..719d6812449 100644 --- a/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Templates/ExecutableTemplate/Template.swift +++ b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Templates/ExecutableTemplate/Template.swift @@ -14,54 +14,52 @@ extension String { } } -//basic structure of a template that uses string interpolation +// basic structure of a template that uses string interpolation @main struct HelloTemplateTool: ParsableCommand { - @OptionGroup(visibility: .hidden) var packageOptions: PkgDir - - //swift argument parser needed to expose arguments to template generator + + // swift argument parser needed to expose arguments to template generator @Option(help: "The name of your app") var name: String @Flag(help: "Include a README?") var includeReadme: Bool = false - //entrypoint of the template executable, that generates just a main.swift and a readme.md + // entrypoint of the template executable, that generates just a main.swift and a readme.md func run() throws { - guard let pkgDir = packageOptions.pkgDir else { throw ValidationError("No --pkg-dir was provided.") } - + let fs = FileManager.default let packageDir = FilePath(pkgDir) - let mainFile = packageDir / "Sources" / name / "main.swift" + let mainFile = packageDir / "Sources" / self.name / "main.swift" try fs.createDirectory(atPath: mainFile.removingLastComponent().string, withIntermediateDirectories: true) try """ - // This is the entry point to your command-line app - print("Hello, \(name)!") + // This is the entry point to your command-line app + print("Hello, \(self.name)!") - """.write(toFile: mainFile) + """.write(toFile: mainFile) - if includeReadme { + if self.includeReadme { try """ - # \(name) - This is a new Swift app! - """.write(toFile: packageDir / "README.md") + # \(self.name) + This is a new Swift app! + """.write(toFile: packageDir / "README.md") } - + print("Project generated at \(packageDir)") } } - // MARK: - Shared option commands that are used to show inheritances of arguments and flags + struct PkgDir: ParsableArguments { @Option(help: .hidden) var pkgDir: String? diff --git a/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift b/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift index 3dea7337eb5..0c23f679535 100644 --- a/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift +++ b/Fixtures/Miscellaneous/ShowExecutables/deck-of-playing-cards/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:999.0.0 +// swift-tools-version:5.10 import PackageDescription let package = Package( diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift index c3059b14209..db9c0da70ea 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -11,7 +11,7 @@ let package = Package( ] + .template(name: "GenerateFromTemplate"), dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), - .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2") + .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2"), ], targets: [ .executableTarget( @@ -21,13 +21,12 @@ let package = Package( name: "GenerateFromTemplate", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), - .product(name: "SystemPackage", package: "swift-system") + .product(name: "SystemPackage", package: "swift-system"), ], initialPackageType: .executable, templatePermissions: [ - .allowNetworkConnections(scope: .local(ports: [1200]), reason: "") + .allowNetworkConnections(scope: .local(ports: [1200]), reason: ""), ], description: "A template that generates a starter executable package" ) ) - diff --git a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateFromTemplate/Template.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateFromTemplate/Template.swift index f5dec1c76bc..91b39d8df9f 100644 --- a/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateFromTemplate/Template.swift +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateFromTemplate/Template.swift @@ -14,40 +14,38 @@ extension String { } } -//basic structure of a template that uses string interpolation +// basic structure of a template that uses string interpolation @main struct HelloTemplateTool: ParsableCommand { - - //swift argument parser needed to expose arguments to template generator + // swift argument parser needed to expose arguments to template generator @Option(help: "The name of your app") var name: String @Flag(help: "Include a README?") var includeReadme: Bool = false - //entrypoint of the template executable, that generates just a main.swift and a readme.md + // entrypoint of the template executable, that generates just a main.swift and a readme.md func run() throws { - print("we got here") let fs = FileManager.default let rootDir = FilePath(fs.currentDirectoryPath) - let mainFile = rootDir / "Generated" / name / "main.swift" + let mainFile = rootDir / "Generated" / self.name / "main.swift" try fs.createDirectory(atPath: mainFile.removingLastComponent().string, withIntermediateDirectories: true) try """ - // This is the entry point to your command-line app - print("Hello, \(name)!") + // This is the entry point to your command-line app + print("Hello, \(self.name)!") - """.write(toFile: mainFile) + """.write(toFile: mainFile) - if includeReadme { + if self.includeReadme { try """ - # \(name) - This is a new Swift app! - """.write(toFile: rootDir / "README.md") + # \(self.name) + This is a new Swift app! + """.write(toFile: rootDir / "README.md") } print("Project generated at \(rootDir)") diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 303093bc7f7..1661ce9807f 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -420,7 +420,6 @@ public struct DefaultTemplateSourceResolver: TemplateSourceResolver { } } - struct PackageInitConfiguration { let packageName: String let cwd: Basics.AbsolutePath @@ -475,7 +474,7 @@ struct PackageInitConfiguration { packageID: packageID ) - if templateSource != nil { + if self.templateSource != nil { self.versionResolver = DependencyRequirementResolver( packageIdentity: packageID, swiftCommandState: swiftCommandState, @@ -492,17 +491,17 @@ struct PackageInitConfiguration { } func makeInitializer() throws -> PackageInitializer { - if let templateSource = templateSource, - let versionResolver = versionResolver, - let buildOptions = buildOptions, - let globalOptions = globalOptions, - let validatePackage = validatePackage { - - return TemplatePackageInitializer( - packageName: packageName, - cwd: cwd, + if let templateSource, + let versionResolver, + let buildOptions, + let globalOptions, + let validatePackage + { + TemplatePackageInitializer( + packageName: self.packageName, + cwd: self.cwd, templateSource: templateSource, - templateName: initMode, + templateName: self.initMode, templateDirectory: self.directory, templateURL: self.url, templatePackageID: self.packageID, @@ -510,22 +509,21 @@ struct PackageInitConfiguration { buildOptions: buildOptions, globalOptions: globalOptions, validatePackage: validatePackage, - args: args, - swiftCommandState: swiftCommandState + args: self.args, + swiftCommandState: self.swiftCommandState ) } else { - return StandardPackageInitializer( - packageName: packageName, - initMode: initMode, - testLibraryOptions: testLibraryOptions, - cwd: cwd, - swiftCommandState: swiftCommandState + StandardPackageInitializer( + packageName: self.packageName, + initMode: self.initMode, + testLibraryOptions: self.testLibraryOptions, + cwd: self.cwd, + swiftCommandState: self.swiftCommandState ) } } } - public struct VersionFlags { let exact: Version? let revision: String? @@ -535,7 +533,6 @@ public struct VersionFlags { let to: Version? } - protocol TemplateSourceResolver { func resolveSource( directory: Basics.AbsolutePath?, diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift index 8afa9cc741a..b63972d242d 100644 --- a/Sources/Commands/TestCommands/TestTemplateCommand.swift +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -43,7 +43,6 @@ extension DispatchTimeInterval { } } - extension SwiftTestCommand { /// Test the various outputs of a template. struct Template: AsyncSwiftCommand { @@ -96,7 +95,6 @@ extension SwiftTestCommand { @Option(help: "Set the output format.") var format: ShowTestTemplateOutput = .matrix - func run(_ swiftCommandState: SwiftCommandState) async throws { guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { throw InternalError("Could not find the current working directory") @@ -113,14 +111,13 @@ extension SwiftTestCommand { ) let buildSystem = self.globalOptions.build.buildSystem != .native ? - self.globalOptions.build.buildSystem : - swiftCommandState.options.build.buildSystem + self.globalOptions.build.buildSystem : + swiftCommandState.options.build.buildSystem - let resolvedTemplateName: String - if self.templateName == nil { - resolvedTemplateName = try await self.findTemplateName(from: cwd, swiftCommandState: swiftCommandState) + let resolvedTemplateName: String = if self.templateName == nil { + try await self.findTemplateName(from: cwd, swiftCommandState: swiftCommandState) } else { - resolvedTemplateName = self.templateName! + self.templateName! } let pluginManager = try await TemplateTesterPluginManager( @@ -303,7 +300,10 @@ extension SwiftTestCommand { } } - func findTemplateName(from templatePath: Basics.AbsolutePath, swiftCommandState: SwiftCommandState) async throws -> String { + func findTemplateName( + from templatePath: Basics.AbsolutePath, + swiftCommandState: SwiftCommandState + ) async throws -> String { try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { workspace, root in let rootManifests = try await workspace.loadRootManifests( packages: root.packages, @@ -314,7 +314,7 @@ extension SwiftTestCommand { throw TestTemplateCommandError.invalidManifestInTemplate } - return try findTemplateName(from: manifest) + return try self.findTemplateName(from: manifest) } } @@ -476,7 +476,7 @@ extension SwiftTestCommand { } private func redirectStdoutAndStderr(to path: String) throws -> (originalStdout: Int32, originalStderr: Int32) { -#if os(Windows) + #if os(Windows) guard let file = _fsopen(path, "w", _SH_DENYWR) else { throw TestTemplateCommandError.outputRedirectionFailed(path) } @@ -486,7 +486,7 @@ extension SwiftTestCommand { _dup2(_fileno(file), _fileno(stderr)) fclose(file) return (originalStdout, originalStderr) -#else + #else guard let file = fopen(path, "w") else { throw TestTemplateCommandError.outputRedirectionFailed(path) } @@ -496,23 +496,23 @@ extension SwiftTestCommand { dup2(fileno(file), STDERR_FILENO) fclose(file) return (originalStdout, originalStderr) -#endif + #endif } private func restoreStdoutAndStderr(originalStdout: Int32, originalStderr: Int32) { fflush(stdout) fflush(stderr) -#if os(Windows) + #if os(Windows) _dup2(originalStdout, _fileno(stdout)) _dup2(originalStderr, _fileno(stderr)) _close(originalStdout) _close(originalStderr) -#else + #else dup2(originalStdout, STDOUT_FILENO) dup2(originalStderr, STDERR_FILENO) close(originalStdout) close(originalStderr) -#endif + #endif } enum ShowTestTemplateOutput: String, RawRepresentable, CustomStringConvertible, ExpressibleByArgument, diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift index 1546cacc20a..8d0db556a98 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -78,11 +78,10 @@ struct TemplatePackageInitializer: PackageInitializer { self.swiftCommandState.observabilityScope .emit(debug: "Inferring initial type of consumer's package based on template's specifications.") - let resolvedTemplateName: String - if self.templateName == nil { - resolvedTemplateName = try await self.findTemplateName(from: resolvedTemplatePath) + let resolvedTemplateName: String = if self.templateName == nil { + try await self.findTemplateName(from: resolvedTemplatePath) } else { - resolvedTemplateName = self.templateName! + self.templateName! } let packageType = try await TemplatePackageInitializer.inferPackageType( @@ -216,10 +215,10 @@ struct TemplatePackageInitializer: PackageInitializer { /// Finds the template name from a template path. func findTemplateName(from templatePath: Basics.AbsolutePath) async throws -> String { - try await swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { workspace, root in + try await self.swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { workspace, root in let rootManifests = try await workspace.loadRootManifests( packages: root.packages, - observabilityScope: swiftCommandState.observabilityScope + observabilityScope: self.swiftCommandState.observabilityScope ) guard let manifest = rootManifests.values.first else { diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift index 91514c6ed41..ca49ccbb430 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -153,7 +153,8 @@ struct LocalTemplateFetcher: TemplateFetcher { } } -/// Fetches a Swift package template from a Git repository based on a specified requirement for initial package type inference. +/// Fetches a Swift package template from a Git repository based on a specified requirement for initial package type +/// inference. /// /// Supports: /// - Checkout by tag (exact version) @@ -214,7 +215,7 @@ struct GitTemplateFetcher: TemplateFetcher { if self.isPermissionError(error) { throw GitTemplateFetcherError.authenticationRequired(source: self.source, error: error) } - swiftCommandState.observabilityScope.emit(error) + self.swiftCommandState.observabilityScope.emit(error) throw GitTemplateFetcherError.cloneFailed(source: self.source) } } @@ -241,7 +242,8 @@ struct GitTemplateFetcher: TemplateFetcher { /// Creates a working copy from a bare directory. /// - /// - Throws: .createWorkingCopyFailed(path: workingCopyPath, underlyingError: error) if the provider failed to create a working copy from a bare repository + /// - Throws: .createWorkingCopyFailed(path: workingCopyPath, underlyingError: error) if the provider failed to + /// create a working copy from a bare repository private func createWorkingCopy( fromBare barePath: Basics.AbsolutePath, at workingCopyPath: Basics.AbsolutePath @@ -360,7 +362,6 @@ struct GitTemplateFetcher: TemplateFetcher { /// - Exact version /// - Upper bound of a version range (e.g., latest version within a range) struct RegistryTemplateFetcher: TemplateFetcher { - /// The swiftCommandState of the current process. let swiftCommandState: SwiftCommandState @@ -455,7 +456,6 @@ struct RegistryTemplateFetcher: TemplateFetcher { /// Errors that can occur while loading Swift package registry configuration. enum RegistryConfigError: Error, LocalizedError { - /// Indicates the configuration file could not be loaded. case failedToLoadConfiguration(file: Basics.AbsolutePath, underlyingError: Error) diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift index 26b607313ce..ba9e87c15b9 100644 --- a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -85,7 +85,8 @@ struct TemplateInitializationPluginManager: TemplatePluginManager { /// Manages the logic of running a template and executing on the information provided by the JSON representation of /// a template's arguments. /// - /// - Throws: Any error thrown during the loading of the template plugin, the fetching of the JSON representation of the template's arguments, prompting, or execution of the template + /// - Throws: Any error thrown during the loading of the template plugin, the fetching of the JSON representation of + /// the template's arguments, prompting, or execution of the template func run() async throws { let plugin = try loadTemplatePlugin() let toolInfo = try await coordinator.dumpToolInfo( diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift index 768b4320957..176f291922b 100644 --- a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -43,25 +43,25 @@ import Workspace public struct TemplateTesterPluginManager: TemplatePluginManager { /// The Swift command state containing build configuration and observability scope. private let swiftCommandState: SwiftCommandState - + /// The name of the template to test. If nil, will be auto-detected from the package manifest. private let template: String? - + /// The scratch directory path where temporary testing files are created. private let scratchDirectory: Basics.AbsolutePath - + /// The command line arguments to pass to the template plugin during testing. private let args: [String] - + /// The loaded package graph containing all resolved packages and dependencies. private let packageGraph: ModulesGraph - + /// The branch names used to filter which command paths to generate during testing. private let branches: [String] - + /// The coordinator responsible for managing template plugin operations. private let coordinator: TemplatePluginCoordinator - + /// The build system provider kind to use for building template dependencies. private let buildSystem: BuildSystemProvider.Kind @@ -72,7 +72,8 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { /// - Warning: This property will cause a fatal error if no root package is found. private var rootPackage: ResolvedPackage { guard let root = packageGraph.rootPackages.first else { - fatalError("No root package found in the package graph. Ensure the template package is properly configured.") + fatalError("No root package found in the package graph. Ensure the template package is properly configured." + ) } return root } @@ -90,7 +91,7 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { /// - branches: The branch names to filter command path generation. /// - buildSystem: The build system provider to use for compilation. /// - /// - Throws: + /// - Throws: /// - `PackageGraphError` if the package graph cannot be loaded /// - `FileSystemError` if the scratch directory is invalid /// - `TemplatePluginError` if the plugin coordinator setup fails @@ -206,7 +207,7 @@ public struct TemplateTesterPluginManager: TemplatePluginManager { public struct CommandPath { /// The unique identifier for this command path, typically formed by joining command names with hyphens. public let fullPathKey: String - + /// The ordered sequence of command components that make up this execution path. public let commandChain: [CommandComponent] } @@ -232,7 +233,7 @@ public struct CommandPath { public struct CommandComponent { /// The name of this command component. let commandName: String - + /// The arguments associated with this command component. let arguments: [TemplateTestPromptingSystem.ArgumentResponse] } @@ -1370,7 +1371,7 @@ public class TemplateTestPromptingSystem { /// /// Generates the appropriate command-line representation based on the argument type: /// - /// - **Flags**: + /// - **Flags**: /// - Returns `["--flag-name"]` if the value is "true" /// - Returns `[]` if the value is "false" or explicitly unset /// @@ -1505,38 +1506,38 @@ private enum TemplateError: Swift.Error { /// The template has no argument definitions to process. case noArguments - + /// An argument name is invalid or malformed. /// - Parameter name: The invalid argument name case invalidArgument(name: String) - + /// An unexpected argument was encountered during parsing. /// - Parameter name: The unexpected argument name case unexpectedArgument(name: String) - + /// An unexpected named argument (starting with --) was encountered. /// - Parameter name: The unexpected named argument case unexpectedNamedArgument(name: String) - + /// A required value for an option argument is missing. /// - Parameter name: The option name missing its value case missingValueForOption(name: String) - + /// One or more values don't match the argument's allowed value constraints. /// - Parameters: /// - argument: The argument name with invalid values /// - invalidValues: The invalid values that were provided /// - allowed: The list of allowed values for this argument case invalidValue(argument: String, invalidValues: [String], allowed: [String]) - + /// An unexpected subcommand was provided in the arguments. /// - Parameter name: The unexpected subcommand name case unexpectedSubcommand(name: String) - + /// A required argument is missing and no interactive terminal is available for prompting. /// - Parameter name: The name of the missing required argument case missingRequiredArgumentWithoutTTY(name: String) - + /// Subcommand selection requires an interactive terminal but none is available. case noTTYForSubcommandSelection } @@ -1559,7 +1560,8 @@ extension TemplateError: CustomStringConvertible { /// ``` /// "Invalid value for --type. Valid values are: executable, library. Also, xyz is not valid." /// "Required argument 'name' not provided and no interactive terminal available" - /// "Invalid subcommand 'build' provided in arguments, arguments only accepts flags, options, or positional arguments. Subcommands are treated via the --branch option" + /// "Invalid subcommand 'build' provided in arguments, arguments only accepts flags, options, or positional + /// arguments. Subcommands are treated via the --branch option" /// ``` var description: String { switch self { diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift index 77046d413db..969865e18ec 100644 --- a/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift +++ b/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift @@ -18,10 +18,9 @@ import SwiftSyntax import TSCBasic import TSCUtility - import struct PackageModel.InstalledSwiftPMConfiguration -import struct PackageModel.SupportedPlatform import class PackageModel.Manifest +import struct PackageModel.SupportedPlatform /// A class responsible for initializing a Swift package from a specified template. /// @@ -38,7 +37,6 @@ import class PackageModel.Manifest /// - Use `promptUser(tool:)` to interactively prompt the user for command line argument values. public struct InitTemplatePackage { - /// The kind of package dependency to add for the template. let packageDependency: SwiftRefactor.PackageDependency @@ -113,7 +111,6 @@ public struct InitTemplatePackage { /// - destinationPath: The directory where the new package should be created. /// - installedSwiftPMConfiguration: Configuration from the SwiftPM toolchain. - package init( name: String, initMode: SwiftRefactor.PackageDependency, @@ -187,11 +184,11 @@ public struct InitTemplatePackage { let editResult = try SwiftRefactor.AddPackageDependency.textRefactor( syntax: manifestSyntax, - in: SwiftRefactor.AddPackageDependency.Context(dependency: packageDependency) + in: SwiftRefactor.AddPackageDependency.Context(dependency: self.packageDependency) ) try editResult.applyEdits( - to: fileSystem, + to: self.fileSystem, manifest: manifestSyntax, manifestPath: manifestPath, verbose: false @@ -199,15 +196,13 @@ public struct InitTemplatePackage { } } - - public final class TemplatePromptingSystem { - private let hasTTY: Bool public init(hasTTY: Bool = true) { self.hasTTY = hasTTY } + /// Prompts the user for input based on the given command definition and arguments. /// /// This method collects responses for a command's arguments by first validating any user-provided @@ -231,21 +226,33 @@ public final class TemplatePromptingSystem { /// /// - Throws: An error if argument parsing or user prompting fails. - public func promptUser(command: CommandInfoV0, arguments: [String], subcommandTrail: [String] = [], inheritedResponses: [ArgumentResponse] = []) throws -> [String] { - + public func promptUser( + command: CommandInfoV0, + arguments: [String], + subcommandTrail: [String] = [], + inheritedResponses: [ArgumentResponse] = [] + ) throws -> [String] { let allArgs = try convertArguments(from: command) - let subCommands = getSubCommand(from: command) ?? [] - let (providedResponses, leftoverArgs) = try self.parseAndMatchArgumentsWithLeftovers(arguments, definedArgs: allArgs, subcommands: subCommands) + let subCommands = self.getSubCommand(from: command) ?? [] + let (providedResponses, leftoverArgs) = try self.parseAndMatchArgumentsWithLeftovers( + arguments, + definedArgs: allArgs, + subcommands: subCommands + ) let missingArgs = self.findMissingArguments(from: allArgs, excluding: providedResponses) var collectedResponses: [String: ArgumentResponse] = [:] - let promptedResponses = try UserPrompter.prompt(for: missingArgs, collected: &collectedResponses, hasTTY: hasTTY) + let promptedResponses = try UserPrompter.prompt( + for: missingArgs, + collected: &collectedResponses, + hasTTY: self.hasTTY + ) // Combine all inherited + current-level responses let allCurrentResponses = inheritedResponses + providedResponses + promptedResponses - let currentArgNames = Set(allArgs.map { $0.valueName }) + let currentArgNames = Set(allArgs.map(\.valueName)) let currentCommandResponses = allCurrentResponses.filter { currentArgNames.contains($0.argument.valueName) } let currentArgs = self.buildCommandLine(from: currentCommandResponses) @@ -255,15 +262,15 @@ public final class TemplatePromptingSystem { // Try to auto-detect a subcommand from leftover args if let (index, matchedSubcommand) = leftoverArgs .enumerated() - .compactMap({ (i, token) -> (Int, CommandInfoV0)? in + .compactMap({ i, token -> (Int, CommandInfoV0)? in if let match = subCommands.first(where: { $0.commandName == token }) { print("Detected subcommand '\(match.commandName)' from user input.") return (i, match) } return nil }) - .first { - + .first + { var newTrail = subcommandTrail newTrail.append(matchedSubcommand.commandName) @@ -280,7 +287,7 @@ public final class TemplatePromptingSystem { return subCommandLine } else { // Fall back to interactive prompt - if !hasTTY { + if !self.hasTTY { throw TemplateError.noTTYForSubcommandSelection } let chosenSubcommand = try self.promptUserForSubcommand(for: subCommands) @@ -316,7 +323,6 @@ public final class TemplatePromptingSystem { /// - Throws: This method does not throw directly, but may propagate errors thrown by downstream callers. private func promptUserForSubcommand(for commands: [CommandInfoV0]) throws -> CommandInfoV0 { - print("Choose from the following:\n") for command in commands { @@ -358,6 +364,7 @@ public final class TemplatePromptingSystem { return filteredSubcommands } + /// Parses predetermined arguments and validates the arguments /// /// This method converts user's predetermined arguments into the ArgumentResponse struct @@ -381,8 +388,8 @@ public final class TemplatePromptingSystem { var tokens = input var terminatorSeen = false var postTerminatorArgs: [String] = [] - - let subcommandNames = Set(subcommands.map { $0.commandName }) + + let subcommandNames = Set(subcommands.map(\.commandName)) let positionalArgs = definedArgs.filter { $0.kind == .positional } // Handle terminator (--) for post-terminator parsing @@ -391,12 +398,12 @@ public final class TemplatePromptingSystem { tokens = Array(tokens[.. [String] { + private func parseOptionValues( + arg: ArgumentInfoV0, + tokens: inout [String], + currentIndex: inout Int + ) throws -> [String] { var values: [String] = [] - + switch arg.parsingStrategy { case .default: // Expect the next token to be a value and parse it @@ -531,14 +546,14 @@ public final class TemplatePromptingSystem { } values.append(tokens[currentIndex]) tokens.remove(at: currentIndex) - + case .scanningForValue: // Parse the next token as a value if it exists if currentIndex < tokens.count { values.append(tokens[currentIndex]) tokens.remove(at: currentIndex) } - + case .unconditional: // Parse the next token as a value, regardless of its type guard currentIndex < tokens.count else { @@ -546,14 +561,14 @@ public final class TemplatePromptingSystem { } values.append(tokens[currentIndex]) tokens.remove(at: currentIndex) - + case .upToNextOption: // Parse multiple values up to the next non-value while currentIndex < tokens.count && !tokens[currentIndex].starts(with: "-") { values.append(tokens[currentIndex]) tokens.remove(at: currentIndex) } - + case .allRemainingInput, .postTerminator, .allUnrecognized: // These are handled separately in the main parsing logic if currentIndex < tokens.count { @@ -561,7 +576,7 @@ public final class TemplatePromptingSystem { tokens.remove(at: currentIndex) } } - + return values } @@ -592,7 +607,7 @@ public final class TemplatePromptingSystem { /// - Returns: An array of argument info objects. Returns empty array if command has no arguments. private func convertArguments(from command: CommandInfoV0) throws -> [ArgumentInfoV0] { - return command.arguments ?? [] + command.arguments ?? [] } /// A helper struct to prompt the user for input values for command arguments. @@ -608,10 +623,9 @@ public final class TemplatePromptingSystem { collected: inout [String: ArgumentResponse], hasTTY: Bool = true ) throws -> [ArgumentResponse] { - return try arguments + try arguments .filter { $0.valueName != "help" && $0.shouldDisplay } .compactMap { arg in - // check flag or option or positional // flag: let key = arg.preferredName?.name ?? arg.valueName ?? UUID().uuidString @@ -623,8 +637,8 @@ public final class TemplatePromptingSystem { let defaultText = arg.defaultValue.map { " (default: \($0))" } ?? "" let allValuesText = (arg.allValues?.isEmpty == false) ? - " [\(arg.allValues!.joined(separator: ", "))]" : "" - let completionText = generateCompletionHint(for: arg) + " [\(arg.allValues!.joined(separator: ", "))]" : "" + let completionText = self.generateCompletionHint(for: arg) let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(completionText)\(defaultText):" var values: [String] = [] @@ -655,9 +669,10 @@ public final class TemplatePromptingSystem { if !hasTTY && arg.isOptional == false && arg.defaultValue == nil { throw TemplateError.missingRequiredArgumentWithoutTTY(name: arg.valueName ?? "") } - + if hasTTY { - let nilSuffix = arg.isOptional && arg.defaultValue == nil ? " (or enter \"nil\" to unset)" : "" + let nilSuffix = arg.isOptional && arg + .defaultValue == nil ? " (or enter \"nil\" to unset)" : "" print(promptMessage + nilSuffix) } @@ -667,12 +682,18 @@ public final class TemplatePromptingSystem { if input.lowercased() == "nil" && arg.isOptional { // Clear the values array to explicitly unset values = [] - let response = ArgumentResponse(argument: arg, values: values, isExplicitlyUnset: true) + let response = ArgumentResponse( + argument: arg, + values: values, + isExplicitlyUnset: true + ) collected[key] = response return response } if let allowed = arg.allValues, !allowed.contains(input) { - print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + print( + "Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))" + ) continue } values.append(input) @@ -684,24 +705,35 @@ public final class TemplatePromptingSystem { } else { let input = hasTTY ? readLine() : nil if let input, !input.isEmpty { - if input.lowercased() == "nil" && arg.isOptional { values = [] - let response = ArgumentResponse(argument: arg, values: values, isExplicitlyUnset: true) + let response = ArgumentResponse( + argument: arg, + values: values, + isExplicitlyUnset: true + ) collected[key] = response return response } else { if let allowed = arg.allValues, !allowed.contains(input) { if hasTTY { - print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") - print("Or try completion suggestions: \(generateCompletionSuggestions(for: arg, input: input))") + print( + "Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))" + ) + print( + "Or try completion suggestions: \(self.generateCompletionSuggestions(for: arg, input: input))" + ) exit(1) } else { - throw TemplateError.invalidValue(argument: arg.valueName ?? "", invalidValues: [input], allowed: allowed) + throw TemplateError.invalidValue( + argument: arg.valueName ?? "", + invalidValues: [input], + allowed: allowed + ) } } values = [input] - } + } } else if let def = arg.defaultValue { values = [def] } else if arg.isOptional == false { @@ -718,13 +750,12 @@ public final class TemplatePromptingSystem { collected[key] = response return response } - } /// Generates completion hint text based on CompletionKindV0 private static func generateCompletionHint(for arg: ArgumentInfoV0) -> String { guard let completionKind = arg.completionKind else { return "" } - + switch completionKind { case .list(let values): return " (suggestions: \(values.joined(separator: ", ")))" @@ -744,13 +775,13 @@ public final class TemplatePromptingSystem { return " (custom completions available)" } } - + /// Generates completion suggestions based on input and CompletionKindV0 private static func generateCompletionSuggestions(for arg: ArgumentInfoV0, input: String) -> String { guard let completionKind = arg.completionKind else { return "No completions available" } - + switch completionKind { case .list(let values): let suggestions = values.filter { $0.hasPrefix(input) } @@ -779,7 +810,7 @@ public final class TemplatePromptingSystem { static func promptForConfirmation(prompt: String, defaultBehavior: String?, isOptional: Bool) throws -> Bool? { let defaultBool = defaultBehavior?.lowercased() == "true" - var suffix = defaultBehavior != nil ? + var suffix = defaultBehavior != nil ? (defaultBool ? " [Y/n]" : " [y/N]") : " [y/n]" if isOptional && defaultBehavior == nil { @@ -789,7 +820,7 @@ public final class TemplatePromptingSystem { print(prompt + suffix, terminator: " ") guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { - if let defaultBehavior = defaultBehavior { + if let defaultBehavior { return defaultBehavior == "true" } else if isOptional { return nil @@ -799,9 +830,9 @@ public final class TemplatePromptingSystem { } switch input { - case "y", "yes": + case "y", "yes": return true - case "n", "no": + case "n", "no": return false case "nil": if isOptional { @@ -810,7 +841,7 @@ public final class TemplatePromptingSystem { throw TemplateError.missingRequiredArgumentWithoutTTY(name: "confirmation") } case "": - if let defaultBehavior = defaultBehavior { + if let defaultBehavior { return defaultBehavior == "true" } else if isOptional { return nil @@ -818,7 +849,7 @@ public final class TemplatePromptingSystem { throw TemplateError.missingRequiredArgumentWithoutTTY(name: "confirmation") } default: - if let defaultBehavior = defaultBehavior { + if let defaultBehavior { return defaultBehavior == "true" } else if isOptional { return nil @@ -843,8 +874,8 @@ public final class TemplatePromptingSystem { /// Returns the command line fragments representing this argument and its values. public var commandLineFragments: [String] { // If explicitly unset, don't generate any command line fragments - guard !isExplicitlyUnset else { return [] } - + guard !self.isExplicitlyUnset else { return [] } + guard let name = argument.valueName else { return self.values } @@ -866,7 +897,6 @@ public final class TemplatePromptingSystem { self.isExplicitlyUnset = isExplicitlyUnset } } - } /// An error enum representing various template-related errors. diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift b/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift index 9a5ea51c686..3e319632226 100644 --- a/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift +++ b/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift @@ -13,28 +13,28 @@ public struct TemporaryDirectoryHelper { public func createTemporaryDirectory(named name: String? = nil) throws -> Basics.AbsolutePath { let dirName = name ?? UUID().uuidString let dirPath = try fileSystem.tempDirectory.appending(component: dirName) - try fileSystem.createDirectory(dirPath) + try self.fileSystem.createDirectory(dirPath) return dirPath } /// Creates multiple subdirectories within a parent directory. public func createSubdirectories(in parent: Basics.AbsolutePath, names: [String]) throws -> [Basics.AbsolutePath] { - return try names.map { name in + try names.map { name in let path = parent.appending(component: name) - try fileSystem.createDirectory(path) + try self.fileSystem.createDirectory(path) return path } } /// Checks if a directory exists at the given path. public func directoryExists(_ path: Basics.AbsolutePath) -> Bool { - return fileSystem.exists(path) + self.fileSystem.exists(path) } /// Removes a directory if it exists. public func removeDirectoryIfExists(_ path: Basics.AbsolutePath) throws { - if fileSystem.exists(path) { - try fileSystem.removeFileTree(path) + if self.fileSystem.exists(path) { + try self.fileSystem.removeFileTree(path) } } @@ -44,7 +44,7 @@ public struct TemporaryDirectoryHelper { for entry in contents { let source = sourceDir.appending(component: entry) let destination = destinationDir.appending(component: entry) - try fileSystem.copy(from: source, to: destination) + try self.fileSystem.copy(from: source, to: destination) } } } diff --git a/Tests/CommandsTests/TemplateTests.swift b/Tests/CommandsTests/TemplateTests.swift index 5d7a0b25239..4fdaa0c78e9 100644 --- a/Tests/CommandsTests/TemplateTests.swift +++ b/Tests/CommandsTests/TemplateTests.swift @@ -1839,7 +1839,7 @@ struct TemplateTests { @Test func handlesConditionalNilSuffixForOptions() throws { // Test that "nil" suffix only shows for optional arguments without defaults - + // Test optional option without default, should show nil suffix let optionalWithoutDefault = ArgumentInfoV0( kind: .option, @@ -1858,7 +1858,7 @@ struct TemplateTests { abstract: "Optional parameter", discussion: nil ) - + // Test optional option with default, should NOT show nil suffix let optionalWithDefault = ArgumentInfoV0( kind: .option, @@ -1877,7 +1877,7 @@ struct TemplateTests { abstract: "Output parameter", discussion: nil ) - + // Test required option, should NOT show nil suffix let requiredOption = ArgumentInfoV0( kind: .option, @@ -1896,19 +1896,21 @@ struct TemplateTests { abstract: "Name parameter", discussion: nil ) - + // Optional without default should allow nil suffix #expect(optionalWithoutDefault.isOptional == true) #expect(optionalWithoutDefault.defaultValue == nil) - let shouldShowNilForOptionalWithoutDefault = optionalWithoutDefault.isOptional && optionalWithoutDefault.defaultValue == nil + let shouldShowNilForOptionalWithoutDefault = optionalWithoutDefault.isOptional && optionalWithoutDefault + .defaultValue == nil #expect(shouldShowNilForOptionalWithoutDefault == true) - + // Optional with default should NOT allow nil suffix #expect(optionalWithDefault.isOptional == true) #expect(optionalWithDefault.defaultValue == "stdout") - let shouldShowNilForOptionalWithDefault = optionalWithDefault.isOptional && optionalWithDefault.defaultValue == nil + let shouldShowNilForOptionalWithDefault = optionalWithDefault.isOptional && optionalWithDefault + .defaultValue == nil #expect(shouldShowNilForOptionalWithDefault == false) - + // Required should NOT allow nil suffix #expect(requiredOption.isOptional == false) let shouldShowNilForRequired = requiredOption.isOptional && requiredOption.defaultValue == nil From 3d5ebdb4f20b4962e932510e749b751d3ffb2662 Mon Sep 17 00:00:00 2001 From: John Bute Date: Thu, 2 Oct 2025 09:29:47 -0400 Subject: [PATCH 225/225] removed redeclarations mistake during merge --- Sources/Commands/PackageCommands/Init.swift | 134 -------------------- 1 file changed, 134 deletions(-) diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 1661ce9807f..4cc9cd2ca6b 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -420,138 +420,4 @@ public struct DefaultTemplateSourceResolver: TemplateSourceResolver { } } -struct PackageInitConfiguration { - let packageName: String - let cwd: Basics.AbsolutePath - let swiftCommandState: SwiftCommandState - let initMode: String? - let templateSource: InitTemplatePackage.TemplateSource? - let testLibraryOptions: TestLibraryOptions - let buildOptions: BuildCommandOptions? - let globalOptions: GlobalOptions? - let validatePackage: Bool? - let args: [String] - let versionResolver: DependencyRequirementResolver? - let directory: Basics.AbsolutePath? - let url: String? - let packageID: String? - - init( - swiftCommandState: SwiftCommandState, - name: String?, - initMode: String?, - testLibraryOptions: TestLibraryOptions, - buildOptions: BuildCommandOptions, - globalOptions: GlobalOptions, - validatePackage: Bool, - args: [String], - directory: Basics.AbsolutePath?, - url: String?, - packageID: String?, - versionFlags: VersionFlags - ) throws { - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") - } - - self.cwd = cwd - self.packageName = name ?? cwd.basename - self.swiftCommandState = swiftCommandState - self.initMode = initMode - self.testLibraryOptions = testLibraryOptions - self.buildOptions = buildOptions - self.globalOptions = globalOptions - self.validatePackage = validatePackage - self.args = args - self.directory = directory - self.url = url - self.packageID = packageID - - let sourceResolver = DefaultTemplateSourceResolver() - self.templateSource = sourceResolver.resolveSource( - directory: directory, - url: url, - packageID: packageID - ) - - if self.templateSource != nil { - self.versionResolver = DependencyRequirementResolver( - packageIdentity: packageID, - swiftCommandState: swiftCommandState, - exact: versionFlags.exact, - revision: versionFlags.revision, - branch: versionFlags.branch, - from: versionFlags.from, - upToNextMinorFrom: versionFlags.upToNextMinorFrom, - to: versionFlags.to - ) - } else { - self.versionResolver = nil - } - } - - func makeInitializer() throws -> PackageInitializer { - if let templateSource, - let versionResolver, - let buildOptions, - let globalOptions, - let validatePackage - { - TemplatePackageInitializer( - packageName: self.packageName, - cwd: self.cwd, - templateSource: templateSource, - templateName: self.initMode, - templateDirectory: self.directory, - templateURL: self.url, - templatePackageID: self.packageID, - versionResolver: versionResolver, - buildOptions: buildOptions, - globalOptions: globalOptions, - validatePackage: validatePackage, - args: self.args, - swiftCommandState: self.swiftCommandState - ) - } else { - StandardPackageInitializer( - packageName: self.packageName, - initMode: self.initMode, - testLibraryOptions: self.testLibraryOptions, - cwd: self.cwd, - swiftCommandState: self.swiftCommandState - ) - } - } -} - -public struct VersionFlags { - let exact: Version? - let revision: String? - let branch: String? - let from: Version? - let upToNextMinorFrom: Version? - let to: Version? -} - -protocol TemplateSourceResolver { - func resolveSource( - directory: Basics.AbsolutePath?, - url: String?, - packageID: String? - ) -> InitTemplatePackage.TemplateSource? -} - -public struct DefaultTemplateSourceResolver: TemplateSourceResolver { - func resolveSource( - directory: Basics.AbsolutePath?, - url: String?, - packageID: String? - ) -> InitTemplatePackage.TemplateSource? { - if url != nil { return .git } - if packageID != nil { return .registry } - if directory != nil { return .local } - return nil - } -} - extension InitPackage.PackageType: ExpressibleByArgument {}