diff --git a/Documentation/Templates.md b/Documentation/Templates.md new file mode 100644 index 00000000000..efd31a06c19 --- /dev/null +++ b/Documentation/Templates.md @@ -0,0 +1,496 @@ +# 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 + +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 `--validate-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 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 + +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: [ + .template( + name: "LibraryTemplate", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + initialPackageType: .library, + description: "Generate a Swift library package" + ), + .template( + 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 package defaults + +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 +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 diff --git a/Examples/init-templates/Package.swift b/Examples/init-templates/Package.swift new file mode 100644 index 00000000000..7daaaf380b6 --- /dev/null +++ b/Examples/init-templates/Package.swift @@ -0,0 +1,69 @@ +// 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", 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"), + ], + 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..75cbda45f14 --- /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..64387ae4a97 --- /dev/null +++ b/Examples/init-templates/Plugins/ServerTemplatePlugin/ServerTemplatePlugin.swift @@ -0,0 +1,51 @@ +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): + """ + + 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..ded60788c5d --- /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): + """ + + 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..be69323e597 --- /dev/null +++ b/Examples/init-templates/Plugins/Template2Plugin/Template2Plugin.swift @@ -0,0 +1,34 @@ +// +// Template2Plugin.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..4a58c0d164b --- /dev/null +++ b/Examples/init-templates/Templates/PartsService/main.swift @@ -0,0 +1,354 @@ +import ArgumentParser +import Foundation +import SystemPackage + +enum 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 { + 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: 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/\(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" + 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..57fa8bb5d31 --- /dev/null +++ b/Examples/init-templates/Templates/ServerTemplate/main.swift @@ -0,0 +1,1791 @@ +import ArgumentParser +import Foundation +import SystemPackage + +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() + ) + 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: + "CRUD" + case .bare: + "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 { + 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 { + 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 { + 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() {} + public 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) + + guard let url = URL(string: logPath) else { + throw ValidationError("Invalid log path: \(self.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 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 + + 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 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: \(self.postgresURL)") + } + + let postgresURLComponents = CLIURL(url) + + let packageDir = FilePath(pkgDir) + + 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") + } +} + +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 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: \(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") + } +} + +// 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 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 + 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..719d6812449 --- /dev/null +++ b/Examples/init-templates/Templates/Template1/Template.swift @@ -0,0 +1,66 @@ +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" / 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, \(self.name)!") + + """.write(toFile: mainFile) + + if self.includeReadme { + try """ + # \(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/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..d60e7a69303 --- /dev/null +++ b/Examples/init-templates/Templates/Template2/Template.swift @@ -0,0 +1,123 @@ +// TEMPLATE: TemplateCLI + +import ArgumentParser +import Foundation +import PathKit +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: + "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: "\(self.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..8524b0cdfea --- /dev/null +++ b/Examples/init-templates/Tests/PartsServiceTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing + +@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 template1_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 = 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] + ) + + #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..f7f02e72c7d --- /dev/null +++ b/Examples/init-templates/Tests/ServerTemplateTests/ServerTemplateTests.swift @@ -0,0 +1,59 @@ +import Foundation +@testable import ServerTemplate +import Testing + +struct CrudServerFilesTests { + @Test + func genTelemetryFileContainsLoggingConfig() { + 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 genEntryPointFileContainsServerAddressAndPort() { + 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 openAPIConfigContainsGenerateSection() { + let config = CrudServerFiles.getOpenAPIConfig() + #expect(config.contains("generate:")) + #expect(config.contains("- types")) + #expect(config.contains("- server")) + } +} + +struct APIHandlerTests { + @Test + func genAPIHandlerIncludesOperations() { + 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..be7662df5d0 --- /dev/null +++ b/Examples/init-templates/Tests/TemplateTest.swift @@ -0,0 +1,80 @@ +import Foundation +import Testing + +// 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 template1_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 = 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") + + // 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/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Package.swift b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Package.swift new file mode 100644 index 00000000000..fb2f054ddbf --- /dev/null +++ b/Fixtures/Miscellaneous/DirectoryManagerFinalize/generated-package/Package.swift @@ -0,0 +1,15 @@ +// 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..35e35bd52ad --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Package.swift @@ -0,0 +1,67 @@ +// swift-tools-version: 6.3.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..938a1073da2 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeBuildToolPluginPlugin/main.swift @@ -0,0 +1,12 @@ +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..938a1073da2 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeCommandPluginPlugin/main.swift @@ -0,0 +1,12 @@ +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..938a1073da2 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeEmptyPlugin/main.swift @@ -0,0 +1,12 @@ +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..938a1073da2 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeExecutablePlugin/main.swift @@ -0,0 +1,12 @@ +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..938a1073da2 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeLibraryPlugin/main.swift @@ -0,0 +1,12 @@ +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..938a1073da2 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeMacroPlugin/main.swift @@ -0,0 +1,12 @@ +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..938a1073da2 --- /dev/null +++ b/Fixtures/Miscellaneous/InferPackageType/Plugins/initialTypeToolPlugin/main.swift @@ -0,0 +1,12 @@ +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/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift new file mode 100644 index 00000000000..9cc1e41cac4 --- /dev/null +++ b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:6.3.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..3e9df21fa0e --- /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): + """ + + 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..719d6812449 --- /dev/null +++ b/Fixtures/Miscellaneous/InitTemplates/ExecutableTemplate/Templates/ExecutableTemplate/Template.swift @@ -0,0 +1,66 @@ +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" / 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, \(self.name)!") + + """.write(toFile: mainFile) + + if self.includeReadme { + try """ + # \(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/app/Package.swift b/Fixtures/Miscellaneous/ShowExecutables/app/Package.swift index 389ab1be39c..7945bacab8a 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:6.3.0 import PackageDescription let package = Package( @@ -6,16 +6,20 @@ let package = Package( products: [ .executable( name: "dealer", - targets: ["Dealer"] + targets: ["dealer"] ), - ], + ] + .template(name: "TemplateExample"), dependencies: [ .package(path: "../deck-of-playing-cards"), ], 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/ShowTemplates/app/Package.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift new file mode 100644 index 00000000000..db9c0da70ea --- /dev/null +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version:6.3.0 +import PackageDescription + +let package = Package( + 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: [ + .executableTarget( + name: "dealer", + ), + ] + .template( + name: "GenerateFromTemplate", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "SystemPackage", package: "swift-system"), + ], + 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/GenerateFromTemplatePlugin/plugin.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateFromTemplatePlugin/plugin.swift new file mode 100644 index 00000000000..b74943ccbd4 --- /dev/null +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Plugins/GenerateFromTemplatePlugin/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: "GenerateFromTemplate") + 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/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/GenerateFromTemplate/Template.swift b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateFromTemplate/Template.swift new file mode 100644 index 00000000000..91b39d8df9f --- /dev/null +++ b/Fixtures/Miscellaneous/ShowTemplates/app/Templates/GenerateFromTemplate/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 { + print("we got here") + let fs = FileManager.default + + let rootDir = FilePath(fs.currentDirectoryPath) + + 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, \(self.name)!") + + """.write(toFile: mainFile) + + if self.includeReadme { + try """ + # \(self.name) + This is a new Swift app! + """.write(toFile: rootDir / "README.md") + } + + print("Project generated at \(rootDir)") + } +} diff --git a/Package.swift b/Package.swift index f39bb5399ff..e6b8aca09f8 100644 --- a/Package.swift +++ b/Package.swift @@ -553,7 +553,8 @@ let package = Package( "SourceControl", "SPMBuildCore", .product(name: "OrderedCollections", package: "swift-collections"), - ], + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + swiftSyntaxDependencies(["SwiftIDEUtils", "SwiftParser", "SwiftRefactor"]), exclude: ["CMakeLists.txt"], swiftSettings: commonExperimentalFeatures + [ .unsafeFlags(["-static"]), @@ -611,7 +612,7 @@ let package = Package( "Workspace", "XCBuildSupport", "SwiftBuildSupport", - "SwiftFixIt", + "SwiftFixIt" ] + swiftSyntaxDependencies(["SwiftIDEUtils", "SwiftRefactor"]), exclude: ["CMakeLists.txt", "README.md"], swiftSettings: swift6CompatibleExperimentalFeatures + [ @@ -1104,7 +1105,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/README.md b/README.md index 1014c6f97c8..1b1f8bcde16 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 --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 --type 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. diff --git a/Sources/Build/BuildPlan/BuildPlan+Test.swift b/Sources/Build/BuildPlan/BuildPlan+Test.swift index 545ecb2702f..35d7ef8b6d0 100644 --- a/Sources/Build/BuildPlan/BuildPlan+Test.swift +++ b/Sources/Build/BuildPlan/BuildPlan+Test.swift @@ -289,6 +289,7 @@ private extension PackageModel.SwiftModule { packageAccess: packageAccess, buildSettings: buildSettings, usesUnsafeFlags: false, + template: false, // test entry points are not templates implicit: true ) } diff --git a/Sources/Commands/PackageCommands/Init.swift b/Sources/Commands/PackageCommands/Init.swift index 7057845a3df..4cc9cd2ca6b 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -11,80 +11,411 @@ //===----------------------------------------------------------------------===// import ArgumentParser + import Basics @_spi(SwiftPMInternal) import CoreCommands import PackageModel -import Workspace import SPMBuildCore +import TSCUtility +import Workspace extension SwiftPackageCommand { - struct Init: SwiftCommand { - public static let configuration = CommandConfiguration( - abstract: "Initialize a new package.") + /// Initialize a new package. + struct Init: AsyncSwiftCommand { + static let configuration = CommandConfiguration( + abstract: "Initialize a new package." + ) @OptionGroup(visibility: .hidden) var globalOptions: GlobalOptions - + @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. - """)) - var initMode: InitPackage.PackageType = .library + 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. + """) + ) + var initMode: String? /// Which testing libraries to use (and any related options.) - @OptionGroup() + @OptionGroup(visibility: .hidden) var testLibraryOptions: TestLibraryOptions + /// Provide custom package name. @Option(name: .customLong("name"), help: "Provide custom package name.") var packageName: String? + @OptionGroup(visibility: .hidden) + var buildOptions: BuildCommandOptions + + /// Path to a local template. + @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 package containing a template.") + var templateURL: String? + + /// Package Registry ID 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 + + /// 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? + + /// 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 output." + ) + var validatePackage: Bool = false + + /// 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 { - guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { - throw InternalError("Could not find the current working directory") + func run(_ swiftCommandState: SwiftCommandState) async throws { + let versionFlags = VersionFlags( + exact: exact, + revision: revision, + branch: branch, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ) + + 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, + versionFlags: versionFlags + ) + + let initializer = try state.makeInitializer() + try await initializer.run() + } + + 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 + } + } +} + +/// Holds the configuration needed to initialize a package. +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") + } + + 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 + 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( + cwd: cwd, + fileSystem: swiftCommandState.fileSystem, + observabilityScope: swiftCommandState.observabilityScope + ) + + self.templateSource = sourceResolver.resolveSource( + directory: directory, + url: url, + packageID: packageID + ) + + if self.templateSource != nil { + // we force wrap as we already do the the nil check. + do { + try sourceResolver.validate( + templateSource: self.templateSource!, + directory: self.directory, + url: self.url, + packageID: self.packageID + ) + } catch { + swiftCommandState.observabilityScope.emit(error) } - let packageName = self.packageName ?? cwd.basename + 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 + ) + } + } +} + +/// Represents version flags for package dependencies. +public struct VersionFlags { + let exact: Version? + let revision: String? + let branch: String? + let from: Version? + let upToNextMinorFrom: Version? + let to: Version? +} + +/// Protocol for resolving template sources from configuration parameters. +protocol TemplateSourceResolver { + func resolveSource( + directory: Basics.AbsolutePath?, + url: String?, + packageID: String? + ) -> InitTemplatePackage.TemplateSource? + + func validate( + templateSource: InitTemplatePackage.TemplateSource, + directory: Basics.AbsolutePath?, + url: String?, + packageID: String? + ) throws +} + +/// Default implementation of template source resolution. +public struct DefaultTemplateSourceResolver: TemplateSourceResolver { + let cwd: AbsolutePath + let fileSystem: FileSystem + let observabilityScope: ObservabilityScope - // 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) + 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 + } + + /// Validates the provided template source configuration. + func validate( + templateSource: InitTemplatePackage.TemplateSource, + directory: Basics.AbsolutePath?, + url: String?, + packageID: String? + ) throws { + switch templateSource { + case .git: + guard let url, isValidGitSource(url, fileSystem: fileSystem) else { + throw SourceResolverError.invalidGitURL(url ?? "nil") } - if testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: swiftCommandState) || - (initMode != .macro && testLibraryOptions.isEnabled(.swiftTesting, swiftCommandState: swiftCommandState)) { - supportedTestingLibraries.insert(.swiftTesting) + + case .registry: + guard let packageID, isValidRegistryPackageIdentity(packageID) else { + throw SourceResolverError.invalidRegistryIdentity(packageID ?? "nil") } - 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) + case .local: + guard let directory else { + throw SourceResolverError.missingLocalPath + } + + try self.isValidSwiftPackage(path: directory) + } + } + + /// 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://") + { + 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 + } + + /// Validates that the provided path exists and is accessible. + private func isValidSwiftPackage(path: AbsolutePath) throws { + if !self.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): + "Invalid local path: \(path) does not exist or is not accessible." + case .invalidGitURL(let url): + "Invalid Git URL: \(url) is not a valid Git source." + case .invalidRegistryIdentity(let id): + "Invalid registry package identity: \(id) is not a valid registry package." + case .missingLocalPath: + "Missing local path for template source." } - try initPackage.writePackageStructure() } } } diff --git a/Sources/Commands/PackageCommands/ShowExecutables.swift b/Sources/Commands/PackageCommands/ShowExecutables.swift index c1e50248b19..18c0c26532d 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.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 new file mode 100644 index 00000000000..e31ce9b821f --- /dev/null +++ b/Sources/Commands/PackageCommands/ShowTemplates.swift @@ -0,0 +1,270 @@ +//===----------------------------------------------------------------------===// +// +// 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 ArgumentParser +import Basics +import CoreCommands +import Foundation +import PackageGraph +import PackageModel +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. +/// 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." + ) + + @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("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`. + @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? + + func run(_ swiftCommandState: SwiftCommandState) async throws { + guard let cwd = swiftCommandState.fileSystem.currentWorkingDirectory else { + throw InternalError("Could not find the current working directory") + } + + let sourceResolver = DefaultTemplateSourceResolver( + cwd: cwd, + fileSystem: swiftCommandState.fileSystem, + observabilityScope: swiftCommandState.observabilityScope + ) + + let templateSource = sourceResolver.resolveSource( + directory: cwd, url: self.templateURL, packageID: self.templatePackageID + ) + + 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) + } + } + } + + 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, + from: from, + upToNextMinorFrom: upToNextMinorFrom, + to: to + ) + + var sourceControlRequirement: SwiftRefactor.PackageDependency.SourceControl.Requirement? + var registryRequirement: SwiftRefactor.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, + templateDirectory: swiftCommandState.fileSystem.currentWorkingDirectory, + templateURL: self.templateURL, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + packageIdentity: self.templatePackageID, + swiftCommandState: swiftCommandState + ).resolve() + } + + 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(\.identity) + + 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() + + 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 + + if let target = targets.first(where: { $0.name == template }), + let options = target.templateInitializationOptions, + case .packageInit(_, _, let description) = options + { + return description + } + + 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: path) { _, _ in + try await self.getDescription(swiftCommandState, template: template.name) + } + if let package = template.package { + print("\(template.name) (\(package)) : \(description)") + } else { + print("\(template.name) : \(description)") + } + } + + 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) + } + } + } + + 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. + 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 { + /// Output as a simple list of template names. + case flatlist + /// Output as a JSON array of template objects. + case json + + init?(rawValue: String) { + switch rawValue.lowercased() { + case "flatlist": + self = .flatlist + case "json": + self = .json + default: + return nil + } + } + + var description: String { + switch self { + case .flatlist: "flatlist" + case .json: "json" + } + } + } +} 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, 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 diff --git a/Sources/Commands/SwiftTestCommand.swift b/Sources/Commands/SwiftTestCommand.swift index 669f04dab3b..2056d6b9be6 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -261,7 +261,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)]) diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift new file mode 100644 index 00000000000..b63972d242d --- /dev/null +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -0,0 +1,592 @@ +import ArgumentParser +import ArgumentParserToolInfo + +@_spi(SwiftPMInternal) +import Basics + +import _Concurrency + +@_spi(SwiftPMInternal) +import CoreCommands + +import Dispatch +import Foundation +import PackageGraph +@_spi(PackageRefactor) import SwiftRefactor +@_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 + } + } +} + +extension SwiftTestCommand { + /// Test the various outputs of a template. + 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 + + /// 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.", + completion: .directory + ) + var outputDirectory: AbsolutePath + + @OptionGroup(visibility: .hidden) + var buildOptions: BuildCommandOptions + + /// Predetermined arguments specified by the consumer. + @Argument( + help: "Predetermined arguments to pass for testing template." + ) + var args: [String] = [] + + /// Specify the branch of the template you want to test. + @Option( + name: .customLong("branches"), + parsing: .upToNextOption, + help: "Specify the branch of the template you want to test. Format: --branches branch1 branch2", + ) + var branches: [String] = [] + + /// Dry-run to display argument tree. + @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 InternalError("Could not find the current working directory") + } + + 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 ? + self.globalOptions.build.buildSystem : + swiftCommandState.options.build.buildSystem + + let resolvedTemplateName: String = if self.templateName == nil { + try await self.findTemplateName(from: cwd, swiftCommandState: swiftCommandState) + } else { + self.templateName! + } + + let pluginManager = try await TemplateTesterPluginManager( + swiftCommandState: swiftCommandState, + template: resolvedTemplateName, + scratchDirectory: cwd, + args: args, + branches: branches, + buildSystem: buildSystem, + ) + + let commandPlugin: ResolvedModule = try pluginManager.loadTemplatePlugin() + + let commandLineFragments: [CommandPath] = try await pluginManager.run() + + if self.dryRun { + for commandLine in commandLineFragments { + print(commandLine.displayFormat()) + } + return + } + 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 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) + } + } catch { + swiftCommandState.observabilityScope.emit(error) + } + } + + 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)") + do { + try FileManager.default.createDirectory(at: destinationPath.asURL, withIntermediateDirectories: true) + } catch { + throw TestTemplateCommandError.directoryCreationFailed(destinationPath.pathString) + } + + return try await self.testTemplateInitialization( + commandPlugin: commandPlugin, + swiftCommandState: swiftCommandState, + buildOptions: self.buildOptions, + destinationAbsolutePath: destinationPath, + testingFolderName: folderName, + argumentPath: commandLine, + initialPackageType: packageType, + cwd: cwd, + buildSystem: buildSystem + ) + } + + 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) + } else { + print("Failed to convert JSON data to string") + } + } catch { + print("Failed to encode JSON: \(error.localizedDescription)") + } + } + + 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 TestTemplateCommandError.invalidManifestInTemplate + } + + var targetName = self.templateName + + if targetName == nil { + targetName = try self.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 TestTemplateCommandError.templateNotFound(targetName ?? "") + } + + 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 TestTemplateCommandError.noTemplatesInManifest + case 1: + return templateTargets[0] + default: + 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 self.findTemplateName(from: manifest) + } + } + + private func testTemplateInitialization( + commandPlugin: ResolvedModule, + swiftCommandState: SwiftCommandState, + buildOptions: BuildCommandOptions, + destinationAbsolutePath: AbsolutePath, + testingFolderName: String, + argumentPath: [CommandComponent], + initialPackageType: InitPackage.PackageType, + cwd: AbsolutePath, + buildSystem: BuildSystemProvider.Kind + ) 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(.init(path: cwd.pathString)), + 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() + } + + try await TemplateBuildSupport.buildForTesting( + swiftCommandState: swiftCommandState, + buildOptions: buildOptions, + testingFolder: destinationAbsolutePath + ) + + // Build flat command with all subcommands and arguments + 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!, + packageGraph: graph, + buildSystemKind: buildSystem, + arguments: flatCommand, + swiftCommandState: swiftCommandState, + requestPermission: false + ) + guard let pluginOutput = String(data: output, encoding: .utf8) else { + throw TestTemplateCommandError.invalidUTF8Encoding(output) + } + print(pluginOutput) + } + + genDuration = startGen.distance(to: .now()) + genSuccess = true + 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, + 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 buildError = TestTemplateCommandError.buildFailed(error.localizedDescription) + swiftCommandState.observabilityScope.emit(buildError) + + let errorLog = destinationAbsolutePath.appending("build-output.log") + logPath = try? self.captureAndWriteError( + to: errorLog, + error: error, + context: "Build Output (before failure)" + ) + } + } + + return BuildInfo( + generationDuration: genDuration, + buildDuration: buildDuration, + generationSuccess: genSuccess, + buildSuccess: buildSuccess, + logFilePath: logPath + ) + } + + 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(\.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 = + """ + 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) { + #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 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, + CaseIterable + { + case matrix + case json + + 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(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) + } + } + + 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." + } + } + } + } +} + +extension String { + private func padded(_ toLength: Int) -> String { + self.padding(toLength: toLength, withPad: " ", startingAt: 0) + } +} diff --git a/Sources/Commands/Utilities/PluginDelegate.swift b/Sources/Commands/Utilities/PluginDelegate.swift index 715da6aad51..39f5b8e7ebc 100644 --- a/Sources/Commands/Utilities/PluginDelegate.swift +++ b/Sources/Commands/Utilities/PluginDelegate.swift @@ -28,12 +28,15 @@ final class PluginDelegate: PluginInvocationDelegate { let buildSystem: BuildSystemProvider.Kind let plugin: PluginModule var lineBufferedOutput: Data + let echoOutput: Bool + var diagnostics: [Basics.Diagnostic] = [] - 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,15 +50,21 @@ 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)) + } } } 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/PackageDependencyBuilder.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift new file mode 100644 index 00000000000..5d32392d7bc --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageDependencyBuilder.swift @@ -0,0 +1,121 @@ +//===----------------------------------------------------------------------===// +// +// 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 TSCBasic +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 +/// (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() throws -> PackageDependency +} + +/// 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? + + /// 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. + /// + /// - 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() throws -> PackageDependency { + switch self.templateSource { + case .local: + return .fileSystem(.init(path: self.resolvedTemplatePath.asURL.path)) + + case .git: + guard let url = templateURL else { + throw PackageDependencyBuilderError.missingGitURLOrPath + } + guard let requirement = sourceControlRequirement else { + throw PackageDependencyBuilderError.missingGitRequirement + } + return .sourceControl(.init(location: url, requirement: requirement)) + + case .registry: + guard let id = templatePackageID else { + throw PackageDependencyBuilderError.missingRegistryIdentity + } + guard let requirement = registryRequirement else { + throw PackageDependencyBuilderError.missingRegistryRequirement + } + return .registry(.init(identity: id, requirement: requirement)) + } + } + + /// Errors thrown by `TemplatePathResolver` during initialization. + enum PackageDependencyBuilderError: LocalizedError, Equatable { + case missingGitURLOrPath + case missingGitRequirement + case missingRegistryIdentity + case missingRegistryRequirement + + var errorDescription: String? { + switch self { + case .missingGitURLOrPath: + "Missing Git URL or path for template from git." + case .missingGitRequirement: + "Missing version requirement for template from git." + case .missingRegistryIdentity: + "Missing registry package identity for template from registry." + case .missingRegistryRequirement: + "Missing version requirement for template from registry ." + } + } + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift new file mode 100644 index 00000000000..8b4bd93ef15 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializationDirectoryManager.swift @@ -0,0 +1,71 @@ +import Basics +import CoreCommands +import Foundation +import Workspace + +import Basics +import CoreCommands +import Foundation +import PackageModel + +public struct TemplateInitializationDirectoryManager { + let observabilityScope: ObservabilityScope + let fileSystem: FileSystem + let helper: TemporaryDirectoryHelper + + public init(fileSystem: FileSystem, observabilityScope: ObservabilityScope) { + self.fileSystem = fileSystem + self.helper = TemporaryDirectoryHelper(fileSystem: fileSystem) + self.observabilityScope = observabilityScope + } + + 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 { + 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 { + _ = try await swiftCommandState.withTemporaryWorkspace(switchingTo: path) { _, _ in + try SwiftPackageCommand.Clean().run(swiftCommandState) + } + } + + public func cleanupTemporary( + templateSource: InitTemplatePackage.TemplateSource, + path: Basics.AbsolutePath, + temporaryDirectory: Basics.AbsolutePath? + ) throws { + do { + switch templateSource { + case .git, .registry: + if FileManager.default.fileExists(atPath: path.pathString) { + try FileManager.default.removeItem(at: path.asURL) + } + case .local: + break + } + + if let tempDir = temporaryDirectory { + try self.helper.removeDirectoryIfExists(tempDir) + } + + } catch { + throw DirectoryManagerError.cleanupFailed(path: temporaryDirectory, underlying: error) + } + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift new file mode 100644 index 00000000000..8d0db556a98 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift @@ -0,0 +1,335 @@ +import ArgumentParser +import ArgumentParserToolInfo + +import Basics + +@_spi(SwiftPMInternal) +import CoreCommands + +import Foundation +import PackageGraph +import SPMBuildCore +@_spi(PackageRefactor) import SwiftRefactor +import TSCBasic +import TSCUtility +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 + 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 + + /// Runs the template initialization process. + func run() async throws { + do { + var sourceControlRequirement: PackageDependency.SourceControl.Requirement? + var registryRequirement: PackageDependency.Registry.Requirement? + + self.swiftCommandState.observabilityScope + .emit(debug: "Fetching versioning requirements and resolving path of template on local disk.") + + switch self.templateSource { + case .local: + sourceControlRequirement = nil + registryRequirement = nil + case .git: + sourceControlRequirement = try? self.versionResolver.resolveSourceControl() + registryRequirement = nil + case .registry: + sourceControlRequirement = nil + registryRequirement = try? await self.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: self.swiftCommandState.observabilityScope + ) + let (stagingPath, cleanupPath, tempDir) = try directoryManager.createTemporaryDirectories() + + self.swiftCommandState.observabilityScope + .emit(debug: "Inferring initial type of consumer's package based on template's specifications.") + + let resolvedTemplateName: String = if self.templateName == nil { + try await self.findTemplateName(from: resolvedTemplatePath) + } else { + self.templateName! + } + + let packageType = try await TemplatePackageInitializer.inferPackageType( + from: resolvedTemplatePath, + templateName: resolvedTemplateName, + swiftCommandState: self.swiftCommandState + ) + + let builder = DefaultPackageDependencyBuilder( + templateSource: templateSource, + packageName: packageName, + templateURL: templateURL, + templatePackageID: templatePackageID, + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + resolvedTemplatePath: resolvedTemplatePath + ) + + let templatePackage = try setUpPackage(builder: builder, packageType: packageType, stagingPath: stagingPath) + + self.swiftCommandState.observabilityScope + .emit(debug: "Finished setting up initial package: \(templatePackage.packageName).") + + self.swiftCommandState.observabilityScope.emit(debug: "Building package with dependency on template.") + + try await TemplateBuildSupport.build( + swiftCommandState: self.swiftCommandState, + buildOptions: self.buildOptions, + globalOptions: self.globalOptions, + cwd: stagingPath, + transitiveFolder: stagingPath + ) + + self.swiftCommandState.observabilityScope + .emit(debug: "Running plugin steps, including prompting and running the template package's plugin.") + + let buildSystem = self.globalOptions.build.buildSystem != .native ? + self.globalOptions.build.buildSystem : + self.swiftCommandState.options.build.buildSystem + + try await TemplateInitializationPluginManager( + swiftCommandState: self.swiftCommandState, + template: resolvedTemplateName, + scratchDirectory: stagingPath, + args: self.args, + buildSystem: buildSystem + ).run() + + try await directoryManager.finalize( + cwd: self.cwd, + stagingPath: stagingPath, + cleanupPath: cleanupPath, + swiftCommandState: self.swiftCommandState + ) + + if self.validatePackage { + try await TemplateBuildSupport.build( + swiftCommandState: self.swiftCommandState, + buildOptions: self.buildOptions, + globalOptions: self.globalOptions, + cwd: self.cwd + ) + } + + try directoryManager.cleanupTemporary( + templateSource: self.templateSource, + path: resolvedTemplatePath, + temporaryDirectory: tempDir + ) + + } catch { + self.swiftCommandState.observabilityScope.emit(error) + throw error + } + } + + /// Infers the package type from a template at the given path. + 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 + ) + + guard let manifest = rootManifests.values.first else { + throw TemplatePackageInitializerError.invalidManifestInTemplate(root.packages.description) + } + + var targetName = templateName + + if targetName == nil { + targetName = try TemplatePackageInitializer.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 TemplatePackageInitializerError.templateNotFound(templateName ?? "") + } + } + + /// 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, + 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) + } + } + + /// Finds the template name from a template path. + func findTemplateName(from templatePath: Basics.AbsolutePath) async throws -> String { + try await self.swiftCommandState.withTemporaryWorkspace(switchingTo: templatePath) { workspace, root in + let rootManifests = try await workspace.loadRootManifests( + packages: root.packages, + observabilityScope: self.swiftCommandState.observabilityScope + ) + + guard let manifest = rootManifests.values.first else { + throw TemplatePackageInitializerError.invalidManifestInTemplate(root.packages.description) + } + + return try TemplatePackageInitializer.findTemplateName(from: manifest) + } + } + + /// Sets up the package with the template dependency. + private func setUpPackage( + builder: DefaultPackageDependencyBuilder, + packageType: InitPackage.PackageType, + stagingPath: Basics.AbsolutePath + ) throws -> InitTemplatePackage { + let templatePackage = try InitTemplatePackage( + name: packageName, + initMode: builder.makePackageDependency(), + fileSystem: self.swiftCommandState.fileSystem, + packageType: packageType, + supportedTestingLibraries: [], + destinationPath: stagingPath, + installedSwiftPMConfiguration: self.swiftCommandState.getHostToolchain().installedSwiftPMConfiguration + ) + + try templatePackage.setupTemplateManifest() + return templatePackage + } + + /// Errors that can occur during template package initialization. + enum TemplatePackageInitializerError: Error, CustomStringConvertible { + case invalidManifestInTemplate(String) + case templateNotFound(String) + case noTemplatesInManifest + case multipleTemplatesFound([String]) + + var description: String { + switch self { + case .invalidManifestInTemplate(let path): + "Invalid manifest found in template at \(path)." + case .templateNotFound(let templateName): + "Could not find template \(templateName)." + case .noTemplatesInManifest: + "No templates with packageInit options were found in the manifest." + case .multipleTemplatesFound(let templates): + "Multiple templates found: \(templates.joined(separator: ", ")). Please specify one using --template." + } + } + } +} + +/// Initializes a package using built-in templates. +struct StandardPackageInitializer: PackageInitializer { + let packageName: String + let initMode: String? + let testLibraryOptions: TestLibraryOptions + 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 + } + guard let knownType = InitPackage.PackageType(rawValue: initModeString) else { + throw StandardPackageInitializerError.unsupportedPackageType(initModeString) + } + // Configure testing libraries + var supportedTestingLibraries = Set() + if self.testLibraryOptions.isExplicitlyEnabled(.xctest, swiftCommandState: self.swiftCommandState) || + (knownType == .macro && self.testLibraryOptions.isEnabled( + .xctest, + swiftCommandState: self.swiftCommandState + )) + { + supportedTestingLibraries.insert(.xctest) + } + if self.testLibraryOptions.isExplicitlyEnabled(.swiftTesting, swiftCommandState: self.swiftCommandState) || + (knownType != .macro && self.testLibraryOptions.isEnabled( + .swiftTesting, + swiftCommandState: self.swiftCommandState + )) + { + supportedTestingLibraries.insert(.swiftTesting) + } + + let initPackage = try InitPackage( + name: packageName, + packageType: knownType, + supportedTestingLibraries: supportedTestingLibraries, + destinationPath: cwd, + installedSwiftPMConfiguration: swiftCommandState.getHostToolchain().installedSwiftPMConfiguration, + fileSystem: self.swiftCommandState.fileSystem + ) + initPackage.progressReporter = { message in print(message) } + try initPackage.writePackageStructure() + } + + /// Errors that can occur during standard package initialization. + enum StandardPackageInitializerError: Error, CustomStringConvertible { + case missingInitMode + case unsupportedPackageType(String) + + var description: String { + switch self { + case .missingInitMode: + "Specify a package type using the --type option." + case .unsupportedPackageType(let type): + "Package type '\(type)' is not supported." + } + } + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift new file mode 100644 index 00000000000..66b1bcb69f4 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/RequirementResolver.swift @@ -0,0 +1,261 @@ +//===----------------------------------------------------------------------===// +// +// 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 CoreCommands +import PackageFingerprint +import PackageRegistry +import PackageSigning +@_spi(PackageRefactor) import SwiftRefactor +import TSCBasic +import TSCUtility +import Workspace + +import class PackageModel.Manifest +import struct PackageModel.PackageIdentity + +/// A protocol defining interfaces for resolving package dependency requirements +/// based on versioning input (e.g., version, branch, or revision). +protocol DependencyRequirementResolving { + func resolveSourceControl() throws -> SwiftRefactor.PackageDependency.SourceControl.Requirement + 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) +/// - `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 +struct DependencyRequirementResolver: DependencyRequirementResolving { + /// Package-id for registry + let packageIdentity: String? + /// SwiftCommandstate + let swiftCommandState: SwiftCommandState + /// 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? + + /// 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`. + func resolveSourceControl() throws -> SwiftRefactor.PackageDependency.SourceControl.Requirement { + var specifiedRequirements: [SwiftRefactor.PackageDependency.SourceControl.Requirement] = [] + + if let exact { + specifiedRequirements.append(.exact(exact.description)) + } + + if let branch { + specifiedRequirements.append(.branch(branch)) + } + + if let revision { + specifiedRequirements.append(.revision(revision)) + } + + if let from { + specifiedRequirements.append(.rangeFrom(from.description)) + } + + if let upToNextMinorFrom { + let range: Range = .upToNextMinor(from: upToNextMinorFrom) + specifiedRequirements.append( + .range( + lowerBound: range.lowerBound.description, + upperBound: range.upperBound.description + ) + ) + } + + guard !specifiedRequirements.isEmpty else { + throw DependencyRequirementError.noRequirementSpecified + } + + guard specifiedRequirements.count == 1, let firstRequirement = specifiedRequirements.first else { + throw DependencyRequirementError.multipleRequirementsSpecified + } + + let requirement: PackageDependency.SourceControl.Requirement + switch firstRequirement { + case .range(let lowerBound, _), .rangeFrom(let lowerBound): + requirement = if let to { + .range(lowerBound: lowerBound, upperBound: to.description) + } else { + firstRequirement + } + default: + requirement = firstRequirement + + if self.to != nil { + throw DependencyRequirementError.invalidToParameterWithoutFrom + } + } + + 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. + func resolveRegistry() async throws -> SwiftRefactor.PackageDependency.Registry.Requirement? { + if exact == nil, from == nil, upToNextMinorFrom == nil, self.to == 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.description) + } + + var specifiedRequirements: [SwiftRefactor.PackageDependency.Registry.Requirement] = [] + + if let exact { + specifiedRequirements.append(.exact(exact.description)) + } + + if let from { + specifiedRequirements.append(.rangeFrom(from.description)) + } + + if let upToNextMinorFrom { + let range: Range = .upToNextMinor(from: upToNextMinorFrom) + specifiedRequirements.append( + .range( + lowerBound: range.lowerBound.description, + upperBound: range.upperBound.description + ) + ) + } + + guard !specifiedRequirements.isEmpty else { + throw DependencyRequirementError.noRequirementSpecified + } + + guard specifiedRequirements.count == 1, let firstRequirement = specifiedRequirements.first else { + throw DependencyRequirementError.multipleRequirementsSpecified + } + + let requirement: SwiftRefactor.PackageDependency.Registry.Requirement + switch firstRequirement { + case .range(let lowerBound, _), .rangeFrom(let lowerBound): + requirement = if let to { + .range(lowerBound: lowerBound, upperBound: to.description) + } else { + firstRequirement + } + default: + requirement = firstRequirement + + if self.to != nil { + throw DependencyRequirementError.invalidToParameterWithoutFrom + } + } + + return requirement + } + + /// 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: self.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. +enum DependencyType { + /// A source control dependency, such as a Git repository. + case sourceControl + /// A registry dependency, typically resolved from a package registry. + case registry +} + +enum DependencyRequirementError: Error, CustomStringConvertible, Equatable { + case multipleRequirementsSpecified + case noRequirementSpecified + case invalidToParameterWithoutFrom + case failedToFetchLatestVersion(metadata: RegistryClient.PackageMetadata, packageIdentity: PackageIdentity) + + var description: String { + switch self { + case .multipleRequirementsSpecified: + "Specify exactly version requirement." + case .noRequirementSpecified: + "No exact or lower bound version requirement specified." + case .invalidToParameterWithoutFrom: + "--to requires --from or --up-to-next-minor-from" + case .failedToFetchLatestVersion(let metadata, let packageIdentity): + """ + Failed to fetch latest version of \(packageIdentity) + Here is the metadata of the package you were trying to query: + \(metadata) + """ + } + } + + 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 new file mode 100644 index 00000000000..afc947f45c6 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplateBuild.swift @@ -0,0 +1,140 @@ +//===----------------------------------------------------------------------===// +// +// 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 ArgumentParser +import Basics +import CoreCommands +import SPMBuildCore +import TSCBasic +import TSCUtility + +/// 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. + /// + /// - 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. + /// - 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. + /// - Any other errors thrown during workspace setup or build system creation. + static func build( + swiftCommandState: SwiftCommandState, + buildOptions: BuildCommandOptions, + globalOptions: GlobalOptions, + cwd: Basics.AbsolutePath, + transitiveFolder: Basics.AbsolutePath? = nil + ) async throws { + let packageRoot = transitiveFolder ?? globalOptions.locations.packageDirectory ?? cwd + + 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: packageRoot) { _, _ in + do { + try await buildSystem.build(subset: subset, buildOutputs: []) + } catch { + 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 + ) + + 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, buildOutputs: []) + } catch { + throw ExitCode.failure + } + } + } + + /// 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 + } + + if buildOptions.printPIFManifestGraphviz { + productsParams.printPIFManifestGraphviz = true + toolsParams.printPIFManifestGraphviz = true + } + } + + return try await swiftCommandState.withTemporaryWorkspace(switchingTo: folder) { _, _ in + try await swiftCommandState.createBuildSystem( + explicitProduct: buildOptions.product, + 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 new file mode 100644 index 00000000000..ca49ccbb430 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePathResolver.swift @@ -0,0 +1,477 @@ +//===----------------------------------------------------------------------===// +// +// 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 ArgumentParser +import Basics +import CoreCommands +import Foundation +import PackageFingerprint +import struct PackageModel.PackageIdentity +import PackageRegistry +import PackageSigning +import SourceControl +@_spi(PackageRefactor) import SwiftRefactor +import TSCBasic +import TSCUtility +import Workspace + +/// A protocol representing a generic template fetcher for Swift package templates. +/// +/// 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 +} + +/// 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 { + let fetcher: TemplateFetcher + + /// 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. + /// + /// - 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 TemplatePathResolverError.missingLocalTemplatePath + } + self.fetcher = LocalTemplateFetcher(path: path) + + case .git: + guard let url = templateURL, let requirement = sourceControlRequirement else { + throw TemplatePathResolverError.missingGitURLOrRequirement + } + self.fetcher = GitTemplateFetcher( + source: url, + requirement: requirement, + swiftCommandState: swiftCommandState + ) + + case .registry: + guard let identity = packageIdentity, let requirement = registryRequirement else { + throw TemplatePathResolverError.missingRegistryIdentityOrRequirement + } + self.fetcher = RegistryTemplateFetcher( + swiftCommandState: swiftCommandState, + packageIdentity: identity, + requirement: requirement + ) + + case .none: + throw TemplatePathResolverError.missingTemplateType + } + } + + /// 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() + } + + /// 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: + "Template path must be specified for local templates." + case .missingGitURLOrRequirement: + "Missing Git URL or requirement for git template." + case .missingRegistryIdentityOrRequirement: + "Missing registry package identity or requirement." + case .missingTemplateType: + "Missing --template-type." + } + } + } +} + +/// 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() async throws -> Basics.AbsolutePath { + self.path + } +} + +/// 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) +/// - 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. + +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. + /// - Throws: Any error encountered during repository fetch, checkout, or validation. + 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( + atPath: workingCopyPath.pathString, + withIntermediateDirectories: true + ) + + let repository = try createWorkingCopy(fromBare: bareCopyPath, at: workingCopyPath) + + try self.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) async throws { + let url = SourceControlURL(source) + let repositorySpecifier = RepositorySpecifier(url: url) + let provider = GitRepositoryProvider() + do { + try await provider.fetch(repository: repositorySpecifier, to: path) + } catch { + if self.isPermissionError(error) { + throw GitTemplateFetcherError.authenticationRequired(source: self.source, error: error) + } + self.swiftCommandState.observabilityScope.emit(error) + throw GitTemplateFetcherError.cloneFailed(source: self.source) + } + } + + /// 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") + } + + /// 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 { + throw GitTemplateFetcherError.invalidRepositoryDirectory(path: path) + } + } + + /// 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 + 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. + private func checkout(repository: WorkingCheckout) throws { + switch self.requirement { + case .exact(let versionString): + try repository.checkout(tag: versionString) + + case .branch(let name): + try repository.checkout(branch: name) + + case .revision(let revision): + try repository.checkout(revision: .init(identifier: revision)) + + 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 { + throw GitTemplateFetcherError.invalidVersionRange(lowerBound: lowerBound, upperBound: upperBound) + } + + let versionRange = lowerVersion ..< upperVersion + let filteredVersions = versions.filter { versionRange.contains($0) } + guard let latestVersion = filteredVersions.max() else { + throw GitTemplateFetcherError.noMatchingTagInVersionRange( + lowerBound: lowerBound, + upperBound: upperBound + ) + } + try repository.checkout(tag: latestVersion.description) + + case .rangeFrom(let versionString): + let tags = try repository.getTags() + let versions = tags.compactMap { Version($0) } + + guard let lowerVersion = Version(versionString) else { + throw GitTemplateFetcherError.invalidVersion(versionString) + } + + let filteredVersions = versions.filter { $0 >= lowerVersion } + guard let latestVersion = filteredVersions.max() else { + throw GitTemplateFetcherError.noMatchingTagFromVersion(versionString) + } + try repository.checkout(tag: latestVersion.description) + } + } + + 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 noMatchingTagInVersionRange(lowerBound: String, upperBound: String) + case noMatchingTagFromVersion(String) + case invalidVersionRange(lowerBound: String, upperBound: String) + case invalidVersion(String) + case authenticationRequired(source: String, error: Error) + + var errorDescription: String? { + switch self { + case .cloneFailed(let source): + "Failed to clone repository from '\(source)'" + case .invalidRepositoryDirectory(let path): + "Invalid Git repository at path: \(path.pathString)" + case .createWorkingCopyFailed(let path, let error): + "Failed to create working copy at '\(path)': \(error.localizedDescription)" + case .checkoutFailed(let requirement, let error): + "Failed to checkout using requirement '\(requirement)': \(error.localizedDescription)" + case .noMatchingTagInVersionRange(let lowerBound, let upperBound): + "No Git tags found within version range \(lowerBound)..<\(upperBound)" + case .noMatchingTagFromVersion(let version): + "No Git tags found from version \(version) or later" + case .invalidVersionRange(let lowerBound, let upperBound): + "Invalid version range: \(lowerBound)..<\(upperBound)" + case .invalidVersion(let version): + "Invalid version string: \(version)" + case .authenticationRequired(let source, let error): + "Authentication required for '\(source)'. \(error)" + } + } + + static func == (lhs: GitTemplateFetcherError, rhs: GitTemplateFetcherError) -> Bool { + lhs.errorDescription == rhs.errorDescription + } + } +} + +/// 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. + 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 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) + 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 dest = tempDir.appending(component: self.packageIdentity) + try await registryClient.downloadSourceArchive( + package: identity, + version: self.version, + destinationPath: dest, + progressHandler: nil, + timeout: nil, + fileSystem: self.swiftCommandState.fileSystem, + observabilityScope: self.swiftCommandState.observabilityScope + ) + + return dest + } + } + + /// 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 { + 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 + } + } + } + + /// Resolves the registry configuration from shared SwiftPM configuration. + /// + /// - Returns: Registry configuration to use for fetching packages. + /// - Throws: If configurations are missing or unreadable. + static func getRegistriesConfig(_ swiftCommandState: SwiftCommandState, global: Bool) throws -> Workspace + .Configuration.Registries + { + let sharedFile = Workspace.DefaultLocations + .registriesConfigurationFile(at: swiftCommandState.sharedConfigurationDirectory) + 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) + + /// 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)': \ + \(underlyingError.localizedDescription) + """ + } + } + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift new file mode 100644 index 00000000000..b56f8e687ad --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginCoordinator.swift @@ -0,0 +1,109 @@ + +import ArgumentParser +import ArgumentParserToolInfo + +import Basics + +@_spi(SwiftPMInternal) +import CoreCommands + +import Foundation +import PackageGraph +import PackageModel +import SPMBuildCore +import TSCBasic +import TSCUtility +import Workspace + +struct TemplatePluginCoordinator { + let buildSystem: BuildSystemProvider.Kind + let swiftCommandState: SwiftCommandState + let scratchDirectory: Basics.AbsolutePath + let template: String + let args: [String] + let branches: [String] + + private let EXPERIMENTAL_DUMP_HELP = ["--", "--experimental-dump-help"] + + func loadPackageGraph() async throws -> ModulesGraph { + 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 + /// + /// - 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, + buildSystem: self.buildSystem, + arguments: self.EXPERIMENTAL_DUMP_HELP, + swiftCommandState: self.swiftCommandState, + requestPermission: true + ) + + 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 .noMatchingTemplate(let name): + "No templates found matching '\(name ?? "")'" + case .multipleMatchingTemplates(let names): + "Multiple templates matched: \(names.joined(separator: ", "))" + case .failedToDecodeToolInfo(let underlying): + "Failed to decode tool info: \(underlying.localizedDescription)" + } + } + } +} + +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 new file mode 100644 index 00000000000..ba9e87c15b9 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -0,0 +1,161 @@ +import ArgumentParserToolInfo + +import Basics + +import CoreCommands +import Foundation +import PackageGraph +import SPMBuildCore +import Workspace + +public protocol TemplatePluginManager { + func loadTemplatePlugin() throws -> ResolvedModule +} + +/// Utility for executing template plugins with common patterns. +enum TemplatePluginExecutor { + static func execute( + plugin: ResolvedModule, + rootPackage: ResolvedPackage, + packageGraph: ModulesGraph, + buildSystemKind: BuildSystemProvider.Kind, + arguments: [String], + swiftCommandState: SwiftCommandState, + requestPermission: Bool = false + ) async throws -> Data { + try await TemplatePluginRunner.run( + plugin: plugin, + package: rootPackage, + packageGraph: packageGraph, + buildSystem: buildSystemKind, + arguments: arguments, + swiftCommandState: swiftCommandState, + requestPermission: requestPermission + ) + } +} + +/// A utility for obtaining and running a template's plugin . +/// +/// `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 scratchDirectory: Basics.AbsolutePath + private let args: [String] + private let packageGraph: ModulesGraph + private let coordinator: TemplatePluginCoordinator + private let buildSystem: BuildSystemProvider.Kind + + private var rootPackage: ResolvedPackage { + get throws { + guard let root = packageGraph.rootPackages.first else { + throw TemplateInitializationError.missingPackageGraph + } + return root + } + } + + init( + swiftCommandState: SwiftCommandState, + template: String, + scratchDirectory: Basics.AbsolutePath, + args: [String], + buildSystem: BuildSystemProvider.Kind + ) async throws { + let coordinator = TemplatePluginCoordinator( + buildSystem: buildSystem, + swiftCommandState: swiftCommandState, + scratchDirectory: scratchDirectory, + template: template, + args: args, + branches: [] + ) + + self.packageGraph = try await coordinator.loadPackageGraph() + self.swiftCommandState = swiftCommandState + self.template = template + self.scratchDirectory = scratchDirectory + self.args = args + self.coordinator = coordinator + 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. + /// + /// - 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( + using: plugin, + from: self.packageGraph, + rootPackage: self.rootPackage + ) + + let cliResponses: [String] = try promptUserForTemplateArguments(using: toolInfo) + + _ = try await self.runTemplatePlugin(plugin, with: cliResponses) + } + + /// 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. + /// + /// - 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] { + 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: + /// - 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. + private func runTemplatePlugin(_ plugin: ResolvedModule, with arguments: [String]) async throws -> Data { + try await TemplatePluginExecutor.execute( + plugin: plugin, + rootPackage: self.rootPackage, + packageGraph: self.packageGraph, + buildSystemKind: self.buildSystem, + arguments: arguments, + swiftCommandState: self.swiftCommandState, + requestPermission: false + ) + } + + /// 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) + } + + enum TemplateInitializationError: Error, CustomStringConvertible { + case missingPackageGraph + + var description: String { + switch self { + case .missingPackageGraph: + "No root package was found in package graph." + } + } + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift new file mode 100644 index 00000000000..11f49f76309 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginRunner.swift @@ -0,0 +1,248 @@ +//===----------------------------------------------------------------------===// +// +// 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 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 + +/// 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, + packageGraph: ModulesGraph, + buildSystem buildSystemKind: BuildSystemProvider.Kind, + arguments: [String], + swiftCommandState: SwiftCommandState, + allowNetworkConnections: [SandboxNetworkPermission] = [], + requestPermission: Bool + ) async throws -> Data { + 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) + + var writableDirs = [outputDir, package.path] + var allowedNetworkConnections = allowNetworkConnections + + if requestPermission { + try self.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: buildSystemKind, + cacheBuildManifest: false, + productsBuildParameters: swiftCommandState.productsBuildParameters, + toolsBuildParameters: buildParams, + packageGraphLoader: { packageGraph } + ) + + let accessibleTools = try await plugin.preparePluginTools( + fileSystem: swiftCommandState.fileSystem, + environment: buildParams.buildEnvironment, + 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] + ) + + if let buildPlan = buildResult.buildPlan { + if let builtTool = buildPlan.buildProducts.first(where: { + $0.product.name == name && $0.buildParameters.destination == .host + }) { + return try builtTool.binaryPath + } else { + return nil + } + } else { + return buildParams.buildPath.appending(path) + } + } + + let pluginDelegate = PluginDelegate( + swiftCommandState: swiftCommandState, + buildSystem: buildSystemKind, + plugin: pluginTarget, + echoOutput: false + ) + + let workingDir = try swiftCommandState.options.locations.packageDirectory + ?? swiftCommandState.fileSystem.currentWorkingDirectory + ?? { throw InternalError("Could not determine working directory") }() + + let success = 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: pluginDelegate + ) + + guard success else { + let stringError = pluginDelegate.diagnostics + .map(\.message) + .joined(separator: "\n") + + throw DefaultPluginScriptRunnerError.invocationFailed( + error: StringError(stringError), + command: arguments + ) + } + return pluginDelegate.lineBufferedOutput + } + + /// Safely casts a `ResolvedModule` to a `PluginModule`, or throws if invalid. + private static func getPluginModule(_ plugin: ResolvedModule) throws -> PluginModule { + guard let pluginTarget = plugin.underlying as? PluginModule else { + throw InternalError("Expected PluginModule") + } + 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, + 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) = 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.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)) + } + } + } + + /// 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)" + return (desc, reason, "--allow-network-connections") + } + } +} diff --git a/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift new file mode 100644 index 00000000000..dbf4d17db84 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift @@ -0,0 +1,45 @@ +import Basics +import CoreCommands +import Foundation +import PackageModel +import Workspace + +/// Manages directories for template testing operations. +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 + } + + /// 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 + ) throws { + let manifestPath = outputDirectoryPath.appending(component: Manifest.filename) + let fs = swiftCommandState.fileSystem + + if !self.helper.directoryExists(outputDirectoryPath) { + try FileManager.default.createDirectory( + at: outputDirectoryPath.asURL, + withIntermediateDirectories: true + ) + } else if fs.exists(manifestPath) { + 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 new file mode 100644 index 00000000000..176f291922b --- /dev/null +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -0,0 +1,1592 @@ +import ArgumentParserToolInfo + +import Basics +import CoreCommands +import Foundation +import PackageGraph +import SPMBuildCore +import Workspace + +/// A utility for obtaining and running a template's plugin during testing workflows. +/// +/// `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 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, + scratchDirectory: Basics.AbsolutePath, + args: [String], + branches: [String], + buildSystem: BuildSystemProvider.Kind + ) async throws { + let coordinator = TemplatePluginCoordinator( + buildSystem: buildSystem, + swiftCommandState: swiftCommandState, + scratchDirectory: scratchDirectory, + template: template, + args: args, + branches: branches + ) + + self.packageGraph = try await coordinator.loadPackageGraph() + self.swiftCommandState = swiftCommandState + self.template = template + self.scratchDirectory = scratchDirectory + self.args = args + self.coordinator = coordinator + self.branches = branches + 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( + using: plugin, + from: self.packageGraph, + rootPackage: self.rootPackage + ) + + 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, + args: self.args, + branches: self.branches + ) + } + + /// 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: " ") + + var result = "Command Path: \(fullPath) \nExecution Format: \n\n" + + // Build flat command format: [Command command-args sub-command sub-command-args ...] + let flatCommand = self.buildFlatCommandDisplay() + result += "\(flatCommand)\n\n" + + 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] = [] + + 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(\.commandLineFragments) + result.append(contentsOf: commandArgs) + } + + 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 { + 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") + } +} + +/// 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 + } + + /// Parses and matches provided arguments against defined argument specifications. + /// + /// 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: + /// - input: The input arguments to parse + /// - definedArgs: The argument definitions from the template tool info + /// - subcommands: Available subcommands for context during parsing + /// + /// - Returns: A tuple containing: + /// - `Set`: Successfully parsed and matched arguments + /// - `[String]`: Leftover arguments that couldn't be matched (potentially for subcommands) + /// + /// - 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], + 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(\.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 + } + + /// 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], + 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 + ) + + 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], + 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 = self.getSubCommand(from: command) ?? [] + + let (answeredArgs, leftoverArgs) = try parseAndMatchArguments( + predefinedArgs, + definedArgs: allArgs, + subcommands: subCommands + ) + + // Combine inherited responses with current parsed responses + 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(\.argument.valueName)) + let missingArgs = allArgs.filter { arg in + !providedArgNames.contains(arg.valueName) && arg.valueName != "help" && arg.shouldDisplay + } + + // Only prompt for missing arguments + var collected: [String: ArgumentResponse] = [:] + let newResolvedArgs = try UserPrompter.prompt(for: missingArgs, collected: &collected, hasTTY: self.hasTTY) + + // 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: 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) { + // 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 { + true + } else if branchDepth < (branches.count - 1) { + matchedSubcommand.commandName == branches[branchDepth + 1] + } else { + matchedSubcommand.commandName == branches[branchDepth] + } + + if shouldTraverse { + 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 { + true + } else if branchDepth < (branches.count - 1) { + sub.commandName == branches[branchDepth + 1] + } else { + 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(\.commandName).joined(separator: "-") + } + } + + /// Retrieves the list of subcommands for a given command, excluding utility commands. + /// + /// This method filters out common utility commands like "help" that are typically + /// auto-generated and not relevant for template testing scenarios. + /// + /// - Parameter command: The command to extract subcommands from + /// + /// - 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 } + + let filteredSubcommands = subcommands.filter { $0.commandName.lowercased() != "help" } + + guard !filteredSubcommands.isEmpty else { return nil } + + return filteredSubcommands + } + + /// Converts command information into an array of argument metadata. + /// + /// 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 + } + return rawArgs + } + + /// 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 users for values for missing command arguments. + /// + /// 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], + hasTTY: Bool = true + ) throws -> [ArgumentResponse] { + 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] { + 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 completionText = self.generateCompletionHint(for: arg) + let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(completionText)\(defaultText):" + + var values: [String] = [] + + 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" + ) + } + + 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: + 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 { + 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) + } + } + if values.isEmpty, let def = arg.defaultValue { + values = [def] + } + } 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 + ) + 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: \(self.generateCompletionSuggestions(for: arg, input: input))" + ) + fatalError("Invalid value provided") + } 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 { + if hasTTY { + fatalError("Required argument '\(arg.valueName ?? "")' not provided.") + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: arg.valueName ?? "") + } + } + } + } + + let response = ArgumentResponse(argument: arg, values: values, isExplicitlyUnset: false) + collected[key] = response + return response + } + } + + /// 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 "" } + + 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 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" + } + + 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" + } + } + } + + /// 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 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?, + 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 { + 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 + 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") + } + } + } + + /// 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 from the template tool information. + let argument: ArgumentInfoV0 + + /// 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] + + /// 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 + + /// 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 [] } + + guard let name = argument.valueName else { + return self.values + } + + switch self.argument.kind { + case .flag: + return self.values.first == "true" ? ["--\(name)"] : [] + case .option: + if self.argument.isRepeating { + return self.values.flatMap { ["--\(name)", $0] } + } else { + return self.values.flatMap { ["--\(name)", $0] } + } + case .positional: + return self.values + } + } + + /// 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 + } + } +} + +/// 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 file path is invalid or does not exist. + case invalidPath + + /// A Package.swift manifest file already exists in the target directory. + case manifestAlreadyExists + + /// 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 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: + "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.")" + 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/CoreCommands/SwiftCommandState.swift b/Sources/CoreCommands/SwiftCommandState.swift index a723cf99912..ac937f9e2e3 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 { @@ -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,74 @@ 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, + 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) ?? (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/PackageDescriptionSerialization.swift b/Sources/PackageDescription/PackageDescriptionSerialization.swift index 8ade7137333..a4c730e6d17 100644 --- a/Sources/PackageDescription/PackageDescriptionSerialization.swift +++ b/Sources/PackageDescription/PackageDescriptionSerialization.swift @@ -210,6 +210,32 @@ enum Serialization { case plugin(name: String, package: String?) } + enum TemplateInitializationOptions: Codable { + case packageInit(templateType: TemplateType, templatePermissions: [TemplatePermissions]?, description: String) + } + + enum TemplateType: Codable { + case library + case executable + case tool + case buildToolPlugin + case commandPlugin + case `macro` + case empty + } + + 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 +256,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..06d1663602c 100644 --- a/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift +++ b/Sources/PackageDescription/PackageDescriptionSerializationConversion.swift @@ -295,6 +295,52 @@ extension Serialization.PluginUsage { } } +extension Serialization.TemplateInitializationOptions { + init(_ usage: PackageDescription.Target.TemplateInitializationOptions) { + switch usage { + + 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 .executable: self = .executable + case .macro: self = .macro + case .library: self = .library + case .tool: self = .tool + case .buildToolPlugin: self = .buildToolPlugin + case .commandPlugin: self = .commandPlugin + case .empty: self = .empty + } + } +} + +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 +362,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/Product.swift b/Sources/PackageDescription/Product.swift index e3b6a7352c4..79e48f2a79b 100644 --- a/Sources/PackageDescription/Product.swift +++ b/Sources/PackageDescription/Product.swift @@ -187,6 +187,22 @@ public class Product { ) -> Product { return Plugin(name: name, targets: targets) } + + fileprivate static func template( + name: String + ) -> Product { + return Executable(name: name, targets: [name], settings: []) + } +} + +public extension [Product] { + @available(_PackageDescription, introduced: 6.3.0) + static func template( + name: String, + ) -> [Product] { + let templatePluginName = "\(name)Plugin" + return [Product.plugin(name: templatePluginName, targets: [templatePluginName]), Product.template(name: name)] + } } diff --git a/Sources/PackageDescription/Target.swift b/Sources/PackageDescription/Target.swift index 9c7183a82f4..e848de18ab5 100644 --- a/Sources/PackageDescription/Target.swift +++ b/Sources/PackageDescription/Target.swift @@ -229,6 +229,23 @@ public final class Target { case plugin(name: String, package: String?) } + public var templateInitializationOptions: TemplateInitializationOptions? + + public enum TemplateType: String { + case executable + case `macro` + case library + case tool + case buildToolPlugin + case commandPlugin + case empty + } + + @available(_PackageDescription, introduced: 5.9) + public enum TemplateInitializationOptions { + case packageInit(templateType: TemplateType, templatePermissions: [TemplatePermissions]? = nil, description: String) + } + /// Construct a target. @_spi(PackageDescriptionInternal) public init( @@ -250,7 +267,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 @@ -271,9 +289,19 @@ public final class Target { self.linkerSettings = linkerSettings self.checksum = checksum self.plugins = plugins + self.templateInitializationOptions = templateInitializationOptions switch type { - case .regular, .executable, .test: + case .regular, .test: + precondition( + url == nil && + pkgConfig == nil && + providers == nil && + pluginCapability == nil && + checksum == nil && + templateInitializationOptions == nil + ) + case .executable: precondition( url == nil && pkgConfig == nil && @@ -295,7 +323,8 @@ public final class Target { swiftSettings == nil && linkerSettings == nil && checksum == nil && - plugins == nil + plugins == nil && + templateInitializationOptions == nil ) case .binary: precondition( @@ -311,7 +340,8 @@ public final class Target { cxxSettings == nil && swiftSettings == nil && linkerSettings == nil && - plugins == nil + plugins == nil && + templateInitializationOptions == nil ) case .plugin: precondition( @@ -325,7 +355,8 @@ public final class Target { cxxSettings == nil && swiftSettings == nil && linkerSettings == nil && - plugins == nil + plugins == nil && + templateInitializationOptions == nil ) case .macro: precondition( @@ -336,7 +367,8 @@ public final class Target { providers == nil && pluginCapability == nil && cSettings == nil && - cxxSettings == nil + cxxSettings == nil && + templateInitializationOptions == nil ) } } @@ -581,7 +613,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, @@ -597,7 +630,8 @@ public final class Target { cxxSettings: cxxSettings, swiftSettings: swiftSettings, linkerSettings: linkerSettings, - plugins: plugins + plugins: plugins, + templateInitializationOptions: templateInitializationOptions ) } @@ -1236,6 +1270,89 @@ public final class Target { } } +public extension [Target] { + @available(_PackageDescription, introduced: 6.3.0) + static func template( + name: String, + dependencies: [Target.Dependency] = [], + path: String? = nil, + exclude: [String] = [], + 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: [Target.PluginUsage]? = nil, + initialPackageType: Target.TemplateType = .empty, + templatePermissions: [TemplatePermissions]? = nil, + description: String + ) -> [Target] { + let templatePluginName = "\(name)Plugin" + let templateExecutableName = "\(name)" + let permissions: [PluginPermission] = { + 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 templateInitializationOptions = Target.TemplateInitializationOptions.packageInit( + templateType: initialPackageType, + 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] + } +} + extension Target.Dependency { @available(_PackageDescription, obsoleted: 5.7, message: "use .product(name:package:condition) instead.") public static func productItem(name: String, package: String? = nil, condition: TargetDependencyCondition? = nil) -> Target.Dependency { @@ -1562,3 +1679,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.3.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.3.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: 6.3.0) +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/Diagnostics.swift b/Sources/PackageLoading/Diagnostics.swift index eb3bbde4777..2873ac5444e 100644 --- a/Sources/PackageLoading/Diagnostics.swift +++ b/Sources/PackageLoading/Diagnostics.swift @@ -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") } diff --git a/Sources/PackageLoading/ManifestJSONParser.swift b/Sources/PackageLoading/ManifestJSONParser.swift index 07ca0e6eacc..44240a407d8 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 ) } @@ -631,6 +633,64 @@ extension TargetDescription.PluginUsage { } } +extension TargetDescription.TemplateInitializationOptions { + init (_ usage: Serialization.TemplateInitializationOptions, identityResolver: IdentityResolver) throws { + switch usage { + case .packageInit(let templateType, let templatePermissions, let description): + self = .packageInit(templateType: .init(templateType), templatePermissions: templatePermissions?.map { .init($0) }, description: description) + } + } +} + +extension TargetDescription.TemplateType { + init(_ type: Serialization.TemplateType) { + switch type { + case .library: + self = .library + case .executable: + self = .executable + case .tool: + self = .tool + case .buildToolPlugin: + self = .buildToolPlugin + case .commandPlugin: + self = .commandPlugin + case .macro: + self = .macro + case .empty: + self = .empty + } + } +} + +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+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: "', '"))'") } diff --git a/Sources/PackageLoading/PackageBuilder.swift b/Sources/PackageLoading/PackageBuilder.swift index 08535c82807..2de6413748b 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,11 +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): - let folderName = (type == .test) ? "Tests" : (type == .plugin) ? "Plugins" : "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) of type \(type) 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: ", ") @@ -332,7 +331,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 +573,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 +587,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. @@ -607,7 +611,10 @@ public final class PackageBuilder { fs: fileSystem, 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 +644,23 @@ 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 .executable: + if target.templateInitializationOptions != nil { + predefinedTemplateTargetDirectory + } else { + predefinedTargetDirectory + } + default: + predefinedTargetDirectory + } + }() + let path = predefinedDir.path.appending(component: target.name) // Return the path if the predefined directory contains it. @@ -670,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 ) } @@ -716,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: PackageBuilder.suggestedPredefinedSourceDirectory(type: type) ) } @@ -1081,6 +1099,7 @@ public final class PackageBuilder { buildSettingsDescription: manifestTarget.settings, // unsafe flags check disabled in 6.2 usesUnsafeFlags: manifest.toolsVersion >= .v6_2 ? false : manifestTarget.usesUnsafeFlags, + template: manifestTarget.templateInitializationOptions != nil, implicit: false ) } else { @@ -1126,8 +1145,8 @@ public final class PackageBuilder { dependencies: dependencies, buildSettings: buildSettings, buildSettingsDescription: manifestTarget.settings, - // unsafe flags check disabled in 6.2 usesUnsafeFlags: manifest.toolsVersion >= .v6_2 ? false : manifestTarget.usesUnsafeFlags, + template: manifestTarget.templateInitializationOptions != nil, implicit: false ) } @@ -1998,6 +2017,7 @@ extension PackageBuilder { buildSettings: buildSettings, buildSettingsDescription: targetDescription.settings, usesUnsafeFlags: false, + template: false, // Snippets are not templates implicit: true ) } 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/Sources/PackageModel/Manifest/TargetDescription.swift b/Sources/PackageModel/Manifest/TargetDescription.swift index d336bedb60a..6db668c04a2 100644 --- a/Sources/PackageModel/Manifest/TargetDescription.swift +++ b/Sources/PackageModel/Manifest/TargetDescription.swift @@ -194,6 +194,45 @@ 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, templatePermissions: [TemplatePermission]?, description: String) + } + + public enum TemplateType: String, Hashable, Codable, Sendable { + case library + case executable + case tool + case buildToolPlugin + case commandPlugin + case `macro` + case empty + } + + 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,11 +249,50 @@ 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 { - case .regular, .executable, .test: + case .regular, .test: + 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!) + ) + } + case .executable: if url != nil { throw Error.disallowedPropertyInTarget( targetName: name, targetType: targetType, @@ -300,6 +378,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 +447,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 +498,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 +542,13 @@ 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!) + ) + } } self.name = name @@ -461,6 +567,7 @@ public struct TargetDescription: Hashable, Encodable, Sendable { self.settings = settings self.checksum = checksum self.pluginUsages = pluginUsages + self.templateInitializationOptions = templateInitializationOptions } } @@ -586,6 +693,42 @@ 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): + var unkeyedContainer = container.nestedUnkeyedContainer(forKey: .packageInit) + try unkeyedContainer.encode(a1) + if let permissions = a2 { + try unkeyedContainer.encode(permissions) + } else { + try unkeyedContainer.encodeNil() + } + try unkeyedContainer.encode(a3) + } + } + + 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 templatePermissions = try unkeyedValues.decodeIfPresent([TargetDescription.TemplatePermission].self) + let description = try unkeyedValues.decode(String.self) + self = .packageInit(templateType: templateType, templatePermissions: templatePermissions ?? nil, description: description) + } + } +} + import protocol Foundation.LocalizedError private enum Error: LocalizedError, Equatable { diff --git a/Sources/PackageModel/ManifestSourceGeneration.swift b/Sources/PackageModel/ManifestSourceGeneration.swift index 9c9a19a3a2d..6b760d4b592 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) @@ -652,6 +657,68 @@ fileprivate extension SourceCodeFragment { } } + init(from templateInitializationOptions: TargetDescription.TemplateInitializationOptions) { + switch templateInitializationOptions { + case .packageInit(let templateType, let templatePermissions, let description): + var params: [SourceCodeFragment] = [] + + switch templateType { + case .library: + self.init(enum: "target", subnodes: params, multiline: true) + case .executable: + self.init(enum: "executableTarget", 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) + } + + // 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/PackageModel/Module/BinaryModule.swift b/Sources/PackageModel/Module/BinaryModule.swift index 31d47fbb948..6a55e07ae07 100644 --- a/Sources/PackageModel/Module/BinaryModule.swift +++ b/Sources/PackageModel/Module/BinaryModule.swift @@ -50,6 +50,7 @@ public final class BinaryModule: Module { buildSettingsDescription: [], pluginUsages: [], usesUnsafeFlags: false, + template: false, implicit: false ) } diff --git a/Sources/PackageModel/Module/ClangModule.swift b/Sources/PackageModel/Module/ClangModule.swift index 5105b7bb563..3e4496b46d0 100644 --- a/Sources/PackageModel/Module/ClangModule.swift +++ b/Sources/PackageModel/Module/ClangModule.swift @@ -62,6 +62,7 @@ public final class ClangModule: Module { buildSettings: BuildSettings.AssignmentTable = .init(), buildSettingsDescription: [TargetBuildSettingDescription.Setting] = [], usesUnsafeFlags: Bool, + template: Bool, implicit: Bool ) throws { guard includeDir.isDescendantOfOrEqual(to: sources.root) else { @@ -88,6 +89,7 @@ public final class ClangModule: Module { buildSettingsDescription: buildSettingsDescription, pluginUsages: [], usesUnsafeFlags: usesUnsafeFlags, + template: template, implicit: implicit ) } diff --git a/Sources/PackageModel/Module/Module.swift b/Sources/PackageModel/Module/Module.swift index 7001c244212..d8cf49e26b1 100644 --- a/Sources/PackageModel/Module/Module.swift +++ b/Sources/PackageModel/Module/Module.swift @@ -242,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 + /// 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 @@ -261,6 +264,7 @@ public class Module { buildSettingsDescription: [TargetBuildSettingDescription.Setting], pluginUsages: [PluginUsage], usesUnsafeFlags: Bool, + template: Bool, implicit: Bool ) { self.name = name @@ -278,6 +282,7 @@ public class Module { self.buildSettingsDescription = buildSettingsDescription self.pluginUsages = pluginUsages self.usesUnsafeFlags = usesUnsafeFlags + self.template = template self.implicit = implicit } } diff --git a/Sources/PackageModel/Module/PluginModule.swift b/Sources/PackageModel/Module/PluginModule.swift index ec180285b93..095eb28dfed 100644 --- a/Sources/PackageModel/Module/PluginModule.swift +++ b/Sources/PackageModel/Module/PluginModule.swift @@ -46,6 +46,7 @@ public final class PluginModule: Module { buildSettingsDescription: [], pluginUsages: [], usesUnsafeFlags: false, + template: false, // Plugins cannot themselves be a template implicit: false ) } diff --git a/Sources/PackageModel/Module/SwiftModule.swift b/Sources/PackageModel/Module/SwiftModule.swift index 1fbb2a257d4..77673477b0f 100644 --- a/Sources/PackageModel/Module/SwiftModule.swift +++ b/Sources/PackageModel/Module/SwiftModule.swift @@ -48,6 +48,7 @@ public final class SwiftModule: Module { buildSettingsDescription: [], pluginUsages: [], usesUnsafeFlags: false, + template: false, // test modules cannot be templates implicit: implicit ) } @@ -71,6 +72,7 @@ public final class SwiftModule: Module { buildSettingsDescription: [TargetBuildSettingDescription.Setting] = [], pluginUsages: [PluginUsage] = [], usesUnsafeFlags: Bool, + template: Bool, implicit: Bool ) { self.declaredSwiftVersions = declaredSwiftVersions @@ -89,6 +91,7 @@ public final class SwiftModule: Module { buildSettingsDescription: buildSettingsDescription, pluginUsages: pluginUsages, usesUnsafeFlags: usesUnsafeFlags, + template: template, implicit: implicit ) } @@ -138,6 +141,7 @@ public final class SwiftModule: Module { buildSettingsDescription: [], pluginUsages: [], usesUnsafeFlags: false, + template: false, // Modules from test entry point files are not templates implicit: true ) } diff --git a/Sources/PackageModel/Module/SystemLibraryModule.swift b/Sources/PackageModel/Module/SystemLibraryModule.swift index f9a42d2e51a..a3b95b0bd38 100644 --- a/Sources/PackageModel/Module/SystemLibraryModule.swift +++ b/Sources/PackageModel/Module/SystemLibraryModule.swift @@ -46,6 +46,7 @@ public final class SystemLibraryModule: Module { buildSettingsDescription: [], pluginUsages: [], usesUnsafeFlags: false, + template: false, // System libraries are not templates implicit: isImplicit ) } diff --git a/Sources/SourceControl/GitRepository.swift b/Sources/SourceControl/GitRepository.swift index 6dbdf063275..cb8c97577c3 100644 --- a/Sources/SourceControl/GitRepository.swift +++ b/Sources/SourceControl/GitRepository.swift @@ -224,6 +224,52 @@ 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, + at destinationPath: Basics.AbsolutePath, + editable: Bool + ) throws -> WorkingCheckout { + + if editable { + 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 { + try self.clone( + repository, + sourcePath.pathString, + destinationPath.pathString, + ["--shared"] + ) + } + return try self.openWorkingCopy(at: destinationPath) + } + + public func createWorkingCopy( repository: RepositorySpecifier, sourcePath: Basics.AbsolutePath, @@ -721,6 +767,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/Workspace/InitPackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift similarity index 99% rename from Sources/Workspace/InitPackage.swift rename to Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift index 07224aeb17f..bf82424481a 100644 --- a/Sources/Workspace/InitPackage.swift +++ b/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift @@ -914,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/TemplateWorkspaceUtilities/InitTemplatePackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift new file mode 100644 index 00000000000..969865e18ec --- /dev/null +++ b/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift @@ -0,0 +1,977 @@ +// +// InitTemplatePackage.swift +// SwiftPM +// +// Created by John Bute on 2025-05-13. +// + +import ArgumentParserToolInfo +import Basics +import Foundation +@_spi(PackageRefactor) import SwiftRefactor +@_spi(FixItApplier) import SwiftIDEUtils + +import SPMBuildCore +import SwiftParser +import SwiftSyntax + +import TSCBasic +import TSCUtility + +import struct PackageModel.InstalledSwiftPMConfiguration +import class PackageModel.Manifest +import struct PackageModel.SupportedPlatform + +/// 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 struct InitTemplatePackage { + /// The kind of package dependency to add for the template. + let packageDependency: SwiftRefactor.PackageDependency + + /// The set of testing libraries supported by the generated package. + public var supportedTestingLibraries: Set + + /// The file system abstraction to use for file operations. + let fileSystem: FileSystem + + /// The absolute path where the package will be created. + let destinationPath: Basics.AbsolutePath + + /// Configuration information from the installed Swift Package Manager toolchain. + let installedSwiftPMConfiguration: InstalledSwiftPMConfiguration + /// The name of the package to create. + public var packageName: String + + /// 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. + public var packageType: InitPackage.PackageType + + /// The set of supported testing libraries to include in the package. + public var supportedTestingLibraries: Set + + /// The list of supported platforms to target in the manifest. + /// + /// 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, + platforms: [SupportedPlatform] = [] + ) { + self.packageType = packageType + self.supportedTestingLibraries = supportedTestingLibraries + self.platforms = platforms + } + } + + /// The type of template source. + public enum TemplateSource: String, CustomStringConvertible { + case local + case git + case registry + + public var description: String { + 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. + + package init( + name: String, + initMode: SwiftRefactor.PackageDependency, + fileSystem: FileSystem, + packageType: InitPackage.PackageType, + supportedTestingLibraries: Set, + destinationPath: Basics.AbsolutePath, + installedSwiftPMConfiguration: InstalledSwiftPMConfiguration, + ) { + self.packageName = name + self.packageDependency = initMode + self.packageType = packageType + self.supportedTestingLibraries = supportedTestingLibraries + self.destinationPath = destinationPath + self.installedSwiftPMConfiguration = installedSwiftPMConfiguration + self.fileSystem = fileSystem + } + + /// 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 + ) + try initializedPackage.writePackageStructure() + 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 self.addTemplateDepenency() + } + + /// 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. + + private func addTemplateDepenency() throws { + let manifestPath = self.destinationPath.appending(component: Manifest.filename) + let manifestContents: ByteString + + do { + manifestContents = try self.fileSystem.readFileContents(manifestPath) + } catch { + throw StringError("Cannot find package manifest in \(manifestPath)") + } + + let manifestSyntax = manifestContents.withData { data in + data.withUnsafeBytes { buffer in + buffer.withMemoryRebound(to: UInt8.self) { buffer in + Parser.parse(source: buffer) + } + } + } + + let editResult = try SwiftRefactor.AddPackageDependency.textRefactor( + syntax: manifestSyntax, + in: SwiftRefactor.AddPackageDependency.Context(dependency: self.packageDependency) + ) + + try editResult.applyEdits( + to: self.fileSystem, + manifest: manifestSyntax, + manifestPath: manifestPath, + verbose: false + ) + } +} + +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 + /// 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 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] { + let allArgs = try convertArguments(from: command) + 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: self.hasTTY + ) + + // Combine all inherited + current-level responses + let allCurrentResponses = inheritedResponses + providedResponses + promptedResponses + + let currentArgNames = Set(allArgs.map(\.valueName)) + let currentCommandResponses = allCurrentResponses.filter { currentArgNames.contains($0.argument.valueName) } + + let currentArgs = self.buildCommandLine(from: currentCommandResponses) + let fullCommand = subcommandTrail + currentArgs + + 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 subCommandLine = try self.promptUser( + command: matchedSubcommand, + arguments: newArgs, + subcommandTrail: newTrail, + inheritedResponses: allCurrentResponses + ) + + return subCommandLine + } else { + // Fall back to interactive prompt + if !self.hasTTY { + throw TemplateError.noTTYForSubcommandSelection + } + let chosenSubcommand = try self.promptUserForSubcommand(for: subCommands) + + var newTrail = subcommandTrail + newTrail.append(chosenSubcommand.commandName) + + let subCommandLine = try self.promptUser( + command: chosenSubcommand, + arguments: leftoverArgs, + subcommandTrail: newTrail, + inheritedResponses: allCurrentResponses + ) + + return subCommandLine + } + } + + return fullCommand + } + + /// 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("Choose from the following:\n") + + for command in commands { + print(""" + Name: \(command.commandName) + About: \(command.abstract ?? "") + """) + } + + 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 option found with name '\(input)'. Please try again:") + } + } else { + print("Please enter a valid option 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" } + + guard !filteredSubcommands.isEmpty else { return nil } + + return filteredSubcommands + } + + /// 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. + /// 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 + /// - 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 parseAndMatchArgumentsWithLeftovers( + _ input: [String], + definedArgs: [ArgumentInfoV0], + subcommands: [CommandInfoV0] = [] + ) throws -> ([ArgumentResponse], [String]) { + var responses: [ArgumentResponse] = [] + var providedMap: [String: [String]] = [:] + var leftover: [String] = [] + var tokens = input + var terminatorSeen = false + var postTerminatorArgs: [String] = [] + + 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] { + 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 + /// + /// 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. + /// + /// - Parameter command: The command info object. + /// - Returns: An array of argument info objects. Returns empty array if command has no arguments. + + private func convertArguments(from command: CommandInfoV0) throws -> [ArgumentInfoV0] { + command.arguments ?? [] + } + + /// 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], + hasTTY: Bool = true + ) throws -> [ArgumentResponse] { + 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] { + 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 completionText = self.generateCompletionHint(for: arg) + let promptMessage = "\(arg.abstract ?? "")\(allValuesText)\(completionText)\(defaultText):" + + var values: [String] = [] + + 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 = try TemplatePromptingSystem.promptForConfirmation( + prompt: promptMessage, + defaultBehavior: arg.defaultValue?.lowercased(), + 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: + 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)" : "" + print(promptMessage + nilSuffix) + } + + if arg.isRepeating { + 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) + } + } + if values.isEmpty, let def = arg.defaultValue { + values = [def] + } + } 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 + ) + 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: \(self.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 { + if hasTTY { + fatalError("Required argument '\(arg.valueName ?? "")' not provided.") + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: arg.valueName ?? "") + } + } + } + } + + 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. + /// + /// - 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] { + 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`. + + 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 && defaultBehavior == nil { + suffix = suffix + " or enter \"nil\" to unset." + } + + print(prompt + suffix, terminator: " ") + + guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + if let 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": + if isOptional { + return nil + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: "confirmation") + } + case "": + if let defaultBehavior { + return defaultBehavior == "true" + } else if isOptional { + return nil + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: "confirmation") + } + default: + if let defaultBehavior { + return defaultBehavior == "true" + } else if isOptional { + return nil + } else { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: "confirmation") + } + } + } + + /// Represents a user's response to an argument prompt. + + public struct ArgumentResponse { + /// The argument metadata. + let argument: ArgumentInfoV0 + + /// 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 !self.isExplicitlyUnset else { return [] } + + 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 + } + } + + /// Initialize with explicit unset state + public init(argument: ArgumentInfoV0, values: [String], isExplicitlyUnset: Bool = false) { + self.argument = argument + self.values = values + self.isExplicitlyUnset = isExplicitlyUnset + } + } +} + +/// 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]) + case missingRequiredArgumentWithoutTTY(name: String) + case noTTYForSubcommandSelection +} + +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.")" + case .missingRequiredArgumentWithoutTTY(name: let name): + "Required argument '\(name)' not provided and no interactive terminal available" + case .noTTYForSubcommandSelection: + "Cannot select subcommand interactively - no terminal available" + } + } +} + +extension [SourceEdit] { + /// Apply the edits for the given manifest to the specified file system, + /// updating the manifest to the given manifest + func applyEdits( + to filesystem: any FileSystem, + manifest: SourceFileSyntax, + manifestPath: Basics.AbsolutePath, + verbose: Bool + ) throws { + let rootPath = manifestPath.parentDirectory + + // Update the manifest + if verbose { + print("Updating package manifest at \(manifestPath.relative(to: rootPath))...", terminator: "") + } + + let updatedManifestSource = FixItApplier.apply( + edits: self, + to: manifest + ) + try filesystem.writeFileContents( + manifestPath, + string: updatedManifestSource + ) + if verbose { + print(" done.") + } + } +} diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift b/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift new file mode 100644 index 00000000000..3e319632226 --- /dev/null +++ b/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift @@ -0,0 +1,69 @@ +import Basics +import Foundation + +/// A helper for managing temporary directories used in filesystem operations. +public struct TemporaryDirectoryHelper { + let fileSystem: FileSystem + + public init(fileSystem: FileSystem) { + 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) + 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] { + try names.map { name in + let path = parent.appending(component: name) + try self.fileSystem.createDirectory(path) + return path + } + } + + /// Checks if a directory exists at the given path. + public func directoryExists(_ path: Basics.AbsolutePath) -> Bool { + self.fileSystem.exists(path) + } + + /// Removes a directory if it exists. + public func removeDirectoryIfExists(_ path: Basics.AbsolutePath) throws { + if self.fileSystem.exists(path) { + try self.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 { + let source = sourceDir.appending(component: entry) + let destination = destinationDir.appending(component: entry) + try self.fileSystem.copy(from: source, to: destination) + } + } +} + +/// 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) + 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)" + } + } +} diff --git a/Sources/Workspace/Workspace+Dependencies.swift b/Sources/Workspace/Workspace+Dependencies.swift index 64eb37227b8..3b603495134 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 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 } diff --git a/Sources/_InternalTestSupport/ResolvedModule+Mock.swift b/Sources/_InternalTestSupport/ResolvedModule+Mock.swift index aa2818d49e0..7a696a05926 100644 --- a/Sources/_InternalTestSupport/ResolvedModule+Mock.swift +++ b/Sources/_InternalTestSupport/ResolvedModule+Mock.swift @@ -30,6 +30,7 @@ extension ResolvedModule { dependencies: [], packageAccess: false, usesUnsafeFlags: false, + template: false, implicit: true ), dependencies: deps.map { .module($0, conditions: conditions) }, diff --git a/Sources/_InternalTestSupport/SwiftTesting+Tags.swift b/Sources/_InternalTestSupport/SwiftTesting+Tags.swift index f8af94b5f30..b536bb81d20 100644 --- a/Sources/_InternalTestSupport/SwiftTesting+Tags.swift +++ b/Sources/_InternalTestSupport/SwiftTesting+Tags.swift @@ -71,6 +71,7 @@ extension Tag.Feature.Command.Package { @Tag public static var Resolve: Tag @Tag public static var ShowDependencies: Tag @Tag public static var ShowExecutables: Tag + @Tag public static var ShowTemplates: Tag @Tag public static var ToolsVersion: Tag @Tag public static var Unedit: Tag @Tag public static var Update: Tag @@ -106,4 +107,5 @@ extension Tag.Feature.PackageType { @Tag public static var BuildToolPlugin: Tag @Tag public static var CommandPlugin: Tag @Tag public static var Macro: Tag + @Tag public static var LocalTemplate: Tag } diff --git a/Tests/BuildTests/ClangTargetBuildDescriptionTests.swift b/Tests/BuildTests/ClangTargetBuildDescriptionTests.swift index 493f3db64df..b4aa048f6c6 100644 --- a/Tests/BuildTests/ClangTargetBuildDescriptionTests.swift +++ b/Tests/BuildTests/ClangTargetBuildDescriptionTests.swift @@ -62,6 +62,7 @@ final class ClangTargetBuildDescriptionTests: XCTestCase { path: .root, sources: .init(paths: [.root.appending(component: "foo.c")], root: .root), usesUnsafeFlags: false, + template: false, implicit: true ) } diff --git a/Tests/CommandsTests/PackageCommandTests.swift b/Tests/CommandsTests/PackageCommandTests.swift index 4fb50dcaa88..b0f600e66de 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -1215,6 +1215,7 @@ struct PackageCommandTests { ) #expect(textOutput.contains("dealer\n")) #expect(textOutput.contains("deck (deck-of-playing-cards)\n")) + #expect(!textOutput.contains("TemplateExample")) let (jsonOutput, _) = try await execute( ["show-executables", "--format=json"], @@ -1270,6 +1271,52 @@ struct PackageCommandTests { } } + + + @Test( + .tags( + .Feature.Command.Package.ShowTemplates + ), + arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms) + ) + func testShowTemplates( + data: BuildData + ) async throws { + try await fixture(name: "Miscellaneous/ShowTemplates") { fixturePath in + let packageRoot = fixturePath.appending("app") + + let (textOutput, _) = try await execute( + ["show-templates", "--format=flatlist"], + packagePath: packageRoot, + configuration: data.config, + buildSystem: data.buildSystem, + ) + #expect(textOutput.contains("GenerateFromTemplate")) + + let (jsonOutput, _) = try await execute( + ["show-templates", "--format=json"], + packagePath: packageRoot, + configuration: data.config, + buildSystem: data.buildSystem, + ) + + let json = try JSON(bytes: ByteString(encodingAsUTF8: jsonOutput)) + guard case let .array(contents) = json else { + Issue.record("unexpected result"); return + } + + #expect(contents.count == 1) + + guard case let first = contents.first else { Issue.record("unexpected result"); return } + guard case let .dictionary(generateFromTemplate) = first else { Issue.record("unexpected result"); return } + guard case let .string(generateFromTemplateName)? = generateFromTemplate["name"] else { Issue.record("unexpected result"); return } + #expect(generateFromTemplateName == "GenerateFromTemplate") + if case let .string(package)? = generateFromTemplate["package"] { + Issue.record("unexpected result"); return + } + } + } + @Suite( .tags( .Feature.Command.Package.ShowDependencies, @@ -1845,6 +1892,80 @@ struct PackageCommandTests { ) } } + + @Test( + .tags( + .Feature.Command.Package.Init, + .Feature.PackageType.LocalTemplate, + ), + arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms) + ) + func initLocalTemplate( + data: BuildData + ) 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"], + packagePath: packageRoot, + 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"))) + } + } + + @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( diff --git a/Tests/CommandsTests/TemplateTests.swift b/Tests/CommandsTests/TemplateTests.swift new file mode 100644 index 00000000000..4fdaa0c78e9 --- /dev/null +++ b/Tests/CommandsTests/TemplateTests.swift @@ -0,0 +1,2530 @@ +//===----------------------------------------------------------------------===// +// +// 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 ArgumentParserToolInfo +@testable import Commands +@_spi(SwiftPMInternal) +@testable import CoreCommands +import Foundation +@testable import Workspace + +import _InternalTestSupport +@_spi(DontAdoptOutsideOfSwiftPMExposedForBenchmarksAndTestsOnly) + +import PackageGraph +import PackageLoading +import SourceControl +import SPMBuildCore +import Testing +import TSCUtility +import Workspace + +@_spi(PackageRefactor) import SwiftRefactor + +import class Basics.AsyncProcess +import class TSCBasic.BufferedOutputByteStream +import struct TSCBasic.ByteString +import enum TSCBasic.JSON + +// MARK: - Helper Methods + +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 { + throw StringError("Unable to get current working directory") + } + let resolver = DefaultTemplateSourceResolver( + cwd: cwd, + fileSystem: tool.fileSystem, + observabilityScope: tool.observabilityScope + ) + return (resolver, tool) +} + +private func makeTestTool() throws -> SwiftCommandState { + let options = try GlobalOptions.parse([]) + return try SwiftCommandState.makeMockState(options: options) +} + +private func makeVersions() -> (lower: Version, higher: Version) { + let lowerBoundVersion = Version(stringLiteral: "1.2.0") + let higherBoundVersion = Version(stringLiteral: "3.0.0") + return (lowerBoundVersion, higherBoundVersion) +} + +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" + let templateURL = "git@github.com:foo/bar" + let templatePackageID = "foo.bar" + let resolvedTemplatePath = try AbsolutePath(validating: "/fake/path/to/template") + return (tool, packageName, templateURL, templatePackageID, resolvedTemplatePath) +} + +@Suite( + // .serialized, + .tags( + .TestSize.large, + .Feature.Command.Package.General, + ), +) +struct TemplateTests { + // MARK: - Template Source Resolution Tests + + @Suite( + .tags( + Tag.TestSize.small, + Tag.Feature.Command.Package.Init, + ), + ) + struct TemplateSourceResolverTests { + @Test + func resolveSourceWithNilInputs() 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 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 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 + ) + + 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 { + 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 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 + ) + + 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 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 + ) + + #expect(throws: DefaultTemplateSourceResolver.SourceResolverError.invalidRegistryIdentity("invalid-id") + .self + ) { + try resolver.validate(templateSource: .registry, directory: nil, url: nil, packageID: "invalid-id") + } + } + + @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 + ) + + #expect(throws: DefaultTemplateSourceResolver.SourceResolverError.missingLocalPath.self) { + try resolver.validate(templateSource: .local, directory: nil, url: nil, packageID: nil) + } + } + + @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 + ) + + #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 { + // 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, + Tag.Feature.Command.Package.Init, + ), + ) + struct DependencyRequirementResolverTests { + @Test + func resolveRegistryDependencyRequirements() 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") + + 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 await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: lowerBoundVersion, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: nil + ).resolveRegistry() + + if case .exact(let version) = exactRegistryDependency { + #expect(version == lowerBoundVersion.description) + } else { + Issue.record("Expected exact registry dependency, got \(String(describing: exactRegistryDependency))") + } + + // test from to + let fromToRegistryDependency = try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: nil, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: nil, + to: higherBoundVersion + ).resolveRegistry() + + if case .range(let lowerBound, let upperBound) = fromToRegistryDependency { + #expect(lowerBound == lowerBoundVersion.description) + #expect(upperBound == higherBoundVersion.description) + } else { + Issue.record("Expected range registry dependency, got \(String(describing: fromToRegistryDependency))") + } + + // test up-to-next-minor-from and to + let upToNextMinorFromToRegistryDependency = try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: lowerBoundVersion, + to: nil + ).resolveRegistry() + + if case .range(let lowerBound, let upperBound) = upToNextMinorFromToRegistryDependency { + let expectedRange = Range.upToNextMinor(from: lowerBoundVersion) + #expect(lowerBound == expectedRange.lowerBound.description) + #expect(upperBound == expectedRange.upperBound.description) + } else { + Issue + .record( + "Expected range registry dependency, got \(String(describing: upToNextMinorFromToRegistryDependency))" + ) + } + + // test just from + let fromRegistryDependency = try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: nil, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: nil, + to: nil + ).resolveRegistry() + + if case .rangeFrom(let lowerBound) = fromRegistryDependency { + #expect(lowerBound == lowerBoundVersion.description) + } else { + Issue + .record("Expected rangeFrom registry dependency, got \(String(describing: fromRegistryDependency))") + } + + // test just up-to-next-minor-from + let upToNextMinorFromRegistryDependency = try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: lowerBoundVersion, + to: nil + ).resolveRegistry() + + if case .range(let lowerBound, let upperBound) = upToNextMinorFromRegistryDependency { + let expectedRange = Range.upToNextMinor(from: lowerBoundVersion) + #expect(lowerBound == expectedRange.lowerBound.description) + #expect(upperBound == expectedRange.upperBound.description) + } else { + Issue + .record( + "Expected range registry dependency, got \(String(describing: upToNextMinorFromRegistryDependency))" + ) + } + + await #expect(throws: DependencyRequirementError.multipleRequirementsSpecified.self) { + try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: lowerBoundVersion, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: lowerBoundVersion, + to: nil + ).resolveRegistry() + } + + await #expect(throws: DependencyRequirementError.noRequirementSpecified.self) { + try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: lowerBoundVersion + ).resolveRegistry() + } + + await #expect(throws: DependencyRequirementError.invalidToParameterWithoutFrom.self) { + try await DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: lowerBoundVersion, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: higherBoundVersion + ).resolveRegistry() + } + } + + @Test + func resolveSourceControlDependencyRequirements() 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", + from: nil, + upToNextMinorFrom: nil, + to: nil + ).resolveSourceControl() + + if case .branch(let branchName) = branchSourceControlDependency { + #expect(branchName == "master") + } else { + Issue + .record( + "Expected branch source control dependency, got \(String(describing: branchSourceControlDependency))" + ) + } + + let revisionSourceControlDependency = try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: nil, + revision: "dae86e", + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: nil + ).resolveSourceControl() + + if case .revision(let revisionHash) = revisionSourceControlDependency { + #expect(revisionHash == "dae86e") + } else { + Issue + .record( + "Expected revision source control dependency, got \(String(describing: revisionSourceControlDependency))" + ) + } + + // test exact specification + let exactSourceControlDependency = try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: lowerBoundVersion, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: nil + ).resolveSourceControl() + + if case .exact(let version) = exactSourceControlDependency { + #expect(version == lowerBoundVersion.description) + } else { + Issue + .record( + "Expected exact source control dependency, got \(String(describing: exactSourceControlDependency))" + ) + } + + // test from to + let fromToSourceControlDependency = try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: nil, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: nil, + to: higherBoundVersion + ).resolveSourceControl() + + if case .range(let lowerBound, let upperBound) = fromToSourceControlDependency { + #expect(lowerBound == lowerBoundVersion.description) + #expect(upperBound == higherBoundVersion.description) + } else { + Issue + .record( + "Expected range source control dependency, got \(String(describing: fromToSourceControlDependency))" + ) + } + + // test up-to-next-minor-from and to + let upToNextMinorFromToSourceControlDependency = try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: lowerBoundVersion, + to: nil + ).resolveSourceControl() + + if case .range(let lowerBound, let upperBound) = upToNextMinorFromToSourceControlDependency { + let expectedRange = Range.upToNextMinor(from: lowerBoundVersion) + #expect(lowerBound == expectedRange.lowerBound.description) + #expect(upperBound == expectedRange.upperBound.description) + } else { + Issue + .record( + "Expected range source control dependency, got \(String(describing: upToNextMinorFromToSourceControlDependency))" + ) + } + + // test just from + let fromSourceControlDependency = try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: nil, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: nil, + to: nil + ).resolveSourceControl() + + if case .rangeFrom(let lowerBound) = fromSourceControlDependency { + #expect(lowerBound == lowerBoundVersion.description) + } else { + Issue + .record( + "Expected rangeFrom source control dependency, got \(String(describing: fromSourceControlDependency))" + ) + } + + // test just up-to-next-minor-from + let upToNextMinorFromSourceControlDependency = try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: lowerBoundVersion, + to: nil + ).resolveSourceControl() + + if case .range(let lowerBound, let upperBound) = upToNextMinorFromSourceControlDependency { + let expectedRange = Range.upToNextMinor(from: lowerBoundVersion) + #expect(lowerBound == expectedRange.lowerBound.description) + #expect(upperBound == expectedRange.upperBound.description) + } else { + Issue + .record( + "Expected range source control dependency, got \(String(describing: upToNextMinorFromSourceControlDependency))" + ) + } + + #expect(throws: DependencyRequirementError.multipleRequirementsSpecified.self) { + try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: lowerBoundVersion, + revision: "dae86e", + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: lowerBoundVersion, + to: nil + ).resolveSourceControl() + } + + #expect(throws: DependencyRequirementError.noRequirementSpecified.self) { + try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: lowerBoundVersion + ).resolveSourceControl() + } + + #expect(throws: DependencyRequirementError.invalidToParameterWithoutFrom.self) { + try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: lowerBoundVersion, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: higherBoundVersion + ).resolveSourceControl() + } + + let range = try DependencyRequirementResolver( + packageIdentity: nil, + swiftCommandState: tool, + exact: nil, + revision: nil, + branch: nil, + from: lowerBoundVersion, + upToNextMinorFrom: nil, + to: lowerBoundVersion + ).resolveSourceControl() + + if case .range(let lowerBound, let upperBound) = range { + #expect(lowerBound == lowerBoundVersion.description) + #expect(upperBound == lowerBoundVersion.description) + } else { + Issue.record("Expected range source control dependency, got \(range)") + } + } + } + + // MARK: - Template Path Resolution Tests + + @Suite( + .tags( + Tag.TestSize.medium, + Tag.Feature.Command.Package.Init, + Tag.Feature.PackageType.LocalTemplate, + ), + ) + struct TemplatePathResolverTests { + @Test + func resolveLocalTemplatePath() 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) + } + + @Test( + .skipHostOS(.windows, "Git operations not fully supported in test environment"), + .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([]) + + 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( + localFileSystem.exists(path.appending(component: "file.swift")), + "Template was not fetched correctly" + ) + } + } + + @Test( + .skipHostOS(.windows, "Git operations not fully supported in test environment"), + .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([]) + + 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() + } + } + } + + @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, + Tag.Feature.Command.Package.Init, + ), + ) + struct TemplateDirectoryManagerTests { + @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() + + #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)) + } + + @Test( + .tags( + Tag.Feature.Command.Package.Init, + Tag.Feature.PackageType.LocalTemplate, + ), + arguments: getBuildData(for: SupportedBuildSystemOnAllPlatforms), + ) + func finalizeDirectoryTransfer( + data: BuildData, + ) 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) + + try await executeSwiftBuild( + stagingPath, + configuration: data.config, + buildSystem: data.buildSystem + ) + + let stagingBuildPath = stagingPath.appending(".build") + let binPathComponents = try data.buildSystem.binPath(for: data.config, scratchPath: []) + let stagingBinPath = stagingBuildPath.appending(components: binPathComponents) + let stagingBinFile = stagingBinPath.appending(executableName("generated-package")) + #expect(localFileSystem.exists(stagingBinFile)) + #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 cwdBinPathComponents = try data.buildSystem.binPath(for: data.config, scratchPath: []) + let cwdBinPath = cwdBuildPath.appending(components: cwdBinPathComponents) + let cwdBinFile = cwdBinPath.appending(executableName("generated-package")) + + // 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" + ) + } + } + + @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") + } + } + } + + // MARK: - Package Dependency Builder Tests + + @Suite( + .tags( + Tag.TestSize.medium, + Tag.Feature.Command.Package.Init, + ), + ) + struct PackageDependencyBuilderTests { + @Test + func buildDependenciesFromTemplateSource() 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: 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 + } + + 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() + + // Test that local dependency was correctly created as filesystem dependency + if case .fileSystem(let fileSystemDep) = localDependency { + #expect(fileSystemDep.path == resolvedTemplatePath.pathString) + } else { + Issue.record("Expected fileSystem dependency, got \(localDependency)") + } + + // 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() + + // Test that git dependency was correctly created as sourceControl dependency + if case .sourceControl(let sourceControlDep) = gitDependency { + #expect(sourceControlDep.location == templateURL) + if case .exact(let exactVersion) = sourceControlDep.requirement { + #expect(exactVersion == "1.2.0") + } else { + Issue.record("Expected exact source control dependency, got \(sourceControlDep.requirement)") + } + } else { + Issue.record("Expected sourceControl dependency, got \(gitDependency)") + } + + #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() + + // Test that registry dependency was correctly created as registry dependency + if case .registry(let registryDep) = registryDependency { + #expect(registryDep.identity == templatePackageID) + + } else { + Issue.record("Expected registry dependency, got \(registryDependency)") + } + } + } + + // MARK: - Package Initializer Configuration Tests + + @Suite( + .tags( + Tag.TestSize.small, + Tag.Feature.Command.Package.Init, + ), + ) + struct PackageInitializerConfigurationTests { + @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([]) + 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) + } + } + + // TODO: Re-enable once SwiftCommandState mocking issues are resolved + // The test fails because mocking swiftCommandState resolves to linux triple on Darwin + /* + @Test( + .requireHostOS(.macOS, "SwiftCommandState mocking issue on non-Darwin platforms"), + ) + func inferPackageTypeFromTemplate() 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") + } + } + */ + } + + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 = self.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 = 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 { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = self.createTestCommand( + arguments: [ + 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 { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = self.createTestCommand( + arguments: [self.createRequiredOption(name: "name")] + ) + + #expect(throws: Error.self) { + _ = try promptingSystem.promptUser( + command: commandInfo, + arguments: [] + ) + } + } + + // MARK: - Argument Response Tests + + @Test + func argumentResponseHandlesExplicitlyUnsetFlags() throws { + let arg = self.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 = self.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 = self.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 = 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 + ), + ] + + 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 = 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 + ), + ] + + 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 = self.createTestCommand( + arguments: [ + 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 = self.createRequiredOption( + name: "type", + allValues: ["executable", "library", "plugin"] + ) + + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + 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( + command: commandInfo, + arguments: ["--type", "invalid"] + ) + } + } + + // MARK: - Subcommand Tests + + @Test + func handlesSubcommandDetection() throws { + let subcommand = self.createTestCommand( + name: "init", + arguments: [self.createRequiredOption(name: "name")] + ) + + 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 { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + 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 { + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = self.createTestCommand( + arguments: [self.createRequiredOption(name: "name")] + ) + + #expect(throws: Error.self) { + _ = try promptingSystem.promptUser( + command: commandInfo, + arguments: ["--name"] + ) + } + } + + @Test + func handlesNestedSubcommands() throws { + let innerSubcommand = self.createTestCommand( + name: "create", + arguments: [self.createRequiredOption(name: "name")] + ) + + let outerSubcommand = self.createTestCommand( + name: "package", + subcommands: [innerSubcommand] + ) + + let mainCommand = self.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 = self.createTestCommand( + arguments: [ + 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")) + #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 = self.createTestCommand( + arguments: [ + 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 { + 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 = 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 { + 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 = 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( + command: commandInfo, + arguments: ["--platform", "Linux"] + ) + } + } + + @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, + 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 = self.createTestCommand( + arguments: [ + self.createRequiredOption(name: "required-arg"), + self.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 = self.createRequiredOption( + name: "files", + parsingStrategy: .upToNextOption + ) + + let promptingSystem = TemplatePromptingSystem(hasTTY: false) + let commandInfo = self.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 = self.createTestCommand( + arguments: [ + 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 + } + + @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 + + @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 { _ 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 { _ 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 { _ 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 = try InitTemplatePackage( + name: "TestPackage", + initMode: dependency, + fileSystem: tool.fileSystem, + packageType: .executable, + supportedTestingLibraries: [.xctest], + destinationPath: packagePath, + installedSwiftPMConfiguration: 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 = try InitTemplatePackage( + name: "TestPackage", + initMode: dependency, + fileSystem: tool.fileSystem, + packageType: .executable, + supportedTestingLibraries: [.xctest], + destinationPath: packagePath, + 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"))) + #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) { + _ = 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 = try InitTemplatePackage( + name: "TestPackage", + initMode: packageDependency, + fileSystem: tool.fileSystem, + packageType: .executable, + supportedTestingLibraries: [.xctest], + destinationPath: packagePath, + 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) + */ + } + } + } + + @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 = try DefaultPackageDependencyBuilder( + templateSource: .git, + packageName: "TestPackage", + templateURL: "https://github.com/example/template.git", + templatePackageID: "test.package", + sourceControlRequirement: sourceControlRequirement, + registryRequirement: registryRequirement, + 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") + 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") + 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) + + // 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"] + ) + } + } + } +} 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") }