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 new file mode 100644 index 00000000000..fce1945aa30 --- /dev/null +++ b/Documentation/Usage.md @@ -0,0 +1,931 @@ +# Usage + +## Table of Contents + +* [Overview](README.md) +* [**Usage**](Usage.md) + * [Creating a Package](#creating-a-package) + * [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) + * [Packaging Legacy Code](#packaging-legacy-code) + * [Handling Version-specific Logic](#handling-version-specific-logic) + * [Editing a Package](#editing-a-package) + * [Top of Tree Development](#top-of-tree-development) + * [Resolving Versions (Package.resolved file)](#resolving-versions-packageresolved-file) + * [Setting the Swift Tools Version](#setting-the-swift-tools-version) + * [Testing](#testing) + * [Running](#running) + * [Setting the Build Configuration](#setting-the-build-configuration) + * [Debug](#debug) + * [Release](#release) + * [Additional Flags](#additional-flags) + * [Depending on Apple Modules](#depending-on-apple-modules) + * [Creating C Language Targets](#creating-c-language-targets) + * [Using Shell Completion Scripts](#using-shell-completion-scripts) + * [Package manifest specification](PackageDescription.md) + * [Packages and continuous integration](ContinuousIntegration.md) + +--- + +## Creating a Package + +Simply put: a package is a git repository with semantically versioned tags, +that contains Swift sources and a `Package.swift` manifest file at its root. + +### Creating a Library Package + +A library package contains code which other packages can use and depend on. To +get started, create a directory and run `swift package init`: + + $ mkdir MyPackage + $ cd MyPackage + $ swift package init # or swift package init --type library + $ swift build + $ swift test + +This will create the directory structure needed for a library package with a +target and the corresponding test target to write unit tests. A library package +can contain multiple targets as explained in [Target Format +Reference](PackageDescription.md#target). + +### Creating an Executable Package + +SwiftPM can create native binaries which can be executed from the command line. To +get started: + + $ mkdir MyExecutable + $ cd MyExecutable + $ swift package init --type executable + $ swift build + $ swift run + Hello, World! + +This creates the directory structure needed for executable targets. Any target +can be turned into a executable target if there is a `main.swift` file present in +its sources. The complete reference for layout is +[here](PackageDescription.md#target). + +### Creating a Macro Package + +SwiftPM can generate boilerplate for custom macros: + + $ mkdir MyMacro + $ cd MyMacro + $ swift package init --type macro + $ swift build + $ swift run + The value 42 was produced by the code "a + b" + +This creates a package with a `.macro` type target with its required dependencies +on [swift-syntax](https://github.com/swiftlang/swift-syntax), a library `.target` +containing the macro's code, and an `.executableTarget` and `.testTarget` for +running the macro. The sample macro, `StringifyMacro`, is documented in the Swift +Evolution proposal for [Expression Macros](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0382-expression-macros.md) +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 +your package, and add a product from that package as a dependency, e.g., if +you want to use https://github.com/apple/example-package-playingcard as +a dependency, add the GitHub URL in the dependencies of `Package.swift`: + +```swift +import PackageDescription + +let package = Package( + name: "MyPackage", + dependencies: [ + .package(url: "https://github.com/apple/example-package-playingcard.git", from: "3.0.4"), + ], + targets: [ + .target( + name: "MyPackage", + dependencies: ["PlayingCard"] + ), + .testTarget( + name: "MyPackageTests", + dependencies: ["MyPackage"] + ), + ] +) +``` + +Now you should be able to `import PlayingCard` in the `MyPackage` target. + +## Publishing a Package + +To publish a package, create and push a semantic version tag: + + $ git init + $ git add . + $ git remote add origin [github-URL] + $ git commit -m "Initial Commit" + $ git tag 1.0.0 + $ git push origin master --tags + +Now other packages can depend on version 1.0.0 of this package using the github +url. +An example of a published package can be found here: +https://github.com/apple/example-package-fisheryates + +## Requiring System Libraries + +You can link against system libraries using the package manager. To do so, you'll +need to add a special `target` of type `.systemLibrary`, and a `module.modulemap` +for each system library you're using. + +Let's see an example of adding [libgit2](https://github.com/libgit2/libgit2) as a +dependency to an executable target. + +Create a directory called `example`, and initialize it as a package that +builds an executable: + + $ mkdir example + $ cd example + example$ swift package init --type executable + +Edit the `Sources/example/main.swift` so it consists of this code: + +```swift +import Clibgit + +let options = git_repository_init_options() +print(options) +``` + +To `import Clibgit`, the package manager requires that the libgit2 library has +been installed by a system packager (eg. `apt`, `brew`, `yum`, `nuget`, etc.). The +following files from the libgit2 system-package are of interest: + + /usr/local/lib/libgit2.dylib # .so on Linux + /usr/local/include/git2.h + +**Note:** the system library may be located elsewhere on your system, such as: +- `/usr/`, or `/opt/homebrew/` if you're using Homebrew on an Apple Silicon Mac. +- `C:\vcpkg\installed\x64-windows\include` on Windows, if you're using `vcpkg`. +On most Unix-like systems, you can use `pkg-config` to lookup where a library is installed: + + example$ pkg-config --cflags libgit2 + -I/usr/local/libgit2/1.6.4/include + + +**First, let's define the `target` in the package description**: + +```swift +// swift-tools-version: 5.8 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "example", + targets: [ + // systemLibrary is a special type of build target that wraps a system library + // in a target that other targets can require as their dependency. + .systemLibrary( + name: "Clibgit", + pkgConfig: "libgit2", + providers: [ + .brew(["libgit2"]), + .apt(["libgit2-dev"]) + ] + ) + ] +) + +``` + +**Note:** For Windows-only packages `pkgConfig` should be omitted as +`pkg-config` is not expected to be available. If you don't want to use the +`pkgConfig` parameter you can pass the path of a directory containing the +library using the `-L` flag in the command line when building your package +instead. + + example$ swift build -Xlinker -L/usr/local/lib/ + +Next, create a directory `Sources/Clibgit` in your `example` project, and +add a `module.modulemap` and the header file to it: + + module Clibgit [system] { + header "git2.h" + link "git2" + export * + } + +The header file should look like this: + +```c +// git2.h +#pragma once +#include +``` + +**Note:** Avoid specifying an absolute path in the `module.modulemap` to `git2.h` +header provided by the library. Doing so will break compatibility of +your project between machines that may use a different file system layout or +install libraries to different paths. + +> The convention we hope the community will adopt is to prefix such modules +> with `C` and to camelcase the modules as per Swift module name conventions. +> Then the community is free to name another module simply `libgit` which +> contains more “Swifty” function wrappers around the raw C interface. + +The `example` directory structure should look like this now: + + . + ├── Package.swift + └── Sources + ├── Clibgit + │   ├── git2.h + │   └── module.modulemap + └── main.swift + +At this point, your system library target is fully defined, and you can now use +that target as a dependency in other targets in your `Package.swift`, like this: + +```swift + +import PackageDescription + +let package = Package( + name: "example", + targets: [ + .executableTarget( + name: "example", + + // example executable requires "Clibgit" target as its dependency. + // It's a systemLibrary target defined below. + dependencies: ["Clibgit"], + path: "Sources" + ), + + // systemLibrary is a special type of build target that wraps a system library + // in a target that other targets can require as their dependency. + .systemLibrary( + name: "Clibgit", + pkgConfig: "libgit2", + providers: [ + .brew(["libgit2"]), + .apt(["libgit2-dev"]) + ] + ) + ] +) + +``` + +Now if we type `swift build` in our example app directory we will create an +executable: + + example$ swift build + … + example$ .build/debug/example + git_repository_init_options(version: 0, flags: 0, mode: 0, workdir_path: nil, description: nil, template_path: nil, initial_head: nil, origin_url: nil) + example$ + +### Requiring a System Library Without `pkg-config` + +Let’s see another example of using [IJG’s JPEG library](http://www.ijg.org) +from an executable, which has some caveats. + +Create a directory called `example`, and initialize it as a package that builds +an executable: + + $ mkdir example + $ cd example + example$ swift package init --type executable + +Edit the `Sources/main.swift` so it consists of this code: + +```swift +import CJPEG + +let jpegData = jpeg_common_struct() +print(jpegData) +``` + +Install the JPEG library, on macOS you can use Homebrew package manager: `brew install jpeg`. +`jpeg` is a keg-only formula, meaning it won't be linked to `/usr/local/lib`, +and you'll have to link it manually at build time. + +Just like in the previous example, run `mkdir Sources/CJPEG` and add the +following `module.modulemap`: + + module CJPEG [system] { + header "shim.h" + header "/usr/local/opt/jpeg/include/jpeglib.h" + link "jpeg" + export * + } + +Create a `shim.h` file in the same directory and add `#include ` in +it. + + $ echo '#include ' > shim.h + +This is because `jpeglib.h` is not a correct module, that is, it does not contain +the required line `#include `. Alternatively, you can add `#include ` +to the top of jpeglib.h to avoid creating the `shim.h` file. + +Now to use the CJPEG package we must declare our dependency in our example +app’s `Package.swift`: + +```swift + +import PackageDescription + +let package = Package( + name: "example", + targets: [ + .executableTarget( + name: "example", + dependencies: ["CJPEG"], + path: "Sources" + ), + .systemLibrary( + name: "CJPEG", + providers: [ + .brew(["jpeg"]) + ]) + ] +) +``` + +Now if we type `swift build` in our example app directory we will create an +executable: + + example$ swift build -Xlinker -L/usr/local/jpeg/lib + … + example$ .build/debug/example + jpeg_common_struct(err: nil, mem: nil, progress: nil, client_data: nil, is_decompressor: 0, global_state: 0) + example$ + +We have to specify the path where the libjpeg is present using `-Xlinker` because +there is no pkg-config file for it. We plan to provide a solution to avoid passing +the flag in the command line. + +### Packages That Provide Multiple Libraries + +Some system packages provide multiple libraries (`.so` and `.dylib` files). In +such cases you should add all the libraries to that Swift modulemap package’s +`.modulemap` file: + + module CFoo [system] { + header "/usr/local/include/foo/foo.h" + link "foo" + export * + } + + module CFooBar [system] { + header "/usr/include/foo/bar.h" + link "foobar" + export * + } + + module CFooBaz [system] { + header "/usr/include/foo/baz.h" + link "foobaz" + export * + } + +`foobar` and `foobaz` link to `foo`; we don’t need to specify this information +in the module-map because the headers `foo/bar.h` and `foo/baz.h` both include +`foo/foo.h`. It is very important however that those headers do include their +dependent headers, otherwise when the modules are imported into Swift the +dependent modules will not get imported automatically and link errors will +happen. If these link errors occur for consumers of a package that consumes your +package, the link errors can be especially difficult to debug. + +### Cross-platform Module Maps + +Module maps must contain absolute paths, thus they are not cross-platform. We +intend to provide a solution for this in the package manager. In the long term, +we hope that system libraries and system packagers will provide module maps and +thus this component of the package manager will become redundant. + +*Notably* the above steps will not work if you installed JPEG and JasPer with +[Homebrew](http://brew.sh) since the files will be installed to `/usr/local` on +Intel Macs, or /opt/homebrew on Apple silicon Macs. For now adapt the paths, +but as said, we plan to support basic relocations like these. + +### Module Map Versioning + +Version the module maps semantically. The meaning of semantic version is less +clear here, so use your best judgement. Do not follow the version of the system +library the module map represents; version the module map(s) independently. + +Follow the conventions of system packagers; for example, the debian package for +python3 is called python3, as there is not a single package for python and +python is designed to be installed side-by-side. Were you to make a module map +for python3 you should name it `CPython3`. + +### System Libraries With Optional Dependencies + +At this time you will need to make another module map package to represent +system packages that are built with optional dependencies. + +For example, `libarchive` optionally depends on `xz`, which means it can be +compiled with `xz` support, but it is not required. To provide a package that +uses libarchive with xz you must make a `CArchive+CXz` package that depends on +`CXz` and provides `CArchive`. + +## Packaging Legacy Code + +You may be working with code that builds both as a package and not. For example, +you may be packaging a project that also builds with Xcode. + +In these cases, you can use the preprocessor definition `SWIFT_PACKAGE` to +conditionally compile code for Swift packages. + +In your source file: +```swift +#if SWIFT_PACKAGE +import Foundation +#endif +``` + +## Handling Version-specific Logic + +The package manager is designed to support packages which work with a variety of +Swift project versions, including both the language and the package manager +version. + +In most cases, if you want to support multiple Swift versions in a package you +should do so by using the language-specific version checks available in the +source code itself. However, in some circumstances this may become unmanageable, +specifically, when the package manifest itself cannot be written to be Swift +version agnostic (for example, because it optionally adopts new package manager +features not present in older versions). + +The package manager has support for a mechanism to allow Swift version-specific +customizations for the both package manifest and the package versions which will +be considered. + +### Version-specific Tag Selection + +The tags which define the versions of the package available for clients to use +can _optionally_ be suffixed with a marker in the form of `@swift-3`. When the +package manager is determining the available tags for a repository, _if_ +a version-specific marker is available which matches the current tool version, +then it will *only* consider the versions which have the version-specific +marker. Conversely, version-specific tags will be ignored by any non-matching +tool version. + +For example, suppose the package `Foo` has the tags `[1.0.0, 1.2.0@swift-3, +1.3.0]`. If version 3.0 of the package manager is evaluating the available +versions for this repository, it will only ever consider version `1.2.0`. +However, version 4.0 would consider only `1.0.0` and `1.3.0`. + +This feature is intended for use in the following scenarios: + +1. A package wishes to maintain support for Swift 3.0 in older versions, but + newer versions of the package require Swift 4.0 for the manifest to be + readable. Since Swift 3.0 will not know to ignore those versions, it would + fail when performing dependency resolution on the package if no action is + taken. In this case, the author can re-tag the last versions which supported + Swift 3.0 appropriately. + +2. A package wishes to maintain dual support for Swift 3.0 and Swift 4.0 at the + same version numbers, but this requires substantial differences in the code. + In this case, the author can maintain parallel tag sets for both versions. + +It is *not* expected that the packages would ever use this feature unless absolutely +necessary to support existing clients. Specifically, packages *should not* +adopt this syntax for tagging versions supporting the _latest released_ Swift +version. + +The package manager supports looking for any of the following marked tags, in +order of preference: + +1. `MAJOR.MINOR.PATCH` (e.g., `1.2.0@swift-3.1.2`) +2. `MAJOR.MINOR` (e.g., `1.2.0@swift-3.1`) +3. `MAJOR` (e.g., `1.2.0@swift-3`) + +### Version-specific Manifest Selection + +The package manager will additionally look for a version-specific marked +manifest version when loading the particular version of a package, by searching +for a manifest in the form of `Package@swift-6.swift`. The set of markers +looked for is the same as for version-specific tag selection. + +This feature is intended for use in cases where a package wishes to maintain +compatibility with multiple Swift project versions, but requires a +substantively different manifest file for this to be viable (e.g., due to +changes in the manifest API). + +It is *not* expected the packages would ever use this feature unless absolutely +necessary to support existing clients. Specifically, packages *should not* +adopt this syntax for tagging versions supporting the _latest released_ Swift +version. + +In case the current Swift version doesn't match any version-specific manifest, +the package manager will pick the manifest with the most compatible tools +version. For example, if there are three manifests: + +- `Package.swift` (tools version 6.0) +- `Package@swift-5.10.swift` (tools version 5.10) +- `Package@swift-5.9.swift` (tools version 5.9) + +The package manager will pick `Package.swift` on Swift 6 and above, because its +tools version will be most compatible with future version of the package manager. +When using Swift 5.10, it will pick `Package@swift-5.10.swift`. Otherwise, when +using Swift 5.9 it will pick `Package@swift-5.9.swift`, and this is the minimum +tools version this package may be used with. + +A package may have versioned manifest files which specify newer tools versions +than its unversioned `Package.swift` file[^1]. In this scenario, the manifest +corresponding to the newest-compatible tools version will be used. + +[^1]: Support for having a versioned manifest file with a _newer_ tools version was required when the feature was first introduced, because prior versions of the package manager were not aware of the concept and only knew to look for the unversioned `Package.swift`. This is still supported, but there have been many Swift releases since the feature was introduced and it is now considered best practice to have `Package.swift` declare the newest-supported tools version and for versioned manifest files to only specifer older versions. + +## Editing a Package + +Swift package manager supports editing dependencies, when your work requires +making a change to one of your dependencies (for example, to fix a bug, or add +a new API). The package manager moves the dependency into a location under the +`Packages/` directory where it can be edited. + +For the packages which are in the editable state, `swift build` will always use +the exact sources in this directory to build, regardless of their state, Git +repository status, tags, or the tag desired by dependency resolution. In other +words, this will _just build_ against the sources that are present. When an +editable package is present, it will be used to satisfy all instances of that +package in the dependency graph. It is possible to edit all, some, or none of +the packages in a dependency graph, without restriction. + +Editable packages are best used to do experimentation with dependency code, or to +create and submit a patch in the dependency owner's repository (upstream). +There are two ways to put a package in editable state: + + $ swift package edit Foo --branch bugFix + +This will create a branch called `bugFix` from the currently resolved version and +put the dependency `Foo` in the `Packages/` directory. + + $ swift package edit Foo --revision 969c6a9 + +This is similar to the previous version, except that the Package Manager will leave +the dependency at a detached HEAD on the specified revision. + +Note: If the branch or revision option is not provided, the Package Manager will +checkout the currently resolved version on a detached HEAD. + +Once a package is in an editable state, you can navigate to the directory +`Packages/Foo` to make changes, build and then push the changes or open a pull +request to the upstream repository. + +You can end editing a package using `unedit` command: + + $ swift package unedit Foo + +This will remove the edited dependency from `Packages/` and put the originally +resolved version back. + +This command fails if there are uncommitted changes or changes which are not +pushed to the remote repository. If you want to discard these changes and +unedit, you can use the `--force` option: + + $ swift package unedit Foo --force + +### Top of Tree Development + +This feature allows overriding a dependency with a local checkout on the +filesystem. This checkout is completely unmanaged by the package manager and +will be used as-is. The only requirement is that the package name in the +overridden checkout should not change. This is extremely useful when developing +multiple packages in tandem or when working on packages alongside an +application. + +The command to attach (or create) a local checkout is: + + $ swift package edit --path + +For example, if `Foo` depends on `Bar` and you have a checkout of `Bar` at +`/workspace/bar`: + + foo$ swift package edit Bar --path /workspace/bar + +A checkout of `Bar` will be created if it doesn't exist at the given path. If +a checkout exists, package manager will validate the package name at the given +path and attach to it. + +The package manager will also create a symlink in the `Packages/` directory to the +checkout path. + +Use unedit command to stop using the local checkout: + + $ swift package unedit + # Example: + $ swift package unedit Bar + +## Resolving Versions (Package.resolved File) + +The package manager records the result of dependency resolution in a +`Package.resolved` file in the top-level of the package, and when this file is +already present in the top-level, it is used when performing dependency +resolution, rather than the package manager finding the latest eligible version +of each package. Running `swift package update` updates all dependencies to the +latest eligible versions and updates the `Package.resolved` file accordingly. + +Resolved versions will always be recorded by the package manager. Some users may +choose to add the Package.resolved file to their package's .gitignore file. When +this file is checked in, it allows a team to coordinate on what versions of the +dependencies they should use. If this file is gitignored, each user will +separately choose when to get new versions based on when they run the `swift +package update` command, and new users will start with the latest eligible +version of each dependency. Either way, for a package which is a dependency of +other packages (e.g., a library package), that package's `Package.resolved` file +will not have any effect on its client packages. + +The `swift package resolve` command resolves the dependencies, taking into +account the current version restrictions in the `Package.swift` manifest and +`Package.resolved` resolved versions file, and issuing an error if the graph +cannot be resolved. For packages which have previously resolved versions +recorded in the `Package.resolved` file, the resolve command will resolve to +those versions as long as they are still eligible. If the resolved version's file +changes (e.g., because a teammate pushed a new version of the file) the next +resolve command will update packages to match that file. After a successful +resolve command, the checked out versions of all dependencies and the versions +recorded in the resolved versions file will match. In most cases the resolve +command will perform no changes unless the `Package.swift` manifest or +`Package.resolved` file have changed. + +Most SwiftPM commands will implicitly invoke the `swift package resolve` +functionality before running, and will cancel with an error if dependencies +cannot be resolved. + +## Setting the Swift Tools Version + +The tools version declares the minimum version of the Swift tools required to +use the package, determines what version of the PackageDescription API should +be used in the `Package.swift` manifest, and determines which Swift language +compatibility version should be used to parse the `Package.swift` manifest. + +When resolving package dependencies, if the version of a dependency that would +normally be chosen specifies a Swift tools version which is greater than the +version in use, that version of the dependency will be considered ineligible +and dependency resolution will continue with evaluating the next-best version. +If no version of a dependency (which otherwise meets the version requirements +from the package dependency graph) supports the version of the Swift tools in +use, a dependency resolution error will result. + +### Swift Tools Version Specification + +The Swift tools version is specified by a special comment in the first line of +the `Package.swift` manifest. To specify a tools version, a `Package.swift` file +must begin with the string `// swift-tools-version:`, followed by a version +number specifier. + +The version number specifier follows the syntax defined by semantic versioning +2.0, with an amendment that the patch version component is optional and +considered to be 0 if not specified. The `semver` syntax allows for an optional +pre-release version component or build version component; those components will +be completely ignored by the package manager currently. +After the version number specifier, an optional `;` character may be present; +it, and anything else after it until the end of the first line, will be ignored +by this version of the package manager, but is reserved for the use of future +versions of the package manager. + +Some Examples: + + // swift-tools-version:3.1 + // swift-tools-version:3.0.2 + // swift-tools-version:4.0 + +### Tools Version Commands + +The following Swift tools version commands are supported: + +* Report tools version of the package: + + $ swift package tools-version + +* Set the package's tools version to the version of the tools currently in use: + + $ swift package tools-version --set-current + +* Set the tools version to a given value: + + $ swift package tools-version --set + +## Testing + +Use the `swift test` tool to run the tests of a Swift package. For more information on +the test tool, run `swift test --help`. + +## Running + +Use the `swift run [executable [arguments...]]` tool to run an executable product of a Swift +package. The executable's name is optional when running without arguments and when there +is only one executable product. For more information on the run tool, run +`swift run --help`. + +## Setting the Build Configuration + +SwiftPM allows two build configurations: Debug (default) and Release. + +### Debug + +By default, running `swift build` will build in its debug configuration. +Alternatively, you can also use `swift build -c debug`. The build artifacts are +located in a directory called `debug` under the build folder. A Swift target is built +with the following flags in debug mode: + +* `-Onone`: Compile without any optimization. +* `-g`: Generate debug information. +* `-enable-testing`: Enable the Swift compiler's testability feature. + +A C language target is built with the following flags in debug mode: + +* `-O0`: Compile without any optimization. +* `-g`: Generate debug information. + +### Release + +To build in release mode, type `swift build -c release`. The build artifacts +are located in directory named `release` under the build folder. A Swift target is +built with following flags in release mode: + +* `-O`: Compile with optimizations. +* `-whole-module-optimization`: Optimize input files (per module) together + instead of individually. + +A C language target is built with following flags in release mode: + +* `-O2`: Compile with optimizations. + +### Additional Flags + +You can pass more flags to the C, C++, or Swift compilers in three different ways: + +* Command-line flags passed to these tools: flags like `-Xcc` or `-Xswiftc` are used to + pass C or Swift flags to all targets, as shown with `-Xlinker` above. +* Target-specific flags in the manifest: options like `cSettings` or `swiftSettings` are + used for fine-grained control of compilation flags for particular targets. +* A destination JSON file: once you have a set of working command-line flags that you + want applied to all targets, you can collect them in a JSON file and pass them in through + `extra-cc-flags` and `extra-swiftc-flags` with `--destination example.json`. Take a + look at `Utilities/build_ubuntu_cross_compilation_toolchain` for an example. + +One difference is that C flags passed in the `-Xcc` command-line or manifest's `cSettings` +are supplied to the Swift compiler too for convenience, but `extra-cc-flags` aren't. + +## Depending on Apple Modules + +Swift Package Manager includes a build system that can build for macOS and Linux. +Xcode 11 integrates with `libSwiftPM` to provide support for iOS, watchOS, and tvOS platforms. +To build your package with Xcode from command line you can use +[`xcodebuild`](https://developer.apple.com/library/archive/technotes/tn2339/_index.html). +An example invocation would be: + +``` +xcodebuild -scheme Foo -destination 'generic/platform=iOS' +``` + +where `Foo` would be the name of the library product you're trying to build. You can +get the full list of available schemes for you SwiftPM package with `xcodebuild -list`. +You can get the list of available destinations for a given scheme with this invocation: + +``` +xcodebuild -showdestinations -scheme Foo +``` + + +## Creating C Language Targets + +C language targets are similar to Swift targets, except that the C language +libraries should contain a directory named `include` to hold the public headers. + +To allow a Swift target to import a C language target, add a [target](PackageDescription.md#target) in the manifest file. Swift Package Manager will +automatically generate a modulemap for each C language library target for these +3 cases: + +* If `include/Foo/Foo.h` exists and `Foo` is the only directory under the + include directory, and the include directory contains no header files, then + `include/Foo/Foo.h` becomes the umbrella header. + +* If `include/Foo.h` exists and `include` contains no other subdirectory, then + `include/Foo.h` becomes the umbrella header. + +* Otherwise, the `include` directory becomes an umbrella directory, which means + that all headers under it will be included in the module. + +In case of complicated `include` layouts or headers that are not compatible with +modules, a custom `module.modulemap` can be provided in the `include` directory. + +For executable targets, only one valid C language main file is allowed, e.g., it +is invalid to have `main.c` and `main.cpp` in the same target. + +## Using Shell Completion Scripts + +SwiftPM ships with completion scripts for both Bash and ZSH. These files should be generated in order to use them. + +### Bash + +Use the following commands to install the Bash completions to `~/.swift-package-complete.bash` and automatically load them using your `~/.bash_profile` file. + +```bash +swift package completion-tool generate-bash-script > ~/.swift-package-complete.bash +echo -e "source ~/.swift-package-complete.bash\n" >> ~/.bash_profile +source ~/.swift-package-complete.bash +``` + +Alternatively, add the following commands to your `~/.bash_profile` file to directly load completions: + +```bash +# Source Swift completion +if [ -n "`which swift`" ]; then + eval "`swift package completion-tool generate-bash-script`" +fi +``` + +### ZSH + +Use the following commands to install the ZSH completions to `~/.zsh/_swift`. You can chose a different folder, but the filename should be `_swift`. This will also add `~/.zsh` to your `$fpath` using your `~/.zshrc` file. + +```bash +mkdir ~/.zsh +swift package completion-tool generate-zsh-script > ~/.zsh/_swift +echo -e "fpath=(~/.zsh \$fpath)\n" >> ~/.zshrc +compinit +``` 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 aa32ba1e384..60b0e7627dc 100644 --- a/Package.swift +++ b/Package.swift @@ -526,7 +526,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"]), @@ -584,7 +585,7 @@ let package = Package( "Workspace", "XCBuildSupport", "SwiftBuildSupport", - "SwiftFixIt", + "SwiftFixIt" ] + swiftSyntaxDependencies(["SwiftIDEUtils", "SwiftRefactor"]), exclude: ["CMakeLists.txt", "README.md"], swiftSettings: swift6CompatibleExperimentalFeatures + [ @@ -1108,7 +1109,7 @@ if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil { // utils/update_checkout/update-checkout-config.json // They are used to build the official swift toolchain. .package(url: "https://github.com/swiftlang/swift-syntax.git", branch: relatedDependenciesBranch), - .package(url: "https://github.com/apple/swift-argument-parser.git", revision: "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", revision: "3.12.5"), .package(url: "https://github.com/apple/swift-system.git", revision: "1.5.0"), .package(url: "https://github.com/apple/swift-collections.git", revision: "1.1.6"), diff --git a/README.md b/README.md index eb35c7024a7..efb37e3e62e 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 5758fff800b..219e52bd0a0 100644 --- a/Sources/Commands/PackageCommands/Init.swift +++ b/Sources/Commands/PackageCommands/Init.swift @@ -11,17 +11,19 @@ //===----------------------------------------------------------------------===// import ArgumentParser + import Basics @_spi(SwiftPMInternal) import CoreCommands import PackageModel -import Workspace import SPMBuildCore +import TSCUtility +import Workspace extension SwiftPackageCommand { - struct Init: SwiftCommand { + struct Init: AsyncSwiftCommand { public static let configuration = CommandConfiguration( abstract: "Initialize a new package.", helpNames: [.short, .long, .customLong("help", withSingleDash: true)] @@ -29,64 +31,391 @@ extension SwiftPackageCommand { @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 + + 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 + } - // 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) + /// 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 09c74f30c79..00d5bff4d29 100644 --- a/Sources/Commands/PackageCommands/ShowExecutables.swift +++ b/Sources/Commands/PackageCommands/ShowExecutables.swift @@ -36,6 +36,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 6c7437fe991..9af07da67c3 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, ShowTraits.self, ToolsVersionCommand.self, ComputeChecksum.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 d2eefa5fd97..0a36bcf27ff 100644 --- a/Sources/Commands/SwiftTestCommand.swift +++ b/Sources/Commands/SwiftTestCommand.swift @@ -262,7 +262,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand { discussion: "SEE ALSO: swift build, swift run, swift package", version: SwiftVersion.current.completeDisplayString, subcommands: [ - List.self, Last.self + List.self, Last.self, Template.self ], helpNames: [.short, .long, .customLong("help", withSingleDash: true)]) diff --git a/Sources/Commands/TestCommands/TestTemplateCommand.swift b/Sources/Commands/TestCommands/TestTemplateCommand.swift new file mode 100644 index 00000000000..477e311b5c6 --- /dev/null +++ b/Sources/Commands/TestCommands/TestTemplateCommand.swift @@ -0,0 +1,593 @@ +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", + shouldDisplay: false //until we reimplement/reorganize parsing logic + ) + + @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.resolveTemplateNameInPackage(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 resolveTemplateNameInPackage( + 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 e5e82f10126..502678968fe 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..c048af2f664 --- /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) + } + } +} diff --git a/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift b/Sources/Commands/Utilities/_InternalInitSupport/PackageInitializer.swift new file mode 100644 index 00000000000..7d30ff999d6 --- /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.resolveTemplateNameInPackage(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 resolveTemplateNameInPackage(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..38162aa9554 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalInitSupport/TemplatePluginManager.swift @@ -0,0 +1,159 @@ +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] { + return try TemplateCLIConstructor( + hasTTY: self.swiftCommandState.outputStream.isTTY, observabilityScope: self.swiftCommandState.observabilityScope).createCLIArgs(predefinedArgs: self.args, toolInfoJson: toolInfo) + } + + /// 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..be4ad51d085 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTestDirectoryManager.swift @@ -0,0 +1,42 @@ +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 fileSystem.createDirectory(outputDirectoryPath) + } 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..666ebc45142 --- /dev/null +++ b/Sources/Commands/Utilities/_InternalTemplateTestSupport/TemplateTesterManager.swift @@ -0,0 +1,1941 @@ +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. + public 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 + func parseAndMatchArguments( + _ input: [String], + definedArgs: [ArgumentInfoV0], + subcommands: [CommandInfoV0] = [] + ) throws -> (Set, [String]) { + let context = ParseContext( + input: input, + definedArgs: definedArgs, + subcommands: subcommands + ) + + // Phase 1: Pre-processing - extract terminators and build lookup tables + let preprocessed = try preprocessTokens(context: context) + + // Phase 2: Token classification and extraction + let classified = try classifyAndExtractTokens(context: preprocessed) + + // Phase 3: Parse arguments according to their strategies + let parsed = try parseArgumentsByStrategy(context: classified) + + // Phase 4: Apply defaults and validate requirements + let validated = try applyDefaultsAndValidate(context: parsed) + + // Phase 5: Build final responses + let (responses, leftover) = try buildFinalResponses(context: validated) + + return (Set(responses), leftover) + } + + /// Parses option values based on the argument's parsing strategy. + /// + /// This helper method handles the complexity of parsing option values according to + /// different parsing strategies defined in the argument specification. + /// + /// - Parameters: + /// - arg: The argument definition containing parsing strategy and validation rules + /// - tokens: The remaining input tokens (modified in-place as tokens are consumed) + /// - currentIndex: The current position in the tokens array (modified in-place) + /// + /// - Returns: An array of parsed values for the option + /// + /// - Throws: + /// - `TemplateError.missingValueForOption` when required values are missing + /// - `TemplateError.invalidValue` when values don't match allowed value constraints + /// + /// ## Supported Parsing Strategies + /// + /// - **Default**: Expects the next token to be a value + /// - **Scanning for Value**: Scans for a value, allowing defaults if none found + /// - **Unconditional**: Always consumes the next token regardless of its format + /// - **Up to Next Option**: Consumes tokens until another option is encountered + /// - **All Remaining Input**: Consumes all remaining tokens + /// - **Post Terminator/All Unrecognized**: Handled separately in main parsing logic + /// Helper method to parse option values based on parsing strategy + private func parseOptionValues( + arg: ArgumentInfoV0, + tokens: inout [String], + currentIndex: inout Int + ) throws -> [String] { + var values: [String] = [] + + switch arg.parsingStrategy { + case .default: + // Expect the next token to be a value and parse it + 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 + } + + // MARK: - Enhanced Parsing Context and Helper Structures + + private struct ParseContext { + let originalInput: [String] + let definedArgs: [ArgumentInfoV0] + let subcommands: [CommandInfoV0] + let subcommandNames: Set + let argumentLookup: [String: ArgumentInfoV0] + var tokens: [String] + var leftover: [String] + var responses: [ArgumentResponse] + var providedValues: [String: [String]] + var postTerminatorArgs: [String] + var terminatorSeen: Bool + + init(input: [String], definedArgs: [ArgumentInfoV0], subcommands: [CommandInfoV0]) { + self.originalInput = input + self.definedArgs = definedArgs + self.subcommands = subcommands + self.subcommandNames = Set(subcommands.map(\.commandName)) + self.argumentLookup = Self.buildArgumentLookup(definedArgs) + self.tokens = input + self.leftover = [] + self.responses = [] + self.providedValues = [:] + self.postTerminatorArgs = [] + self.terminatorSeen = false + } + + private static func buildArgumentLookup(_ args: [ArgumentInfoV0]) -> [String: ArgumentInfoV0] { + var lookup: [String: ArgumentInfoV0] = [:] + + for arg in args { + // Add all possible names for this argument + if let names = arg.names { + for nameInfo in names { + lookup[nameInfo.name] = arg + } + } + + // Add valueName as a fallback + if let valueName = arg.valueName { + lookup[valueName] = arg + } + } + + return lookup + } + + func findArgument(byName name: String) -> ArgumentInfoV0? { + return argumentLookup[name] + } + + func getArgumentName(_ arg: ArgumentInfoV0) -> String { + return arg.valueName ?? arg.names?.first?.name ?? "__unknown" + } + } + + private struct TokenClassification { + let flags: [ClassifiedToken] + let options: [ClassifiedToken] + let positionals: [String] + let subcommands: [String] + let unrecognized: [String] + let optionValuePairs: [(String, String)] + } + + private struct ClassifiedToken { + let argument: ArgumentInfoV0 + let name: String + let values: [String] + let originalTokens: [String] + } + + // MARK: - Enhanced Parsing Phase Methods + + private func preprocessTokens(context: ParseContext) throws -> ParseContext { + var updatedContext = context + + // Handle terminator (--) extraction + if let terminatorIndex = updatedContext.tokens.firstIndex(of: "--") { + updatedContext.postTerminatorArgs = Array(updatedContext.tokens[(terminatorIndex + 1)...]) + updatedContext.tokens = Array(updatedContext.tokens[.. ParseContext { + var updatedContext = context + var flags: [ClassifiedToken] = [] + var options: [ClassifiedToken] = [] + var positionals: [String] = [] + var subcommands: [String] = [] + var unrecognized: [String] = [] + var optionValuePairs: [(String, String)] = [] + + var i = 0 + var consumedIndices: Set = [] + + // First pass: identify and extract named arguments (flags/options) + while i < updatedContext.tokens.count { + guard !consumedIndices.contains(i) else { + i += 1 + continue + } + + let token = updatedContext.tokens[i] + + if token.starts(with: "--") { + let name = String(token.dropFirst(2)) + + if let arg = updatedContext.findArgument(byName: name) { + consumedIndices.insert(i) + + switch arg.kind { + case .flag: + flags.append(ClassifiedToken( + argument: arg, + name: name, + values: ["true"], + originalTokens: [token] + )) + + case .option: + let (values, consumedCount) = try extractOptionValues( + arg: arg, + tokens: updatedContext.tokens, + startIndex: i + 1, + subcommandNames: updatedContext.subcommandNames + ) + + for j in (i + 1)..<(i + 1 + consumedCount) { + consumedIndices.insert(j) + } + + options.append(ClassifiedToken( + argument: arg, + name: name, + values: values, + originalTokens: Array(updatedContext.tokens[i...(i + consumedCount)]) + )) + + case .positional: + throw TemplateError.unexpectedNamedArgument(name: name) + } + } else { + // Unknown named argument - preserve for subcommands + unrecognized.append(token) + consumedIndices.insert(i) + + // Check if next token could be a value for this unknown option + if i + 1 < updatedContext.tokens.count && + !updatedContext.tokens[i + 1].starts(with: "--") && + !updatedContext.subcommandNames.contains(updatedContext.tokens[i + 1]) { + let nextToken = updatedContext.tokens[i + 1] + optionValuePairs.append((token, nextToken)) + unrecognized.append(nextToken) + consumedIndices.insert(i + 1) + } + } + } + + i += 1 + } + + // Second pass: classify remaining tokens as positionals or subcommands + // Stop at the first subcommand and put everything after it in leftover + var firstSubcommandIndex: Int? = nil + + for (index, token) in updatedContext.tokens.enumerated() { + guard !consumedIndices.contains(index) else { continue } + + if updatedContext.subcommandNames.contains(token) { + // Found first subcommand - everything from here goes to leftover + firstSubcommandIndex = index + break + } else { + positionals.append(token) + } + } + + // If we found a subcommand, everything from that point onwards is leftover + if let firstSubcommandIndex = firstSubcommandIndex { + for index in firstSubcommandIndex.. = [] + + for flag in flags { + let argName = updatedContext.getArgumentName(flag.argument) + if !processedArguments.contains(argName) { + let allValues = argumentAccumulator[argName] ?? [] + + // For non-repeating arguments, validate that we don't have multiple occurrences + if !flag.argument.isRepeating { + let occurrenceCount = flags.filter { + updatedContext.getArgumentName($0.argument) == argName + }.count + if occurrenceCount > 1 { + throw TemplateError.tooManyValues(argument: argName, expected: 1, received: occurrenceCount) + } + } + + let values = try validateArgumentValues(allValues, for: flag.argument, argName: argName) + updatedContext.responses.append(ArgumentResponse( + argument: flag.argument, + values: values, + isExplicitlyUnset: false + )) + processedArguments.insert(argName) + } + } + + for option in options { + let argName = updatedContext.getArgumentName(option.argument) + if !processedArguments.contains(argName) { + let allValues = argumentAccumulator[argName] ?? [] + + // For non-repeating arguments, validate that we don't have multiple occurrences + if !option.argument.isRepeating { + let occurrenceCount = options.filter { + updatedContext.getArgumentName($0.argument) == argName + }.count + if occurrenceCount > 1 { + throw TemplateError.tooManyValues(argument: argName, expected: 1, received: occurrenceCount) + } + } + + let values = try validateArgumentValues(allValues, for: option.argument, argName: argName) + updatedContext.responses.append(ArgumentResponse( + argument: option.argument, + values: values, + isExplicitlyUnset: false + )) + processedArguments.insert(argName) + } + } + + // Process positional arguments in order + let positionalArgs = updatedContext.definedArgs.filter { $0.kind == .positional } + var positionalIndex = 0 + var tokenIndex = 0 + + while tokenIndex < positionals.count && positionalIndex < positionalArgs.count { + let arg = positionalArgs[positionalIndex] + let argName = updatedContext.getArgumentName(arg) + + var values: [String] = [] + + if arg.isRepeating { + // Collect all remaining positional tokens for repeating argument + while tokenIndex < positionals.count { + values.append(positionals[tokenIndex]) + tokenIndex += 1 + } + } else { + // Take single token for non-repeating argument + values.append(positionals[tokenIndex]) + tokenIndex += 1 + } + + let validatedValues = try validateArgumentValues(values, for: arg, argName: argName) + updatedContext.responses.append(ArgumentResponse( + argument: arg, + values: validatedValues, + isExplicitlyUnset: false + )) + + positionalIndex += 1 + } + + // Update context with leftover tokens + updatedContext.leftover = unrecognized + subcommands + + return updatedContext + } + + private func extractOptionValues( + arg: ArgumentInfoV0, + tokens: [String], + startIndex: Int, + subcommandNames: Set + ) throws -> ([String], Int) { + var values: [String] = [] + var consumedCount = 0 + var currentIndex = startIndex + + switch arg.parsingStrategy { + case .default: + guard currentIndex < tokens.count && + !tokens[currentIndex].starts(with: "-") && + !subcommandNames.contains(tokens[currentIndex]) else { + throw TemplateError.missingValueForOption(name: arg.valueName ?? "") + } + values.append(tokens[currentIndex]) + consumedCount = 1 + + case .scanningForValue: + if currentIndex < tokens.count && + !tokens[currentIndex].starts(with: "-") && + !subcommandNames.contains(tokens[currentIndex]) { + values.append(tokens[currentIndex]) + consumedCount = 1 + } + + case .unconditional: + guard currentIndex < tokens.count else { + throw TemplateError.missingValueForOption(name: arg.valueName ?? "") + } + values.append(tokens[currentIndex]) + consumedCount = 1 + + case .upToNextOption: + while currentIndex < tokens.count && + !tokens[currentIndex].starts(with: "-") && + !subcommandNames.contains(tokens[currentIndex]) { + values.append(tokens[currentIndex]) + currentIndex += 1 + consumedCount += 1 + } + + case .allRemainingInput: + // Consume all remaining tokens from current position + while currentIndex < tokens.count && + !subcommandNames.contains(tokens[currentIndex]) { + values.append(tokens[currentIndex]) + currentIndex += 1 + consumedCount += 1 + } + + case .postTerminator, .allUnrecognized: + // These strategies are handled in later phases + break + } + + return (values, consumedCount) + } + + private func parseArgumentsByStrategy(context: ParseContext) throws -> ParseContext { + var updatedContext = context + + // Process special parsing strategies first + for arg in updatedContext.definedArgs { + let argName = updatedContext.getArgumentName(arg) + + switch arg.parsingStrategy { + case .postTerminator: + if updatedContext.terminatorSeen { + let values = try validateArgumentValues(updatedContext.postTerminatorArgs, for: arg, argName: argName) + updatedContext.responses.append(ArgumentResponse( + argument: arg, + values: values, + isExplicitlyUnset: false + )) + } + + case .allUnrecognized: + let unrecognizedTokens = updatedContext.leftover.filter { token in + !updatedContext.subcommandNames.contains(token) && !token.starts(with: "--") + } + let values = try validateArgumentValues(unrecognizedTokens, for: arg, argName: argName) + updatedContext.responses.append(ArgumentResponse( + argument: arg, + values: values, + isExplicitlyUnset: false + )) + + // Remove consumed tokens from leftover + updatedContext.leftover = updatedContext.leftover.filter { token in + updatedContext.subcommandNames.contains(token) || token.starts(with: "--") + } + + default: + // Handle normal parsing strategies in classification phase + break + } + } + + return updatedContext + } + + private func applyDefaultsAndValidate(context: ParseContext) throws -> ParseContext { + var updatedContext = context + let processedArgNames = Set(updatedContext.responses.map { updatedContext.getArgumentName($0.argument) }) + + // Apply defaults and validate requirements + for arg in updatedContext.definedArgs { + let argName = updatedContext.getArgumentName(arg) + + // Skip if already processed + if processedArgNames.contains(argName) { + continue + } + + // Check if argument was provided via named arguments + if let providedValues = updatedContext.providedValues[argName] { + let values = try validateArgumentValues(providedValues, for: arg, argName: argName) + updatedContext.responses.append(ArgumentResponse( + argument: arg, + values: values, + isExplicitlyUnset: false + )) + continue + } + + // Apply default value if available + if let defaultValue = arg.defaultValue { + let values = try validateArgumentValues([defaultValue], for: arg, argName: argName) + updatedContext.responses.append(ArgumentResponse( + argument: arg, + values: values, + isExplicitlyUnset: false + )) + continue + } + + // Check if required argument is missing + if !arg.isOptional && arg.parsingStrategy != .postTerminator { + throw TemplateError.missingRequiredArgument(name: argName) + } + + // Add as explicitly unset for optional arguments + if arg.isOptional { + updatedContext.responses.append(ArgumentResponse( + argument: arg, + values: [], + isExplicitlyUnset: true + )) + } + } + + return updatedContext + } + + private func buildFinalResponses(context: ParseContext) throws -> ([ArgumentResponse], [String]) { + return (context.responses, context.leftover) + } + + private func validateArgumentValues( + _ values: [String], + for arg: ArgumentInfoV0, + argName: String + ) throws -> [String] { + // Validate against allowed values if specified + if let allowedValues = arg.allValues { + let invalidValues = values.filter { !allowedValues.contains($0) } + if !invalidValues.isEmpty { + throw TemplateError.invalidValue( + argument: argName, + invalidValues: invalidValues, + allowed: allowedValues + ) + } + } + return values + } + + /// Generates all possible command paths for template testing. + /// + /// This is the main entry point for command path generation. It uses swift-argument-parser + /// style parsing for improved consistency and reliability. + /// + /// - 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 + // Filter inherited responses to avoid conflicts with current command's arguments + let currentArgNames = Set(allArgs.map(\.valueName)) + let relevantInheritedResponses = inheritedResponses.filter { inherited in + !currentArgNames.contains(inherited.argument.valueName) && + // Also check if any current responses would conflict + !answeredArgs.contains { answered in + answered.argument.valueName == inherited.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 { + // Branch filtering: branchDepth 0 = root, so first branch corresponds to branchDepth 0 + branchDepth < branches.count && 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 { + // Adjust for root command: branchDepth 0 corresponds to first branch element + branchDepth < branches.count && 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] { + return command.arguments ?? [] + } + + /// A utility for prompting users for command argument values. + /// + /// `UserPrompter` provides static methods for interactively prompting users to provide + /// values for command arguments when they haven't been specified via command-line arguments. + /// It handles different argument types (flags, options, positional) and supports both + /// interactive (TTY) and non-interactive modes. + /// + /// ## Features + /// + /// - **Interactive Prompting**: Prompts users when a TTY is available + /// - **Default Value Handling**: Uses default values when provided and no user input given + /// - **Value Validation**: Validates input against allowed value constraints + /// - **Completion Hints**: Provides completion suggestions based on argument metadata + /// - **Explicit Unset Support**: Allows users to explicitly unset optional arguments with "nil" + /// - **Repeating Arguments**: Supports prompting for multiple values for repeating arguments + /// + /// ## Argument Types + /// + /// - **Flags**: Boolean arguments prompted with yes/no confirmation + /// - **Options**: String arguments with optional value validation + /// - **Positional**: Arguments that don't use flag syntax + public enum UserPrompter { + /// Prompts 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 { + throw TemplateError.missingRequiredArgumentWithoutTTY(name: arg.valueName ?? "") + } + + 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 { + 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: ", "))") + print("Or try completion suggestions: \(self.generateCompletionSuggestions(for: arg, input: input))") + continue + } + values.append(input) + } + } + if values.isEmpty, let def = arg.defaultValue { + values = [def] + } + } else { + if hasTTY { + while true { + guard let input = readLine(), !input.isEmpty else { + if let def = arg.defaultValue { + values = [def] + break + } + if !arg.isOptional { + print("This option/argument is required. Please specify a value") + continue + } + break + } + + if input.lowercased() == "nil" && arg.isOptional { + 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: ", "))") + print("Or try completion suggestions: \(self.generateCompletionSuggestions(for: arg, input: input))") + continue + } + + values = [input] + break + } + } else { + if let def = arg.defaultValue { + values = [def] + } else if arg.isOptional { + values = [] + let response = ArgumentResponse( + argument: arg, + values: values, + isExplicitlyUnset: true + ) + collected[key] = response + return response + + } 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, Equatable { + /// 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 + + /// A required argument is missing. + /// - Parameter name: The name of the missing required argument + case missingRequiredArgument(name: String) + + /// Too many values provided for a non-repeating argument. + /// - Parameters: + /// - argument: The argument name that received too many values + /// - expected: The expected number of values (typically 1) + /// - received: The actual number of values received + case tooManyValues(argument: String, expected: Int, received: Int) +} + +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" + case .missingRequiredArgument(name: let name): + "Required argument '\(name)' not provided" + case .tooManyValues(argument: let argument, expected: let expected, received: let received): + "Too many values for argument '\(argument)'. Expected \(expected), but received \(received)" + } + } +} diff --git a/Sources/CoreCommands/SwiftCommandState.swift b/Sources/CoreCommands/SwiftCommandState.swift index 2067b20f426..58d60914b56 100644 --- a/Sources/CoreCommands/SwiftCommandState.swift +++ b/Sources/CoreCommands/SwiftCommandState.swift @@ -209,7 +209,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 { @@ -234,7 +234,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 @@ -1374,3 +1374,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/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 77169042aa0..3b79a138097 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/Runtimes/PackageDescription/PackageDescriptionSerialization.swift b/Sources/Runtimes/PackageDescription/PackageDescriptionSerialization.swift index 8ade7137333..a4c730e6d17 100644 --- a/Sources/Runtimes/PackageDescription/PackageDescriptionSerialization.swift +++ b/Sources/Runtimes/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/Runtimes/PackageDescription/PackageDescriptionSerializationConversion.swift b/Sources/Runtimes/PackageDescription/PackageDescriptionSerializationConversion.swift index 09d4f73ebf3..06d1663602c 100644 --- a/Sources/Runtimes/PackageDescription/PackageDescriptionSerializationConversion.swift +++ b/Sources/Runtimes/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/Runtimes/PackageDescription/Product.swift b/Sources/Runtimes/PackageDescription/Product.swift index e3b6a7352c4..79e48f2a79b 100644 --- a/Sources/Runtimes/PackageDescription/Product.swift +++ b/Sources/Runtimes/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/Runtimes/PackageDescription/Target.swift b/Sources/Runtimes/PackageDescription/Target.swift index 9c7183a82f4..e848de18ab5 100644 --- a/Sources/Runtimes/PackageDescription/Target.swift +++ b/Sources/Runtimes/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/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/CMakeLists.txt b/Sources/Workspace/CMakeLists.txt index cdc939112f2..1dab2c807f0 100644 --- a/Sources/Workspace/CMakeLists.txt +++ b/Sources/Workspace/CMakeLists.txt @@ -9,7 +9,6 @@ add_library(Workspace CheckoutState.swift Diagnostics.swift - InitPackage.swift LoadableResult.swift ManagedArtifact.swift ManagedDependency.swift @@ -26,6 +25,18 @@ add_library(Workspace PackageContainer/SourceControlPackageContainer.swift ResolvedFileWatcher.swift ResolverPrecomputationProvider.swift + TemplateWorkspaceUtilities/InitTemplatePackage.swift + TemplateWorkspaceUtilities/InitPackage.swift + TemplateWorkspaceUtilities/TemplateDirectoryManager.swift + TemplateWorkspaceUtilities/ArgumentProcessing/ArgumentProcessor.swift + TemplateWorkspaceUtilities/ArgumentProcessing/ArgumentResponse.swift + TemplateWorkspaceUtilities/CommandParsing/ParsedArgument.swift + TemplateWorkspaceUtilities/CommandParsing/SplitArguments.swift + TemplateWorkspaceUtilities/CommandParsing/TemplateCommandParser.swift + TemplateWorkspaceUtilities/Models/ErrorModels.swift + TemplateWorkspaceUtilities/Models/ParsingModels.swift + TemplateWorkspaceUtilities/Models/ProcessingModels.swift + TemplateWorkspaceUtilities/UserInteraction/InteractivePrompter.swift ToolsVersionSpecificationRewriter.swift Workspace.swift Workspace+BinaryArtifacts.swift diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/ArgumentProcessing/ArgumentProcessor.swift b/Sources/Workspace/TemplateWorkspaceUtilities/ArgumentProcessing/ArgumentProcessor.swift new file mode 100644 index 00000000000..e4a4d4554e6 --- /dev/null +++ b/Sources/Workspace/TemplateWorkspaceUtilities/ArgumentProcessing/ArgumentProcessor.swift @@ -0,0 +1,154 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024-2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import ArgumentParserToolInfo +import Foundation +import Basics + +public class TemplateCLIConstructor { + private let hasTTY: Bool + private let observabilityScope: ObservabilityScope? + + public init(hasTTY: Bool, observabilityScope: ObservabilityScope? = nil) { + self.hasTTY = hasTTY + self.observabilityScope = observabilityScope + } + + public func createCLIArgs(predefinedArgs: [String], toolInfoJson: ToolInfoV0) + throws -> [String] + { + self.observabilityScope? + .emit( + debug: "Starting template argument parsing with predefined args: [\(predefinedArgs.joined(separator: ", "))]" + ) + + // we are now at the top, we have access to the root command + var parser = TemplateCommandParser(toolInfoJson.command, observabilityScope: self.observabilityScope) + + do { + // First attempt: try parsing with predefined arguments + self.observabilityScope?.emit(debug: "Attempting to parse predefined arguments") + let commandPath = try parser.parseWithPrompting(predefinedArgs, hasTTY: self.hasTTY) + let result = self.buildCommandLine(from: commandPath) + self.observabilityScope? + .emit(debug: "Successfully parsed template arguments, result: [\(result.joined(separator: ", "))]") + return result + } catch { + self.observabilityScope? + .emit(warning: "Initial template argument parsing failed: \(self.formatErrorMessage(error))") + // On parsing failure, implement fallback strategy + return try self.handleParsingError(error, toolInfoJson: toolInfoJson, predefinedArgs: predefinedArgs) + } + } + + private func handleParsingError( + _ error: Error, + toolInfoJson: ToolInfoV0, + predefinedArgs: [String] + ) throws -> [String] { + self.observabilityScope?.emit(debug: "Handling parsing error, checking TTY availability") + + // If no TTY available, re-throw the original error + guard self.hasTTY else { + self.observabilityScope?.emit(debug: "No TTY available, re-throwing original error") + throw error + } + + self.observabilityScope?.emit(debug: "TTY available, falling back to interactive prompting") + + // Print the parsing error to inform the user what went wrong + print("Parsing failed with predefined arguments: \(predefinedArgs.joined(separator: " "))") + print("Error: \(self.formatErrorMessage(error))") + print("\nFalling back to interactive prompting for all arguments...\n") + + // Cancel all predefined inputs and prompt for everything from scratch + self.observabilityScope?.emit(debug: "Creating fresh parser for interactive prompting") + var freshParser = TemplateCommandParser(toolInfoJson.command, observabilityScope: self.observabilityScope) + let commandPath = try freshParser.parseWithPrompting([], hasTTY: self.hasTTY) + // Empty predefined args + + let result = self.buildCommandLine(from: commandPath) + self.observabilityScope? + .emit(debug: "Interactive parsing completed successfully, result: [\(result.joined(separator: ", "))]") + return result + } + + private func formatErrorMessage(_ error: Error) -> String { + switch error { + case let templateError as TemplateError: + self.formatTemplateError(templateError) + case let parsingError as ParsingError: + self.formatParsingError(parsingError) + default: + error.localizedDescription + } + } + + private func formatTemplateError(_ error: TemplateError) -> String { + switch error { + case .unexpectedArguments(let args): + "Unexpected arguments: \(args.joined(separator: ", "))" + case .ambiguousSubcommand(let command, let branches): + "Ambiguous subcommand '\(command)' found in: \(branches.joined(separator: ", "))" + case .noTTYForSubcommandSelection: + "Interactive subcommand selection requires a terminal" + case .missingRequiredArgument(let name): + "Missing required argument: \(name)" + case .invalidArgumentValue(let value, let arg): + "Invalid value '\(value)' for argument '\(arg)'" + case .invalidSubcommandSelection(let validOptions): + "Invalid subcommand selection. Please select a valid index or write a valid subcommand name \(validOptions ?? "")" + case .unsupportedParsingStrategy: + "Unsupported parsing strategy" + } + } + + private func formatParsingError(_ error: ParsingError) -> String { + switch error { + case .missingValueForOption(let option): + "Missing value for option '--\(option)'" + case .invalidValue(let arg, let invalid, let allowed): + if allowed.isEmpty { + "Invalid value '\(invalid.joined(separator: ", "))' for '\(arg)'" + } else { + "Invalid value '\(invalid.joined(separator: ", "))' for '\(arg)'. Valid options: \(allowed.joined(separator: ", "))" + } + case .tooManyValues(let arg, let expected, let received): + "Too many values for '\(arg)' (expected \(expected), got \(received))" + case .unexpectedArgument(let arg): + "Unexpected argument: \(arg)" + case .multipleParsingErrors(let errors): + "Multiple parsing errors: \(errors.map(\.localizedDescription).joined(separator: ", "))" + } + } + + private func buildCommandLine(from commandPath: TemplateCommandPath) -> + [String] + { + var result: [String] = [] + + for (index, component) in commandPath.commandChain.enumerated() { + // Skip root command name, but include subcommand names + if index > 0 { + result.append(component.commandName) + } + + // Add all arguments for this command level + for argument in component.arguments { + result.append(contentsOf: argument.commandLineFragments) + } + } + + return result + } +} + diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/ArgumentProcessing/ArgumentResponse.swift b/Sources/Workspace/TemplateWorkspaceUtilities/ArgumentProcessing/ArgumentResponse.swift new file mode 100644 index 00000000000..47afb6bacc1 --- /dev/null +++ b/Sources/Workspace/TemplateWorkspaceUtilities/ArgumentProcessing/ArgumentResponse.swift @@ -0,0 +1,83 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024-2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import ArgumentParserToolInfo + +public struct ArgumentResponse { + public let argument: ArgumentInfoV0 + public let values: [String] + public let isExplicitlyUnset: Bool + + public var commandLineFragments: [String] { + guard !self.isExplicitlyUnset else { return [] } + + switch self.argument.kind { + case .flag: + // For flags, only include if true + guard self.values.first == "true" else { return [] } + return [self.formatArgumentName()] + + case .option: + // For options, include name-value pairs + return self.values.flatMap { [self.formatArgumentName(), $0] } + + case .positional: + // Positional arguments are just the values + return self.values + } + } + + private func formatArgumentName() -> String { + // Use preferred name format (respects short vs long preference) + if let preferredName = argument.preferredName { + switch preferredName.kind { + case .short: + return "-\(preferredName.name)" + case .long: + return "--\(preferredName.name)" + case .longWithSingleDash: + return "-\(preferredName.name)" + } + } + + // Fallback: use first available name + if let firstName = argument.names?.first { + switch firstName.kind { + case .short: + return "-\(firstName.name)" + case .long: + return "--\(firstName.name)" + case .longWithSingleDash: + return "-\(firstName.name)" + } + } + + // Final fallback: use valueName with long format + if let valueName = argument.valueName { + return "--\(valueName)" + } + + // Should never reach here, but safety fallback + return "--unknown" + } + + public init( + argument: ArgumentInfoV0, + values: [String], + isExplicitlyUnset: + Bool = false + ) { + self.argument = argument + self.values = values + self.isExplicitlyUnset = isExplicitlyUnset + } +} diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/CommandParsing/ParsedArgument.swift b/Sources/Workspace/TemplateWorkspaceUtilities/CommandParsing/ParsedArgument.swift new file mode 100644 index 00000000000..ef3e5a89a31 --- /dev/null +++ b/Sources/Workspace/TemplateWorkspaceUtilities/CommandParsing/ParsedArgument.swift @@ -0,0 +1,117 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024-2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +struct ParsedTemplateArgument: Equatable { + enum ArgumentType: Equatable { + case shortFlag(Character) + case shortOption(Character, String?) + case shortGroup([Character]) + case longFlag(String) + case longOption(String, String?) + } + + let type: ArgumentType + let originalToken: String + + var name: String { + switch self.type { + case .shortFlag(let character), .shortOption(let character, _): + String(character) + case .shortGroup(let characters): + String(characters.first ?? "?") + case .longFlag(let name), .longOption(let name, _): + name + } + } + + var value: String? { + switch self.type { + case .shortOption(_, let value), .longOption(_, let value): + value + default: + nil + } + } + + var isShort: Bool { + switch self.type { + case .shortFlag, .shortOption, .shortGroup: + true + case .longFlag, .longOption: + false + } + } + + func matchesName(_ targetName: String) -> Bool { + self.name == targetName + } + + static func parseLongOption(_ remainder: String) throws -> + ParsedTemplateArgument + { + if let equalIndex = remainder.firstIndex(of: "=") { + let name = String(remainder[.. + [ParsedTemplateArgument] + { + if let equalIndex = remainder.firstIndex(of: "=") { + // -o=value format + let name = String(remainder[.. = [] + var consumptionLog: [ConsumptionRecord] = [] + + var remainingElements: ArraySlice { + self.elements[self.firstUnused...] + } + + var isEmpty: Bool { + self.remainingElements.isEmpty + } + + var count: Int { + self.remainingElements.count + } + + init(arguments: [String]) throws { + self.originalInput = arguments + self.elements = [] + + for (index, args) in arguments.enumerated() { + let element = try Self.parseArgument(args, at: index) + self.elements.append(contentsOf: element) + + // if it is a terminator, we parse the rest as values + if element.first?.isTerminator == true { + for (i, remainingArg) in arguments[(index + 1)...].enumerated() { + self.elements.append(Element( + value: .value(remainingArg), + index: i + 1 + index + )) + } + break + } + } + } + + // MARK: Consumption helpers + + func peekNext() -> Element? { + // Find the first unconsumed element from firstUnused onwards + for element in self.remainingElements { + if !self.consumedIndices.contains(element.index) { + return element + } + } + return nil + } + + mutating func consumeNext() -> Element? { + // Find and consume the first unconsumed element + while !self.isEmpty { + let element = self.remainingElements.first! + self.firstUnused += 1 + + if !self.consumedIndices.contains(element.index) { + self.consumedIndices.insert(element.index) + return element + } + // If already consumed, continue to next element + } + return nil + } + + mutating func consumeNextValue(for argumentName: String? = nil) -> String? { + guard let next = peekNext(), next.isValue else { return nil } + if case .value(let str) = consumeNext()?.value { + // Mark the consumption purpose if we have an argument name + if let argName = argumentName { + self.markAsConsumed( + next.index, + for: .optionValue(argName), + argumentName: argName + ) + } + return str + } + return nil + } + + mutating func scanForNextValue(for argumentName: String? = nil) -> String? { + var scanIndex = self.firstUnused + + while scanIndex < self.elements.count { + let element = self.elements[scanIndex] + + if self.consumedIndices.contains(element.index) { + scanIndex += 1 + continue + } + + switch element.value { + case .value(let str): + self.consumedIndices.insert(element.index) + if let argName = argumentName { + self.markAsConsumed( + element.index, + for: .optionValue(argName), + argumentName: argName + ) + } + return str + + case .option: + scanIndex += 1 + continue + + case .terminator: + // Stop scanning at terminator + return nil + } + } + + return nil + } + + mutating func consumeOption(named name: String) -> ParsedTemplateArgument? { + guard let next = peekNext(), + case .option(let parsed) = next.value, + parsed.matchesName(name) else { return nil } + + let element = self.consumeNext()! + // Mark the option itself as consumed with proper tracking + self.markAsConsumed( + element.index, + for: .optionValue(name), + argumentName: + name + ) + return parsed + } + + // MARK: - Template-Specific Methods + + /// Returns all remaining values (for positional arguments) + var remainingValues: [String] { + self.remainingElements.compactMap { element in + if case .value(let str) = element.value { + return str + } + return nil + } + } + + /// Returns all remaining options with their values + var remainingOptions: [(name: String, value: String?)] { + self.remainingElements.compactMap { element in + if case .option(let parsed) = element.value { + return (parsed.name, parsed.value) + } + return nil + } + } + + // MARK: - Private Parsing + + private static func parseArgument(_ arg: String, at index: Int) throws -> + [Element] + { + if arg == "--" { + return [Element(value: .terminator, index: index)] + } + + if arg.hasPrefix("--") { + // Long option: --name or --name=value + let parsed = try + ParsedTemplateArgument.parseLongOption(String(arg.dropFirst(2))) + return [Element(value: .option(parsed), index: index)] + } + + if arg.hasPrefix("-") && arg.count > 1 { + // Short option(s): -f or -abc or -f=value + let remainder = String(arg.dropFirst()) + if let equalIndex = remainder.firstIndex(of: "=") { + // Single short option with value: -f=value + let name = String(remainder[.. [String] { + guard let terminatorIndex = elements.firstIndex(where: { $0.isTerminator + }) else { + return [] + } + + let postTerminatorValues = elements[(terminatorIndex + 1)...].compactMap { element -> String? + in + if case .value(let str) = element.value { + self.markAsConsumed(element.index, for: .postTerminator) + return str + } + return nil + } + + return postTerminatorValues + } + + var unconsumedElements: ArraySlice { + elements.filter { !consumedIndices.contains($0.index) }[...] + } +} + diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/CommandParsing/TemplateCommandParser.swift b/Sources/Workspace/TemplateWorkspaceUtilities/CommandParsing/TemplateCommandParser.swift new file mode 100644 index 00000000000..009531af367 --- /dev/null +++ b/Sources/Workspace/TemplateWorkspaceUtilities/CommandParsing/TemplateCommandParser.swift @@ -0,0 +1,832 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024-2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import ArgumentParserToolInfo +import class Basics.ObservabilityScope +import Foundation + +public struct TemplateCommandParser { + let rootCommand: CommandInfoV0 + var currentCommand: CommandInfoV0 + var parsedCommands: [CommandComponent] = [] + var commandStack: [CommandInfoV0] = [] + private var parsingErrors: [ParsingError] = [] + private let observabilityScope: ObservabilityScope? + + init(_ rootCommand: CommandInfoV0, observabilityScope: ObservabilityScope? = nil) { + self.rootCommand = rootCommand + self.currentCommand = rootCommand + self.commandStack = [rootCommand] + self.observabilityScope = observabilityScope + } + + mutating func parseWithPrompting(_ arguments: [String], hasTTY: Bool) throws -> TemplateCommandPath { + self.observabilityScope? + .emit(debug: "Parsing template arguments: [\(arguments.joined(separator: ", "))] with TTY: \(hasTTY)") + var split = try SplitArguments(arguments: arguments) + + while true { + self.observabilityScope?.emit(debug: "Processing command: '\(self.currentCommand.commandName)'") + + // 1. Parse available arguments for current command + let parsingResult = try parseAvailableArguments(&split) + + if !parsingResult.errors.isEmpty { + self.observabilityScope? + .emit( + warning: "Parsing errors encountered for command '\(self.currentCommand.commandName)': \(parsingResult.errors.map(\.localizedDescription).joined(separator: ", "))" + ) + } + + // 2. Prompt for missing required arguments (if TTY available) + let prompter = TemplatePrompter(hasTTY: hasTTY) + let parsedArgNames = Set(parsingResult.responses.compactMap { try? self.getArgumentName($0.argument) }) + + self.observabilityScope? + .emit( + debug: "Prompting for missing arguments. Already parsed: [\(parsedArgNames.joined(separator: ", "))]" + ) + let promptedResponses = try + prompter.promptForMissingArguments(self.currentCommand.arguments ?? [], parsed: parsedArgNames) + + // 3. Combine all responses + let allResponses = parsingResult.responses + promptedResponses + let validatedResponses = try validateAllResponses(allResponses) + + // 4. Create command component + let component = CommandComponent( + commandName: currentCommand.commandName, + arguments: validatedResponses + ) + self.parsedCommands.append(component) + self.observabilityScope? + .emit( + debug: "Added command component: '\(self.currentCommand.commandName)' with \(validatedResponses.count) arguments" + ) + + // 5. Look for subcommand + guard let nextCommand = try consumeNextSubcommand(&split, hasTTY: hasTTY) else { + break // No more subcommands + } + + self.currentCommand = nextCommand + self.commandStack.append(nextCommand) + } + + // Validate no unexpected arguments remain + let remainingElements = split.unconsumedElements + if !remainingElements.isEmpty && !remainingElements.allSatisfy(\.isTerminator) { + let remaining = remainingElements.compactMap { element -> String? in + switch element.value { + case .value(let str): return str + case .option(let opt): return "--\(opt.name)" + case .terminator: return nil + } + } + + if !remaining.isEmpty { + self.observabilityScope? + .emit(warning: "Found unexpected arguments: [\(remaining.joined(separator: ", "))]") + throw TemplateError.unexpectedArguments(remaining) + } + } + + // Report any collected parsing errors if no other errors occurred + if !self.parsingErrors.isEmpty { + self.observabilityScope? + .emit( + warning: "Multiple parsing errors occurred: \(self.parsingErrors.map(\.localizedDescription).joined(separator: ", "))" + ) + throw ParsingError.multipleParsingErrors(self.parsingErrors) + } + + let fullPathKey = self.parsedCommands.map(\.commandName).joined(separator: "-") + self.observabilityScope? + .emit( + debug: "Successfully parsed template command path: '\(fullPathKey)' with \(self.parsedCommands.count) command components" + ) + + return TemplateCommandPath( + fullPathKey: fullPathKey, + commandChain: self.parsedCommands + ) + } + + private mutating func parseAvailableArguments(_ split: inout SplitArguments) + throws -> ParsingResult + { + guard let arguments = currentCommand.arguments else { + self.observabilityScope? + .emit(debug: "No arguments defined for command '\(self.currentCommand.commandName)'") + return ParsingResult(responses: [], errors: [], remainingArguments: []) + } + + self.observabilityScope? + .emit( + debug: "Parsing \(arguments.count) available arguments for command '\(self.currentCommand.commandName)'" + ) + + var responses: [ArgumentResponse] = [] + var parsedArgNames: Set = [] + var localErrors: [ParsingError] = [] + + // handle postTerminator arguments first (before any parsing) + do { + let postTerminatorResponses = try + parsePostTerminatorArguments(arguments, &split) + responses.append(contentsOf: postTerminatorResponses) + for response in responses { + try parsedArgNames.insert(self.getArgumentName(response.argument)) + } + if !postTerminatorResponses.isEmpty { + self.observabilityScope? + .emit(debug: "Parsed \(postTerminatorResponses.count) post-terminator arguments") + } + } catch { + if let parsingError = error as? ParsingError { + self.observabilityScope? + .emit(warning: "Post-terminator argument parsing failed: \(parsingError.localizedDescription)") + localErrors.append(parsingError) + } + } + + // Parsed named arguments and regular positionals + self.parseNamedAndPositionalArguments( + &split, + arguments, + &responses, + &parsedArgNames, + &localErrors + ) + + self.parsingErrors.append(contentsOf: localErrors) + + if !localErrors.isEmpty { + self.observabilityScope? + .emit(debug: "Collected \(localErrors.count) parsing errors during argument processing") + } + + return ParsingResult( + responses: responses, + errors: localErrors, + remainingArguments: split.remainingValues + ) + } + + private mutating func parseNamedAndPositionalArguments( + _ split: inout SplitArguments, + _ arguments: [ArgumentInfoV0], + _ responses: inout [ArgumentResponse], + _ parsedArgNames: inout Set, + _ localErrors: inout [ParsingError] + ) { + // Check for passthrough capture behavior + let capturesForPassthrough = arguments.contains { arg in + arg.kind == .positional && + arg.parsingStrategy == .allRemainingInput && + arg.isRepeating + } + + argumentLoop: while let element = split.peekNext(), !element.isTerminator { + switch element.value { + case .option(let parsed): + do { + if case .shortGroup(let characters) = parsed.type { + // Expand shortGroup into individual flags + let element = split.consumeNext()! + var shouldStopParsing = false + + for char in characters { + let expandedFlag = ParsedTemplateArgument( + type: .shortFlag(char), originalToken: + parsed.originalToken + ) + if let matchedArg = findMatchingArgument(expandedFlag, in: arguments) { + let argName = try getArgumentName(matchedArg) + split.markAsConsumed( + element.index, + for: + .optionValue(argName), + argumentName: argName + ) + let response = try + parseOptionWithValidation(matchedArg, expandedFlag, &split) + responses.append(response) + parsedArgNames.insert(argName) + } else { + // Check if this short flag might belong to a subcommand + if self.isPotentialSubcommandOption(String(char)) { + self.observabilityScope? + .emit( + debug: "Short option '-\(char)' might belong to subcommand, stopping current level parsing" + ) + shouldStopParsing = true + break argumentLoop // Stop processing this flag group + } + + self.observabilityScope?.emit(warning: "Unknown option '-\(char)' encountered") + localErrors.append(.unexpectedArgument("-\(char)")) + split.markAsConsumed( + element.index, + for: + .subcommand, + argumentName: "-\(char)" + ) + } + } + + if shouldStopParsing { + break argumentLoop // Exit main parsing loop + } + } else { + if let matchedArg = findMatchingArgument( + parsed, + in: arguments + ) { + let argName = try getArgumentName(matchedArg) + let element = split.consumeNext()! + // Consume the option element itself first + + split.markAsConsumed( + element.index, + for: + .optionValue(argName), + argumentName: argName + ) + let response = try parseOptionWithValidation(matchedArg, parsed, &split) + responses.append(response) + parsedArgNames.insert(argName) + } else { + // Unknown option - check if it could belong to a subcommand + if capturesForPassthrough { + break argumentLoop // Stop for passthrough + } + + // Check if this option might belong to a subcommand + if self.isPotentialSubcommandOption(parsed.name) { + self.observabilityScope? + .emit( + debug: "Option '--\(parsed.name)' might belong to subcommand, stopping current level parsing" + ) + break argumentLoop // Stop parsing at this level, leave for subcommand + } + + self.observabilityScope?.emit(warning: "Unknown option '--\(parsed.name)' encountered") + localErrors.append(.unexpectedArgument("--\(parsed.name)")) + let element = split.consumeNext()! + // Consume to avoid infinite loop + split.markAsConsumed(element.index, for: .subcommand, argumentName: "--\(parsed.name)") + } + } + } catch { + if let parsingError = error as? ParsingError { + localErrors.append(parsingError) + } + _ = split.consumeNext() // Continue parsing + } + + case .value(let value): + // Check for passthrough capture at first unrecognized value + if capturesForPassthrough { + let hasMatchingPositional = (try? + self.getNextPositionalArgument(arguments, parsedArgNames) + ) != nil + if !hasMatchingPositional && !self.isPotentialSubcommand(value) { + break argumentLoop// Stop parsing for passthrough + } + } + + // Check if this could be part of a subcommand path + if self.isPotentialSubcommand(value) { + break argumentLoop // Leave for subcommand processing + } + + // Try to match with positional arguments + do { + if let positionalArg = try + getNextPositionalArgument(arguments, parsedArgNames) + { + if positionalArg.parsingStrategy == .allRemainingInput { + let response = try + parseAllRemainingInputPositional(positionalArg, value, &split) + responses.append(response) + try parsedArgNames.insert( + self.getArgumentName(positionalArg) + ) + break argumentLoop + // allRemainingInput consumes everything, stop parsing + } else { + let response = try + parseRegularPositional(positionalArg, value, &split) + responses.append(response) + try parsedArgNames.insert( + self.getArgumentName(positionalArg) + ) + } + } else { + localErrors.append(.unexpectedArgument(value)) + self.observabilityScope? + .emit( + warning: "Unexpected positional argument '\(value)' - no matching positional parameter found" + ) + let element = split.consumeNext()! + // Consume to avoid infinite loop + split.markAsConsumed(element.index, for: .subcommand, argumentName: value) + break argumentLoop + // No more positional arguments to fill + } + } catch { + if let parsingError = error as? ParsingError { + localErrors.append(parsingError) + } + _ = split.consumeNext() // Continue parsing + } + + case .terminator: + break argumentLoop // Skip terminator, already handled in Phase 1 + } + } + } + + private mutating func validateAllResponses(_ responses: [ArgumentResponse]) + throws -> [ArgumentResponse] + { + self.observabilityScope?.emit(debug: "Validating \(responses.count) argument responses") + var validatedResponses: [ArgumentResponse] = [] + var validationErrors: [ParsingError] = [] + + for response in responses { + do { + try self.validateParsedArgument(response) + validatedResponses.append(response) + } catch { + // Collect validation errors but continue + if let parsingError = error as? ParsingError { + self.observabilityScope? + .emit(warning: "Validation failed for argument: \(parsingError.localizedDescription)") + validationErrors.append(parsingError) + self.parsingErrors.append(parsingError) + } + // Include response anyway for partial parsing + validatedResponses.append(response) + } + } + + if !validationErrors.isEmpty { + self.observabilityScope? + .emit( + debug: "Validation completed with \(validationErrors.count) errors, \(validatedResponses.count) responses processed" + ) + } else { + self.observabilityScope? + .emit(debug: "All \(validatedResponses.count) argument responses validated successfully") + } + + return validatedResponses + } + + private func validateParsedArgument(_ response: ArgumentResponse) throws { + let arg = response.argument + let argName = try getArgumentName(arg) + + // Validate value count + if !arg.isRepeating && response.values.count > 1 { + throw ParsingError.tooManyValues(argName, 1, response.values.count) + } + + // Validate against allowed values + if let allowedValues = arg.allValues, !allowedValues.isEmpty { + let invalidValues = response.values.filter { + !allowedValues.contains($0) + } + if !invalidValues.isEmpty { + throw ParsingError.invalidValue( + argName, + invalidValues, + allowedValues + ) + } + } + + // Validate completion constraints + if let completionKind = arg.completionKind, + case .list(let allowedValues) = completionKind + { + let invalidValues = response.values.filter { + !allowedValues.contains($0) + } + if !invalidValues.isEmpty { + throw ParsingError.invalidValue( + argName, + invalidValues, + allowedValues + ) + } + } + } + + private func parseOptionWithValidation( + _ arg: ArgumentInfoV0, + _ parsed: ParsedTemplateArgument, + _ split: inout SplitArguments + ) throws -> ArgumentResponse { + let argName = try getArgumentName(arg) + + switch arg.kind { + case .flag: + // Flags should not have values + if parsed.value != nil { + throw ParsingError.unexpectedArgument("Flag \(argName) should not have a value") + } + return ArgumentResponse( + argument: arg, + values: ["true"], + isExplicitlyUnset: false + ) + + case .option: + var values: [String] = [] + + switch arg.parsingStrategy { + case .default: + if let attachedValue = parsed.value { + values = [attachedValue] + } else { + guard let nextValue = split.consumeNextValue(for: argName) + else { + throw ParsingError.missingValueForOption(argName) + } + values = [nextValue] + } + + case .scanningForValue: + if let attachedValue = parsed.value { + values = [attachedValue] + } else if let scannedValue = split.scanForNextValue(for: argName) { + values = [scannedValue] + } else if let defaultValue = arg.defaultValue, + !requiresPrompting(for: arg) + { + values = [defaultValue] + } + + case .unconditional: + if let attachedValue = parsed.value { + values = [attachedValue] + } else { + guard let nextElement = split.consumeNext() else { + throw ParsingError.missingValueForOption(argName) + } + + switch nextElement.value { + case .value(let value): + values = [value] + case .option(let opt): + values = ["--\(opt.name)"] + case .terminator: + values = ["--"] + } + } + + case .upToNextOption: + if let attachedValue = parsed.value { + values = [attachedValue] + } + + while let nextValue = split.consumeNextValue(for: argName) { + values.append(nextValue) + } + + if values.isEmpty, let defaultValue = arg.defaultValue, + !requiresPrompting(for: arg) + { + values = [defaultValue] + } + + case .allRemainingInput: + if let attachedValue = parsed.value { + values = [attachedValue] + } + + while let element = split.consumeNext() { + switch element.value { + case .value(let value): + values.append(value) + case .option(let opt): + values.append("--\(opt.name)") + if let optValue = opt.value { + values.append(optValue) + } + case .terminator: + values.append("--") + } + } + + case .postTerminator, .allUnrecognized: + throw ParsingError.unexpectedArgument("Positional parsing strategy used for option \(argName)") + } + + // Handle repeating arguments by continuing to parse if needed + if arg.isRepeating && values.count == 1 { + // Could continue parsing more values based on strategy + // Implementation depends on specific requirements + } + + return ArgumentResponse( + argument: arg, + values: values, + isExplicitlyUnset: false + ) + + case .positional: + throw ParsingError.unexpectedArgument("Positional argument parsing should be handled separately") + } + } + + private func parsePostTerminatorArguments( + _ arguments: [ArgumentInfoV0], + _ split: inout SplitArguments + ) throws -> [ArgumentResponse] { + var responses: [ArgumentResponse] = [] + + let postTerminatorArgs = arguments.filter { + $0.kind == .positional && $0.parsingStrategy == .postTerminator + } + + guard !postTerminatorArgs.isEmpty else { return responses } + + // Use enhanced method that properly removes consumed elements + let postTerminatorValues = split.removeElementsAfterTerminator() + + if let firstPostTerminatorArg = postTerminatorArgs.first, + !postTerminatorValues.isEmpty + { + responses.append(ArgumentResponse( + argument: firstPostTerminatorArg, + values: postTerminatorValues, + isExplicitlyUnset: false + )) + } + + return responses + } + + private func isHelpArgument(_ arg: ArgumentInfoV0) -> Bool { + let argName = (try? self.getArgumentName(arg)) ?? "" + return argName.lowercased() == "help" || + arg.names?.contains { $0.name.lowercased() == "help" } == true + } + + private func requiresPrompting(for arg: ArgumentInfoV0) -> Bool { + // Determine if this argument should be prompted for rather than using default + // This could be based on argument metadata, current context, etc. + !arg.isOptional && arg.defaultValue == nil + } + + private func isSubcommand(_ value: String) -> Bool { + self.currentCommand.subcommands?.contains { $0.commandName == value } ?? + false + } + + private func findMatchingArgument( + _ parsed: ParsedTemplateArgument, + in arguments: [ArgumentInfoV0] + ) -> ArgumentInfoV0? { + arguments.first { arg in + guard let names = arg.names else { return false } + return names.contains { nameInfo in + if parsed.isShort { + nameInfo.kind == .short && nameInfo.name == + parsed.name + } else { + nameInfo.kind == .long && nameInfo.name == parsed.name + } + } + } + } + + private func getArgumentName(_ argument: ArgumentInfoV0) throws -> String { + guard let name = argument.valueName ?? argument.preferredName?.name else { + throw ParsingStringError("NO NAME BAD") + } + return name + } + + private func getNextPositionalArgument( + _ arguments: [ArgumentInfoV0], + _ parsedArgNames: Set + ) throws -> ArgumentInfoV0? { + try arguments.first { arg in + try arg.kind == .positional && + !parsedArgNames.contains(self.getArgumentName(arg)) + } + } + + private func parseRegularPositional( + _ arg: ArgumentInfoV0, + _ value: String, + _ split: inout + SplitArguments + ) throws -> + ArgumentResponse + { + // Consume the current value + let argName = try getArgumentName(arg) + let element = split.consumeNext()! + split.markAsConsumed( + element.index, + for: .positionalArgument(argName), + argumentName: argName + ) + + var values = [value] + + // If repeating, consume additional values until next option/subcommand + if arg.isRepeating { + while let nextValue = split.consumeNextValue(for: arg.preferredName?.name) { + values.append(nextValue) + } + } + + return ArgumentResponse( + argument: arg, + values: values, + isExplicitlyUnset: + false + ) + } + + private func parseAllRemainingInputPositional( + _ arg: ArgumentInfoV0, + _ value: String, + _ split: inout SplitArguments + ) throws -> ArgumentResponse { + var values = [value] + + // Consume the current value first + _ = split.consumeNext() + + // Then consume EVERYTHING remaining (including options as values) + while let element = split.consumeNext() { + switch element.value { + case .value(let str): + values.append(str) + case .option(let opt): + values.append("--\(opt.name)") + if let optValue = opt.value { + values.append(optValue) + } + case .terminator: + values.append("--") + } + } + + return ArgumentResponse(argument: arg, values: values, isExplicitlyUnset: false) + } + + 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 + } + + private func isPotentialSubcommand(_ value: String) -> Bool { + self.findSubcommandPath(value, from: self.currentCommand) != nil + } + + private func isPotentialSubcommandOption(_ optionName: String) -> Bool { + guard let subcommands = currentCommand.subcommands else { return false } + + // Recursively check if any subcommand has this option + return self.hasOptionInSubcommands(optionName, subcommands: subcommands) + } + + private func hasOptionInSubcommands(_ optionName: String, subcommands: [CommandInfoV0]) -> Bool { + for subcommand in subcommands { + // Check if this subcommand has the option + if let arguments = subcommand.arguments { + for arg in arguments { + if let names = arg.names { + for nameInfo in names { + if (nameInfo.kind == .long && nameInfo.name == optionName) || + (nameInfo.kind == .short && nameInfo.name == optionName) + { + return true + } + } + } + } + } + + // Recursively check nested subcommands + if let nestedSubcommands = subcommand.subcommands, + hasOptionInSubcommands(optionName, subcommands: nestedSubcommands) + { + return true + } + } + + return false + } + + private func findSubcommandPath(_ targetCommand: String, from command: CommandInfoV0) -> [String]? { + guard let subcommands = command.subcommands else { return nil } + + // Check direct subcommands + for subcommand in subcommands { + if subcommand.commandName == targetCommand { + return [subcommand.commandName] + } + + // Check nested subcommands + if let nestedPath = findSubcommandPath( + targetCommand, + from: + subcommand + ) { + return [subcommand.commandName] + nestedPath + } + } + + return nil + } + + private mutating func consumeNextSubcommand(_ split: inout SplitArguments, hasTTY: Bool) throws -> CommandInfoV0? { + // No direct subcommand found - check if we need intelligent branch + guard let subCommands = getSubCommand(from: currentCommand) else { + self.observabilityScope?.emit(debug: "No Subcommands found") + return nil // No subcommands available + } + + // Intelligent branch selection with validation + if let nextValue = split.peekNext()?.value, + case .value(let potentialCommand) = nextValue, + let _ = findSubcommandPath(potentialCommand, from: currentCommand) + { + + let compatibleBranches = subCommands.filter { branch in + // First check if this branch IS the command we're looking for + if branch.commandName == potentialCommand { + return true + } + // Otherwise check if the command exists within this branch + return self.findSubcommandPath(potentialCommand, from: branch) != nil + } + + self.observabilityScope?.emit(debug: "Found compatible branches: \(compatibleBranches.map(\.commandName))") + + if compatibleBranches.count == 1 { + // Unambiguous - auto-select with notification + self.observabilityScope?.emit(debug: "Auto-selecting '\(compatibleBranches.first!.commandName)' for command '\(potentialCommand)'") + + // Consume the subcommand token + let element = split.consumeNext()! + split.markAsConsumed(element.index, for: .subcommand, argumentName: potentialCommand) + + return compatibleBranches.first! + + } else if compatibleBranches.count > 1 { + guard hasTTY else { + throw TemplateError.ambiguousSubcommand( + command: potentialCommand, + branches: compatibleBranches.map(\.commandName) + ) + } + + // Ambiguous - prompt with context + print("Command '\(potentialCommand)' found in multiple branches:") + let prompter = TemplatePrompter(hasTTY: hasTTY) + let choice = try + prompter.promptForAmbiguousSubcommand(potentialCommand, compatibleBranches) + + // Consume the subcommand token + let element = split.consumeNext()! + split.markAsConsumed(element.index, for: .subcommand, argumentName: potentialCommand) + + return choice + } + } + + // Fallback: Regular branch selection + guard hasTTY else { + throw TemplateError.noTTYForSubcommandSelection + } + + let prompter = TemplatePrompter(hasTTY: hasTTY) + let chosenBranch = try prompter.promptForSubcommandSelection(subCommands) + return chosenBranch + } +} + diff --git a/Sources/Workspace/InitPackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift similarity index 81% rename from Sources/Workspace/InitPackage.swift rename to Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift index 9625ae7fc3e..65fa7f518d1 100644 --- a/Sources/Workspace/InitPackage.swift +++ b/Sources/Workspace/TemplateWorkspaceUtilities/InitPackage.swift @@ -53,16 +53,16 @@ public final class InitPackage { /// Represents a package type for the purposes of initialization. public enum PackageType: String, CustomStringConvertible { - case empty = "empty" - case library = "library" - case executable = "executable" - case tool = "tool" + case empty + case library + case executable + case tool case buildToolPlugin = "build-tool-plugin" case commandPlugin = "command-plugin" - case macro = "macro" + case macro public var description: String { - return rawValue + rawValue } } @@ -92,7 +92,7 @@ public final class InitPackage { /// The name of the type to create (within the package). var typeName: String { - return moduleName + moduleName } /// Create an instance that can create a package with given arguments. @@ -170,10 +170,10 @@ public final class InitPackage { if packageType == .macro { stream.send( - """ - import CompilerPluginSupport + """ + import CompilerPluginSupport - """ + """ ) } @@ -187,28 +187,28 @@ public final class InitPackage { var pkgParams = [String]() pkgParams.append(""" - name: "\(pkgname)" - """) + name: "\(pkgname)" + """) var platforms = options.platforms // Macros require macOS 10.15, iOS 13, etc. if packageType == .macro { func addIfMissing(_ newPlatform: SupportedPlatform) { - if platforms.contains(where: { platform in - platform.platform == newPlatform.platform - }) { - return - } + if platforms.contains(where: { platform in + platform.platform == newPlatform.platform + }) { + return + } - platforms.append(newPlatform) + platforms.append(newPlatform) } - addIfMissing(.init(platform: .macOS, version: .init("10.15"))) - addIfMissing(.init(platform: .iOS, version: .init("13"))) - addIfMissing(.init(platform: .tvOS, version: .init("13"))) - addIfMissing(.init(platform: .watchOS, version: .init("6"))) - addIfMissing(.init(platform: .macCatalyst, version: .init("13"))) + addIfMissing(.init(platform: .macOS, version: .init("10.15"))) + addIfMissing(.init(platform: .iOS, version: .init("13"))) + addIfMissing(.init(platform: .tvOS, version: .init("13"))) + addIfMissing(.init(platform: .watchOS, version: .init("6"))) + addIfMissing(.init(platform: .macCatalyst, version: .init("13"))) } var platformsParams = [String]() @@ -234,8 +234,8 @@ public final class InitPackage { // Package platforms if !platforms.isEmpty { pkgParams.append(""" - platforms: [\(platformsParams.joined(separator: ", "))] - """) + platforms: [\(platformsParams.joined(separator: ", "))] + """) } // Package products @@ -273,15 +273,18 @@ public final class InitPackage { ), ] """) - } // Package dependencies var dependencies = [String]() if packageType == .tool { - dependencies.append(#".package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0")"#) + dependencies + .append(#".package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0")"#) } else if packageType == .macro { - dependencies.append(#".package(url: "https://github.com/swiftlang/swift-syntax.git", from: "\#(self.installedSwiftPMConfiguration.swiftSyntaxVersionForMacroTemplate.description)")"#) + dependencies + .append( + #".package(url: "https://github.com/swiftlang/swift-syntax.git", from: "\#(self.installedSwiftPMConfiguration.swiftSyntaxVersionForMacroTemplate.description)")"# + ) } if !dependencies.isEmpty { let dependencies = dependencies.map { dependency in @@ -305,16 +308,15 @@ public final class InitPackage { """ if packageType == .executable { - let testTarget: String - if !options.supportedTestingLibraries.isEmpty { - testTarget = """ + let testTarget = if !options.supportedTestingLibraries.isEmpty { + """ .testTarget( name: "\(pkgname)Tests", dependencies: ["\(pkgname)"] ), """ } else { - testTarget = "" + "" } param += """ .executableTarget( @@ -324,16 +326,15 @@ public final class InitPackage { ] """ } else if packageType == .tool { - let testTarget: String - if !options.supportedTestingLibraries.isEmpty { - testTarget = """ + let testTarget = if !options.supportedTestingLibraries.isEmpty { + """ .testTarget( name: "\(pkgname)Tests", dependencies: ["\(pkgname)"] ), """ } else { - testTarget = "" + "" } param += """ .executableTarget( @@ -365,9 +366,8 @@ public final class InitPackage { ] """ } else if packageType == .macro { - let testTarget: String - if options.supportedTestingLibraries.contains(.swiftTesting) { - testTarget = """ + let testTarget = if options.supportedTestingLibraries.contains(.swiftTesting) { + """ // A test target used to develop the macro implementation. .testTarget( @@ -380,7 +380,7 @@ public final class InitPackage { ), """ } else if options.supportedTestingLibraries.contains(.xctest) { - testTarget = """ + """ // A test target used to develop the macro implementation. .testTarget( @@ -392,7 +392,7 @@ public final class InitPackage { ), """ } else { - testTarget = "" + "" } param += """ // Macro implementation that performs the source transformation of a macro. @@ -413,16 +413,15 @@ public final class InitPackage { ] """ } else { - let testTarget: String - if !options.supportedTestingLibraries.isEmpty { - testTarget = """ + let testTarget = if !options.supportedTestingLibraries.isEmpty { + """ .testTarget( name: "\(pkgname)Tests", dependencies: ["\(pkgname)"] ), """ } else { - testTarget = "" + "" } param += """ @@ -502,12 +501,12 @@ public final class InitPackage { let sourceFile = try AbsolutePath(validating: sourceFileName, relativeTo: moduleDir) var content = """ - import PackagePlugin - import struct Foundation.URL + import PackagePlugin + import struct Foundation.URL - @main + @main - """ + """ if packageType == .buildToolPlugin { content += """ struct \(typeName): BuildToolPlugin { @@ -549,7 +548,7 @@ public final class InitPackage { func createBuildCommand(for inputPath: URL, in outputDirectoryPath: URL, with generatorToolPath: URL) -> Command? { // Skip any file that doesn't have the extension we're looking for (replace this with the actual one). guard inputPath.pathExtension == "my-input-suffix" else { return .none } - + // Return a command that will run during the build to generate the output file. let inputName = inputPath.lastPathComponent let outputName = inputPath.deletingPathExtension().lastPathComponent + ".swift" @@ -565,8 +564,7 @@ public final class InitPackage { } """ - } - else { + } else { content += """ struct \(typeName): CommandPlugin { // Entry point for command plugins applied to Swift Packages. @@ -616,30 +614,31 @@ public final class InitPackage { // If we're creating an executable we can't have both a @main declaration and a main.swift file. // Handle the edge case of a user creating a project called "main" by give the generated file a different name. - let sourceFileName = ((packageType == .executable || packageType == .tool) && typeName == "main") ? "MainEntrypoint.swift" : "\(typeName).swift" + let sourceFileName = ((packageType == .executable || packageType == .tool) && typeName == "main") ? + "MainEntrypoint.swift" : "\(typeName).swift" let sourceFile = try AbsolutePath(validating: sourceFileName, relativeTo: moduleDir) let content: String switch packageType { case .library: content = """ - // The Swift Programming Language - // https://docs.swift.org/swift-book + // The Swift Programming Language + // https://docs.swift.org/swift-book - """ + """ case .executable: content = """ - // The Swift Programming Language - // https://docs.swift.org/swift-book + // The Swift Programming Language + // https://docs.swift.org/swift-book - @main - struct \(typeName) { - static func main() { - print("Hello, world!") - } + @main + struct \(typeName) { + static func main() { + print("Hello, world!") } + } - """ + """ case .tool: content = """ // The Swift Programming Language @@ -670,10 +669,10 @@ public final class InitPackage { /// /// produces a tuple `(x + y, "x + y")`. @freestanding(expression) - public macro stringify(_ value: T) -> (T, String) = #externalMacro(module: "\(moduleName)Macros", type: "StringifyMacro") + public macro stringify(_ value: T) -> (T, String) = #externalMacro(module: "\(moduleName + )Macros", type: "StringifyMacro") """ - case .empty, .buildToolPlugin, .commandPlugin: throw InternalError("invalid packageType \(packageType)") } @@ -683,8 +682,8 @@ public final class InitPackage { } if packageType == .macro { - try writeMacroPluginSources(sources.appending("\(pkgname)Macros")) - try writeMacroClientSources(sources.appending("\(pkgname)Client")) + try writeMacroPluginSources(sources.appending("\(pkgname)Macros")) + try writeMacroClientSources(sources.appending("\(pkgname)Client")) } } @@ -729,27 +728,27 @@ public final class InitPackage { if options.supportedTestingLibraries.contains(.swiftTesting) { content += """ - @Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - } + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } - """ + """ } if options.supportedTestingLibraries.contains(.xctest) { content += """ - final class \(moduleName)Tests: XCTestCase { - func testExample() throws { - // XCTest Documentation - // https://developer.apple.com/documentation/xctest + final class \(moduleName)Tests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest - // Defining Test Cases and Test Methods - // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods - } + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods } + } - """ + """ } try writePackageFile(path) { stream in @@ -761,12 +760,12 @@ public final class InitPackage { var content = "" content += ##""" - import SwiftSyntax - import SwiftSyntaxBuilder - import SwiftSyntaxMacros - import SwiftSyntaxMacrosTestSupport + import SwiftSyntax + import SwiftSyntaxBuilder + import SwiftSyntaxMacros + import SwiftSyntaxMacrosTestSupport - """## + """## if options.supportedTestingLibraries.contains(.swiftTesting) { content += "import Testing\n" @@ -777,17 +776,17 @@ public final class InitPackage { content += ##""" - // Macro implementations build for the host, so the corresponding module is not available when cross-compiling. Cross-compiled tests may still make use of the macro itself in end-to-end tests. - #if canImport(\##(moduleName)Macros) - import \##(moduleName)Macros + // Macro implementations build for the host, so the corresponding module is not available when cross-compiling. Cross-compiled tests may still make use of the macro itself in end-to-end tests. + #if canImport(\##(moduleName)Macros) + import \##(moduleName)Macros - let testMacros: [String: Macro.Type] = [ - "stringify": StringifyMacro.self, - ] - #endif + let testMacros: [String: Macro.Type] = [ + "stringify": StringifyMacro.self, + ] + #endif - """## + """## // XCTest is only added if it was explicitly asked for, so add tests // for it *and* Testing if it is enabled. @@ -798,41 +797,41 @@ public final class InitPackage { if options.supportedTestingLibraries.contains(.xctest) { content += ##""" - final class \##(moduleName)Tests: XCTestCase { - func testMacro() throws { - #if canImport(\##(moduleName)Macros) - assertMacroExpansion( - """ - #stringify(a + b) - """, - expandedSource: """ - (a + b, "a + b") - """, - macros: testMacros - ) - #else - throw XCTSkip("macros are only supported when running tests for the host platform") - #endif - } + final class \##(moduleName)Tests: XCTestCase { + func testMacro() throws { + #if canImport(\##(moduleName)Macros) + assertMacroExpansion( + """ + #stringify(a + b) + """, + expandedSource: """ + (a + b, "a + b") + """, + macros: testMacros + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif + } - func testMacroWithStringLiteral() throws { - #if canImport(\##(moduleName)Macros) - assertMacroExpansion( - #""" - #stringify("Hello, \(name)") - """#, - expandedSource: #""" - ("Hello, \(name)", #""Hello, \(name)""#) - """#, - macros: testMacros - ) - #else - throw XCTSkip("macros are only supported when running tests for the host platform") - #endif - } + func testMacroWithStringLiteral() throws { + #if canImport(\##(moduleName)Macros) + assertMacroExpansion( + #""" + #stringify("Hello, \(name)") + """#, + expandedSource: #""" + ("Hello, \(name)", #""Hello, \(name)""#) + """#, + macros: testMacros + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif } + } - """## + """## } try writePackageFile(path) { stream in @@ -925,18 +924,24 @@ 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 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" + "a manifest file already exists in this directory" + case .unsupportedTestingLibraryForPackageType(let library, let packageType): + "\(library) cannot be used when initializing a \(packageType) package" + case .nonEmptyDirectory(let content): + "directory is not empty: \(content.joined(separator: ", "))" } } } @@ -945,19 +950,19 @@ extension PackageModel.Platform { var manifestName: String { switch self { case .macOS: - return "macOS" + "macOS" case .macCatalyst: - return "macCatalyst" + "macCatalyst" case .iOS: - return "iOS" + "iOS" case .tvOS: - return "tvOS" + "tvOS" case .watchOS: - return "watchOS" + "watchOS" case .visionOS: - return "visionOS" + "visionOS" case .driverKit: - return "DriverKit" + "DriverKit" default: fatalError("unexpected manifest name call for platform \(self)") } @@ -980,22 +985,21 @@ extension SupportedPlatform { switch platform { case .macOS where version.major == 10: - return (10...15).contains(version.minor) + return (10 ... 15).contains(version.minor) case .macOS: - return (11...11).contains(version.major) + return (11 ... 11).contains(version.major) case .macCatalyst: - return (13...14).contains(version.major) + return (13 ... 14).contains(version.major) case .iOS: - return (8...14).contains(version.major) + return (8 ... 14).contains(version.major) case .tvOS: - return (9...14).contains(version.major) + return (9 ... 14).contains(version.major) case .watchOS: - return (2...7).contains(version.major) + return (2 ... 7).contains(version.major) case .visionOS: - return (1...1).contains(version.major) + return (1 ... 1).contains(version.major) case .driverKit: - return (19...20).contains(version.major) - + return (19 ... 20).contains(version.major) default: return false } diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift b/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift new file mode 100644 index 00000000000..3748a8b9f14 --- /dev/null +++ b/Sources/Workspace/TemplateWorkspaceUtilities/InitTemplatePackage.swift @@ -0,0 +1,227 @@ +// +// 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 + ) + } +} + +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/Models/ErrorModels.swift b/Sources/Workspace/TemplateWorkspaceUtilities/Models/ErrorModels.swift new file mode 100644 index 00000000000..81f099a133f --- /dev/null +++ b/Sources/Workspace/TemplateWorkspaceUtilities/Models/ErrorModels.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024-2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +enum TemplateError: Error, Equatable { + case unexpectedArguments([String]) + case ambiguousSubcommand(command: String, branches: [String]) + case noTTYForSubcommandSelection + case missingRequiredArgument(String) + case invalidArgumentValue(value: String, argument: String) + case invalidSubcommandSelection(validOptions: String?) + case unsupportedParsingStrategy +} diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/Models/ParsingModels.swift b/Sources/Workspace/TemplateWorkspaceUtilities/Models/ParsingModels.swift new file mode 100644 index 00000000000..00928828aca --- /dev/null +++ b/Sources/Workspace/TemplateWorkspaceUtilities/Models/ParsingModels.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024-2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +struct ParsingStringError: Error { + let message: String + + init(_ message: String) { + self.message = message + } +} + +struct ParsingResult { + let responses: [ArgumentResponse] + let errors: [ParsingError] + let remainingArguments: [String] +} + +enum ParsingError: Error { + case missingValueForOption(String) + case invalidValue(String, [String], [String]) // arg, invalid, allowed + case tooManyValues(String, Int, Int) // arg, expected, received + case unexpectedArgument(String) + case multipleParsingErrors([ParsingError]) +} + +struct ConsumptionRecord { + let elementIndex: Int + let purpose: ConsumptionPurpose + let argumentName: String? +} + +enum ConsumptionPurpose { + case optionValue(String) + case positionalArgument(String) + case postTerminator + case subcommand +} diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/Models/ProcessingModels.swift b/Sources/Workspace/TemplateWorkspaceUtilities/Models/ProcessingModels.swift new file mode 100644 index 00000000000..f791887c447 --- /dev/null +++ b/Sources/Workspace/TemplateWorkspaceUtilities/Models/ProcessingModels.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024-2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public struct TemplateCommandPath { + public let fullPathKey: String + public let commandChain: [CommandComponent] + + public init(fullPathKey: String, commandChain: [CommandComponent]) { + self.fullPathKey = fullPathKey + self.commandChain = commandChain + } +} + +public struct CommandComponent { + public let commandName: String + public let arguments: [ArgumentResponse] + + public init(commandName: String, arguments: [ArgumentResponse]) { + self.commandName = commandName + self.arguments = arguments + } +} diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift b/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift new file mode 100644 index 00000000000..ffe5cd419e8 --- /dev/null +++ b/Sources/Workspace/TemplateWorkspaceUtilities/TemplateDirectoryManager.swift @@ -0,0 +1,66 @@ +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, Equatable { + case foundManifestFile(path: Basics.AbsolutePath) + case cleanupFailed(path: Basics.AbsolutePath?) + + public var description: String { + switch self { + case .foundManifestFile(let path): + return "Package.swift was found in \(path)." + case .cleanupFailed(let path): + let dir = path?.pathString ?? "" + return "Failed to clean up directory at \(dir)" + } + } +} diff --git a/Sources/Workspace/TemplateWorkspaceUtilities/UserInteraction/InteractivePrompter.swift b/Sources/Workspace/TemplateWorkspaceUtilities/UserInteraction/InteractivePrompter.swift new file mode 100644 index 00000000000..303f338f0da --- /dev/null +++ b/Sources/Workspace/TemplateWorkspaceUtilities/UserInteraction/InteractivePrompter.swift @@ -0,0 +1,561 @@ +import ArgumentParserToolInfo +import Foundation + +public class TemplatePrompter { + private let hasTTY: Bool + + public init(hasTTY: Bool) { + self.hasTTY = hasTTY + } + + public func promptForMissingArguments( + _ arguments: [ArgumentInfoV0], + parsed: + Set + ) throws -> [ArgumentResponse] { + var responses: [ArgumentResponse] = [] + + for arg in arguments.filter({ $0.valueName != "help" && $0.shouldDisplay + }) { + let argName = try getArgumentName(arg) + + // Skip if already parsed + if parsed.contains(argName) { continue } + + // For required arguments in non-TTY environment + guard self.hasTTY || arg.defaultValue != nil || arg.isOptional else { + throw TemplateError.missingRequiredArgument(argName) + } + + var value: [String] = if let defaultValue = arg.defaultValue, !hasTTY { + // use default value if in a non-TTY environment + [defaultValue] + } else if arg.isOptional, !self.hasTTY { + ["nil"] + } else { + // Prompt for required argument + try self.promptUserForArgument(arg) + } + + // if argument is optional, and user wanted to explicitly unset, they would have written nil, which resolves + // to index 0 + if arg.isOptional && value[0] == "nil" { + responses.append(ArgumentResponse( + argument: arg, + values: value, + isExplicitlyUnset: true + )) + } else { + responses.append(ArgumentResponse( + argument: arg, + values: value, + isExplicitlyUnset: false + )) + } + } + + return responses + } + + public func promptUserForArgument(_ argument: ArgumentInfoV0) throws -> [String] { + let argName = try getArgumentName(argument) + let promptMessage = "\(argument.abstract ?? argName)" + + switch argument.kind { + case .flag: + return try ["\(String(describing: self.promptForFlag(argument, promptMessage: promptMessage)))"] + case .option: + return try self.promptForOption(argument, promptMessage: promptMessage) + case .positional: + // For single positional prompting, assume position 1 of 1 + return try self.promptForPositional(argument, position: 1, totalPositionals: 1) + } + } + + /// Prompts for multiple positional arguments in sequence, respecting position order + public func promptUserForPositionalArguments(_ arguments: [ArgumentInfoV0]) throws -> [[String]] { + let positionalArgs = arguments.filter { $0.kind == .positional } + var results: [[String]] = [] + + if positionalArgs.isEmpty { + return results + } + + print("Collecting positional arguments (\(positionalArgs.count) total):") + print() + + for (index, argument) in positionalArgs.enumerated() { + let position = index + 1 + let values = try promptForPositional(argument, position: position, totalPositionals: positionalArgs.count) + results.append(values) + + // If this was an optional argument and user skipped it, + // check if there are later required arguments + if argument.isOptional && values.isEmpty { + let laterArgs = Array(positionalArgs[(index + 1)...]) + let hasLaterRequired = laterArgs.contains { !$0.isOptional } + + if hasLaterRequired { + print("Skipping optional argument. Cannot prompt for later required arguments.") + break + } + } + + print() // Spacing between arguments + } + + return results + } + + private func promptForPostionalArgument( + _: ArgumentInfoV0, + promptMessage: String + ) throws -> String { + "" + } + + /// Interactive prompting for positional arguments with strategy-aware behavior + private func promptForPositional( + _ argument: ArgumentInfoV0, + position: Int, + totalPositionals: Int + ) throws -> [String] { + let argName = argument.valueName ?? "argument" + let isRequired = !argument.isOptional + let hasDefault = argument.defaultValue != nil + + // Build context-aware prompt message + var promptHeader = "Argument \(position)/\(totalPositionals): \(argument.abstract ?? argName)" + + if !isRequired { + promptHeader += " (optional)" + } + + // Handle different parsing strategies with appropriate prompting + switch argument.parsingStrategy { + case .allRemainingInput: + return try self.promptForPassthroughPositional(argument, header: promptHeader) + + case .postTerminator: + return try self.promptForPostTerminatorPositional(argument, header: promptHeader) + + case .allUnrecognized: + throw TemplateError.unsupportedParsingStrategy + + case .default, .scanningForValue, .unconditional, .upToNextOption: + // Standard positional argument prompting + return try self.promptForStandardPositional(argument, header: promptHeader) + } + } + + /// Prompts for standard positional arguments (most common case) + private func promptForStandardPositional(_ argument: ArgumentInfoV0, header: String) throws -> [String] { + let isRequired = !argument.isOptional + let hasDefault = argument.defaultValue != nil + + if argument.isRepeating { + // Repeating positional argument + print(header) + if let defaultValue = argument.defaultValue { + print("Default: \(defaultValue)") + } + + if let allowedValues = argument.allValues { + print("Allowed values: \(allowedValues.joined(separator: ", "))") + } + + print("Enter multiple values (one per line, empty line to finish):") + + var values: [String] = [] + while true { + print("[\(values.count)] > ", terminator: "") + guard let input = readLine() else { + break + } + + if input.isEmpty { + break // Empty line finishes collection + } + + // Validate input + if let allowedValues = argument.allValues, !allowedValues.contains(input) { + print("Invalid value '\(input)'. Allowed values: \(allowedValues.joined(separator: ", "))") + continue + } + + values.append(input) + print("Added '\(input)' (total: \(values.count))") + } + + // Handle empty collection + if values.isEmpty { + if let defaultValue = argument.defaultValue { + return [defaultValue] + } else if !isRequired { + return [] + } else { + throw TemplateError.missingRequiredArgument(header) + } + } + + return values + + } else { + // Single positional argument + var promptSuffix = "" + if hasDefault, let defaultValue = argument.defaultValue { + promptSuffix = " (default: \(defaultValue))" + } else if !isRequired { + promptSuffix = " (press Enter to skip)" + } + + if let allowedValues = argument.allValues { + print("\(header)") + print("Allowed values: \(allowedValues.joined(separator: ", "))") + print("Enter value\(promptSuffix): ", terminator: "") + } else { + print("\(header)\(promptSuffix): ", terminator: "") + } + + guard let input = readLine() else { + if hasDefault, let defaultValue = argument.defaultValue { + return [defaultValue] + } else if !isRequired { + return [] + } else { + throw TemplateError.missingRequiredArgument(header) + } + } + + if input.isEmpty { + if hasDefault, let defaultValue = argument.defaultValue { + return [defaultValue] + } else if !isRequired { + return [] + } else { + throw TemplateError.missingRequiredArgument(header) + } + } + + // Validate input + if let allowedValues = argument.allValues, !allowedValues.contains(input) { + print("Invalid value '\(input)'. Allowed values: \(allowedValues.joined(separator: ", "))") + throw TemplateError.invalidArgumentValue( + value: input, + argument: + header + ) + } + + return [input] + } + } + + /// Prompts for .allRemainingInput (passthrough) arguments + private func promptForPassthroughPositional( + _ argument: ArgumentInfoV0, + header: String + ) throws -> [String] { + print(header) + print("This argument captures ALL remaining input as-is (including flags and options)") + print("Everything you enter will be passed through without parsing.") + print("Enter the complete argument string: ", terminator: "") + + guard let input = readLine() else { + if argument.isOptional { + return [] + } else { + throw TemplateError.missingRequiredArgument(header) + } + } + + if input.isEmpty { + if argument.isOptional { + return [] + } else { + throw TemplateError.missingRequiredArgument(header) + } + } + + // Split input into individual arguments (respecting quotes) + return self.splitCommandLineString(input) + } + + /// Prompts for .postTerminator arguments + private func promptForPostTerminatorPositional( + _: ArgumentInfoV0, + header: String + ) throws -> [String] { + print(header) + print("This argument only captures values that appear after '--' separator") + print("Enter arguments as they would appear after '--': ", terminator: "") + + guard let input = readLine() else { + return [] + } + + if input.isEmpty { + return [] + } + + return self.splitCommandLineString(input) + } + + /// Utility to split a command line string into individual arguments + /// Handles basic quoting (simplified version) + private func splitCommandLineString(_ input: String) -> [String] { + var arguments: [String] = [] + var currentArg = "" + var inQuotes = false + var quoteChar: Character? = nil + + for char in input { + switch char { + case "\"", "'": + if !inQuotes { + inQuotes = true + quoteChar = char + } else if char == quoteChar { + inQuotes = false + quoteChar = nil + } else { + currentArg.append(char) + } + case " ", "\t": + if inQuotes { + currentArg.append(char) + } else if !currentArg.isEmpty { + arguments.append(currentArg) + currentArg = "" + } + default: + currentArg.append(char) + } + } + + if !currentArg.isEmpty { + arguments.append(currentArg) + } + + return arguments + } + + private func promptForOption( + _ argument: ArgumentInfoV0, + promptMessage: + String + ) throws -> [String] { + var prefix = promptMessage + var suffix = "" + + if argument.defaultValue == nil && argument.isOptional { + suffix = " or enter \"nil\" to unset." + } else if let defaultValue = argument.defaultValue { + suffix = " (default: \(defaultValue))" + } + + if let allValues = argument.allValues { + suffix += " (allowed values: \(allValues.joined(separator: ", ")))" + } + + if argument.isRepeating { + // For repeating arguments, show clear instructions + print("\(prefix)\(suffix)") + print("Enter multiple values (one per line, empty line to finish):") + + var values: [String] = [] + while true { + print("> ", terminator: "") + guard let input = readLine() else { + break + } + + if input.isEmpty { + break // Empty line finishes collection + } + + if input.lowercased() == "nil" && argument.isOptional { + return ["nil"] + } + + // Validate input and retry on error + if let allowed = argument.allValues, !allowed.contains(input) { + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + print("Please try again:") + continue + } + + values.append(input) + print("Added '\(input)' (total: \(values.count))") + } + + if values.isEmpty { + if let defaultValue = argument.defaultValue { + return [defaultValue] + } + return [] + } + return values + + } else { + // For single arguments + let completeMessage = prefix + suffix + print(completeMessage, terminator: " ") + + guard let input = readLine() else { + if let defaultValue = argument.defaultValue { + return [defaultValue] + } + return [] + } + + if input.isEmpty { + if let defaultValue = argument.defaultValue { + return [defaultValue] + } + return [] + } + + if input.lowercased() == "nil" && argument.isOptional { + return ["nil"] + } + + if let allowed = argument.allValues, !allowed.contains(input) { + let argName = argument.preferredName?.name ?? argument.names?.first?.name ?? argument.valueName ?? "unknown" + print("Invalid value '\(input)'. Allowed values: \(allowed.joined(separator: ", "))") + throw TemplateError.invalidArgumentValue(value: input, argument: argName) + } + + return [input] + } + } + + private func promptForFlag(_ argument: ArgumentInfoV0, promptMessage: String) throws -> Bool? { + let defaultBehaviour: Bool? = if let defaultValue = argument.defaultValue { + defaultValue.lowercased() == "true" + } else { + nil + } + + var suffix: String = if let defaultBehaviour { + defaultBehaviour ? " [Y/n]" : " [y/N]" + } else { + " [y/n]" + } + + let isOptional = argument.isOptional + if isOptional && defaultBehaviour == nil { + suffix = suffix + " or enter \"nil\" to unset." + } + + print(promptMessage + suffix, terminator: " ") + guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else { + // Handle EOF/no input + if let defaultBehaviour { + return defaultBehaviour + } else if isOptional { + return nil + } else { + throw TemplateError.missingRequiredArgument(argument.preferredName?.name ?? "unknown") + } + } + + switch input { + case "y", "yes", "true", "1": + return true + case "n", "no", "false", "0": + return false + case "nil": + if isOptional { + return nil + } else { + let argName = argument.preferredName?.name ?? "unknown" + throw TemplateError.invalidArgumentValue(value: input, argument: argName) + } + case "": + // Empty input - use default or handle as missing + if let defaultBehaviour { + return defaultBehaviour + } else if isOptional { + return nil + } else { + throw TemplateError.missingRequiredArgument(argument.preferredName?.name ?? "unknown") + } + default: + // Invalid input - provide clear error + let argName = argument.preferredName?.name ?? "unknown" + print("Invalid value '\(input)'. Please enter: y/yes/true, n/no/false" + (isOptional ? ", or nil" : "")) + throw TemplateError.invalidArgumentValue(value: input, argument: argName) + } + } + + public func promptForSubcommandSelection(_ subcommands: [CommandInfoV0]) throws -> CommandInfoV0 { + guard self.hasTTY else { + throw TemplateError.noTTYForSubcommandSelection + } + + print("Available subcommands:") + for (index, subcommand) in subcommands.enumerated() { + print("\(index + 1). \(subcommand.commandName)") + } + + print("Select subcommand (number or name): ", terminator: "") + + guard let input = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines), + !input.isEmpty + else { + throw TemplateError.invalidSubcommandSelection(validOptions: nil) + } + + // Try to parse as index first + if let choice = Int(input), + choice > 0 && choice <= subcommands.count + { + return subcommands[choice - 1] + } + + // Try to find by name (exact match) + if let matchedSubcommand = subcommands.first(where: { $0.commandName == + input + }) { + return matchedSubcommand + } + + // Try to find by name (case-insensitive match) + if let matchedSubcommand = subcommands.first(where: { $0.commandName.lowercased() == input.lowercased() }) { + return matchedSubcommand + } + + let validOptions = subcommands.map(\.commandName).joined(separator: ", ") + throw TemplateError.invalidSubcommandSelection(validOptions: validOptions) + } + + public func promptForAmbiguousSubcommand(_ command: String, _ branches: [CommandInfoV0]) throws -> CommandInfoV0 { + guard self.hasTTY else { + throw TemplateError.ambiguousSubcommand(command: command, branches: branches.map(\.commandName)) + } + + print("Command '\(command)' found in multiple branches:") + for (index, branch) in branches.enumerated() { + print("\(index + 1). \(branch.commandName)") + } + + print("Select branch (1-\(branches.count)): ", terminator: "") + + guard let input = readLine(), + let choice = Int(input), + choice > 0 && choice <= branches.count + else { + throw TemplateError.invalidSubcommandSelection(validOptions: nil) + } + + return branches[choice - 1] + } + + private func getArgumentName(_ argument: ArgumentInfoV0) throws -> String { + guard let name = argument.valueName ?? argument.preferredName?.name else { + throw ParsingStringError("Argument has no name") + } + return name + } +} diff --git a/Sources/Workspace/Workspace+Dependencies.swift b/Sources/Workspace/Workspace+Dependencies.swift index 743e6ac926e..94fa5bab7cb 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 9873fdf111d..7c1b03ce2ca 100644 --- a/Sources/_InternalTestSupport/SwiftTesting+Tags.swift +++ b/Sources/_InternalTestSupport/SwiftTesting+Tags.swift @@ -140,6 +140,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 ShowTraits: Tag @Tag public static var ToolsVersion: Tag @Tag public static var Unedit: Tag @@ -184,6 +185,7 @@ 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 } extension Tag.Feature.Product { 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 317a570b41d..1052ad3b3d3 100644 --- a/Tests/CommandsTests/PackageCommandTests.swift +++ b/Tests/CommandsTests/PackageCommandTests.swift @@ -1451,6 +1451,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"], @@ -1506,6 +1507,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, @@ -2081,6 +2128,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/SwiftCommandStateTests.swift b/Tests/CommandsTests/SwiftCommandStateTests.swift index 17b0a8f2e91..64f8abf667d 100644 --- a/Tests/CommandsTests/SwiftCommandStateTests.swift +++ b/Tests/CommandsTests/SwiftCommandStateTests.swift @@ -401,7 +401,8 @@ final class SwiftCommandStateTests: XCTestCase { let swiftCommandState = try SwiftCommandState.makeMockState( options: options, fileSystem: fs, - environment: ["PATH": "/usr/bin"] + environment: ["PATH": "/usr/bin"], + hostTriple: .arm64Linux ) XCTAssertEqual(swiftCommandState.originalWorkingDirectory, fs.currentWorkingDirectory) @@ -458,7 +459,8 @@ final class SwiftCommandStateTests: XCTestCase { let swiftCommandState = try SwiftCommandState.makeMockState( options: options, fileSystem: fs, - environment: ["PATH": "/usr/bin"] + environment: ["PATH": "/usr/bin"], + hostTriple: .arm64Linux ) let hostToolchain = try swiftCommandState.getHostToolchain() @@ -516,7 +518,8 @@ final class SwiftCommandStateTests: XCTestCase { let swiftCommandState = try SwiftCommandState.makeMockState( options: options, fileSystem: fs, - environment: ["PATH": "/usr/bin"] + environment: ["PATH": "/usr/bin"], + hostTriple: .arm64Linux ) let hostToolchain = try swiftCommandState.getHostToolchain() @@ -557,7 +560,8 @@ extension SwiftCommandState { options: GlobalOptions, createPackagePath: Bool = false, fileSystem: any FileSystem = localFileSystem, - environment: Environment = .current + environment: Environment = .current, + hostTriple: Triple = hostTriple ) throws -> SwiftCommandState { return try SwiftCommandState( outputStream: outputStream, @@ -578,7 +582,7 @@ extension SwiftCommandState { ) }, createPackagePath: createPackagePath, - hostTriple: .arm64Linux, + hostTriple: hostTriple, targetInfo: UserToolchain.mockTargetInfo, fileSystem: fileSystem, environment: environment diff --git a/Tests/CommandsTests/TemplateTests.swift b/Tests/CommandsTests/TemplateTests.swift new file mode 100644 index 00000000000..5a9d231d123 --- /dev/null +++ b/Tests/CommandsTests/TemplateTests.swift @@ -0,0 +1,4183 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +@Suite( + .serialized, + .tags( + .TestSize.large, + .Feature.Command.Package.General, + ), +) +class TemplateTests { + + /// Original working directory before the test ran (if known). + var originalWorkingDirectory: AbsolutePath? = .none + + init() async throws{ + originalWorkingDirectory = localFileSystem.currentWorkingDirectory + } + deinit { + if let originalWorkingDirectory { + try? localFileSystem.changeCurrentWorkingDirectory(to: originalWorkingDirectory) + } + } + + // 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 fileSystem = InMemoryFileSystem() + let observabilityScope = ObservabilitySystem.makeForTesting() + + let resolver = DefaultTemplateSourceResolver( + cwd: fileSystem.currentWorkingDirectory!, + fileSystem: fileSystem, + observabilityScope: observabilityScope.topScope + ) + + 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(observabilityScope.topScope.errorsReportedInAnyScope == false) + } + + @Test + func validateGitURLWithInvalidInput() throws { + let fileSystem = InMemoryFileSystem() + let observabilityScope = ObservabilitySystem.makeForTesting() + + let resolver = DefaultTemplateSourceResolver( + cwd: fileSystem.currentWorkingDirectory!, + fileSystem: fileSystem, + observabilityScope: observabilityScope.topScope + ) + + #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 fileSystem = InMemoryFileSystem() + let observabilityScope = ObservabilitySystem.makeForTesting() + + let resolver = DefaultTemplateSourceResolver( + cwd: fileSystem.currentWorkingDirectory!, + fileSystem: fileSystem, + observabilityScope: observabilityScope.topScope + ) + + 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(observabilityScope.topScope.errorsReportedInAnyScope == false) + } + + @Test + func validateRegistryIDWithInvalidInput() throws { + let fileSystem = InMemoryFileSystem() + let observabilityScope = ObservabilitySystem.makeForTesting() + + let resolver = DefaultTemplateSourceResolver( + cwd: fileSystem.currentWorkingDirectory!, + fileSystem: fileSystem, + observabilityScope: observabilityScope.topScope + ) + + #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 fileSystem = InMemoryFileSystem() + let observabilityScope = ObservabilitySystem.makeForTesting() + + let resolver = DefaultTemplateSourceResolver( + cwd: fileSystem.currentWorkingDirectory!, + fileSystem: fileSystem, + observabilityScope: observabilityScope.topScope + ) + + #expect(throws: DefaultTemplateSourceResolver.SourceResolverError.missingLocalPath.self) { + try resolver.validate(templateSource: .local, directory: nil, url: nil, packageID: nil) + } + } + + @Test + func validateLocalSourceWithInvalidPath() throws { + let fileSystem = InMemoryFileSystem() + let observabilityScope = ObservabilitySystem.makeForTesting() + + let resolver = DefaultTemplateSourceResolver( + cwd: fileSystem.currentWorkingDirectory!, + fileSystem: fileSystem, + observabilityScope: observabilityScope.topScope + ) + + #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( + .serialized, + .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, fileSystem: InMemoryFileSystem()) + + 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( + .serialized, + .tags( + Tag.TestSize.medium, + Tag.Feature.Command.Package.Init, + ), + ) + struct TemplateDirectoryManagerTests { + @Test + func createTemporaryDirectories() throws { + let options = try GlobalOptions.parse([]) + + // Create InMemoryFileSystem with necessary directories + let fileSystem = InMemoryFileSystem() + try fileSystem.createDirectory(AbsolutePath("/tmp"), recursive: true) + + let tool = try SwiftCommandState.makeMockState(options: options, fileSystem: fileSystem) + + 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( + .serialized, + .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("cwd") + 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("clean-up") + 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("clean-up") + 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( + .serialized, + .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, fileSystem: InMemoryFileSystem()) + + 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( + .serialized, + .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) + } + } + + @Test(.skip("Failing due to package resolution")) + func findTemplateName() async throws { + try await fixture(name: "Miscellaneous/InitTemplates") {workingDir in + let templatePath = workingDir.appending(component: "ExecutableTemplate") + let versionFlags = VersionFlags( + exact: nil, + revision: nil, + branch: nil, + from: nil, + upToNextMinorFrom: nil, + to: nil + ) + + let globalOptions = try GlobalOptions.parse(["--package-path", workingDir.pathString]) + let swiftCommandState = try SwiftCommandState.makeMockState(options: globalOptions) + + let testLibraryOptions = try TestLibraryOptions.parse([]) + + let buildOptions = try BuildCommandOptions.parse([]) + + let templatePackageInitializer = try PackageInitConfiguration( + swiftCommandState: swiftCommandState, + name: nil, + initMode: nil, + testLibraryOptions: testLibraryOptions, + buildOptions: buildOptions, + globalOptions: globalOptions, + validatePackage: false, + args: [], + directory: templatePath, + url: nil, + packageID: nil, + versionFlags: versionFlags + ).makeInitializer() as? TemplatePackageInitializer + + let templateName = try await templatePackageInitializer?.resolveTemplateNameInPackage(from: templatePath) + #expect(templateName == "ExecutableTemplate") + } + } + + // 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 TemplateCLIConstructorTests { + // 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, + isRepeating: Bool = false + ) -> ArgumentInfoV0 { + ArgumentInfoV0( + kind: .option, + shouldDisplay: true, + sectionTitle: nil, + isOptional: false, + isRepeating: isRepeating, + 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 = TemplateCLIConstructor(hasTTY: false) + let emptyCommand = self.createTestCommand(name: "empty") + + let toolInfo = ToolInfoV0(command: emptyCommand) + let result = try promptingSystem.createCLIArgs( + predefinedArgs: [], + toolInfoJson: toolInfo + ) + + #expect(result.isEmpty) + } + + @Test + func handlesCommandWithProvidedArguments() throws { + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand( + arguments: [self.createRequiredOption(name: "name")] + ) + + let toolInfo = ToolInfoV0(command: commandInfo) + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "TestPackage"], + toolInfoJson: toolInfo + ) + + #expect(result.contains("--name")) + #expect(result.contains("TestPackage")) + } + + @Test + func handlesOptionalArgumentsWithDefaults() throws { + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand( + arguments: [ + self.createRequiredOption(name: "name"), + self.createOptionalFlag(name: "include-readme", defaultValue: "false"), + ] + ) + + let toolInfo = ToolInfoV0(command: commandInfo) + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "TestPackage"], + toolInfoJson: toolInfo + ) + + #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 = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand( + arguments: [self.createRequiredOption(name: "name")] + ) + + let toolInfo = ToolInfoV0(command: commandInfo) + #expect(throws: Error.self) { + _ = try promptingSystem.createCLIArgs( + predefinedArgs: [], + toolInfoJson: toolInfo + ) + } + } + + // MARK: - Argument Response Tests + + @Test + func argumentResponseHandlesExplicitlyUnsetFlags() throws { + let arg = self.createOptionalFlag(name: "verbose", defaultValue: "false") + + // Test explicitly unset flag + let explicitlyUnsetResponse = ArgumentResponse( + argument: arg, + values: [], + isExplicitlyUnset: true + ) + #expect(explicitlyUnsetResponse.isExplicitlyUnset == true) + #expect(explicitlyUnsetResponse.commandLineFragments.isEmpty) + + // Test normal flag response (true) + let trueResponse = ArgumentResponse( + argument: arg, + values: ["true"], + isExplicitlyUnset: false + ) + #expect(trueResponse.isExplicitlyUnset == false) + #expect(trueResponse.commandLineFragments == ["--verbose"]) + + // Test false flag response (should be empty) + let falseResponse = 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 = ArgumentResponse( + argument: arg, + values: [], + isExplicitlyUnset: true + ) + #expect(explicitlyUnsetResponse.isExplicitlyUnset == true) + #expect(explicitlyUnsetResponse.commandLineFragments.isEmpty) + + // Test normal option response + let normalResponse = 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 = 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 = ArgumentResponse( + argument: arg, + values: [], + isExplicitlyUnset: true + ) + #expect(explicitlyUnsetResponse.isExplicitlyUnset == true) + #expect(explicitlyUnsetResponse.commandLineFragments.isEmpty) + + // Test normal positional response + let normalResponse = ArgumentResponse( + argument: arg, + values: ["MyTarget"], + isExplicitlyUnset: false + ) + #expect(normalResponse.isExplicitlyUnset == false) + #expect(normalResponse.commandLineFragments == ["MyTarget"]) + } + + // MARK: - Command Line Generation Tests + + @Test + func commandLineGenerationWithMixedArgumentStates() throws { + // Test the actual public API instead of internal buildCommandLine + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + + let flagArg = self.createOptionalFlag(name: "verbose") + let requiredOptionArg = self.createRequiredOption(name: "name") + let optionalOptionArg = self.createOptionalOption(name: "output") + + let commandInfo = self.createTestCommand( + arguments: [flagArg, requiredOptionArg, optionalOptionArg] + ) + + let toolInfo = ToolInfoV0(command: commandInfo) + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "TestPackage", "--verbose"], + toolInfoJson: toolInfo + ) + + // Should contain the provided arguments + #expect(result.contains("--name")) + #expect(result.contains("TestPackage")) + #expect(result.contains("--verbose")) + } + + @Test + func commandLineGenerationWithDefaultValues() throws { + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + + let optionWithDefault = self.createOptionalOption(name: "version", defaultValue: "1.0.0") + let flagWithDefault = self.createOptionalFlag(name: "enabled", defaultValue: "true") + + let commandInfo = self.createTestCommand( + arguments: [optionWithDefault, flagWithDefault] + ) + + let toolInfo = ToolInfoV0(command: commandInfo) + let result = try promptingSystem.createCLIArgs( + predefinedArgs: [], + toolInfoJson: toolInfo + ) + + // Should contain default values + #expect(result.contains("--version")) + #expect(result.contains("1.0.0")) + #expect(result.contains("--enabled")) + } + + // MARK: - Argument Parsing Tests + + @Test + func parsesProvidedArgumentsCorrectly() throws { + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand( + arguments: [ + self.createRequiredOption(name: "name"), + self.createOptionalFlag(name: "verbose"), + self.createOptionalOption(name: "output"), + ] + ) + + let toolInfo = ToolInfoV0(command: commandInfo) + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "TestPackage", "--verbose", "--output", "./dist"], + toolInfoJson: toolInfo + ) + + #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 = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [restrictedArg]) + + // Valid value should work + let toolInfo = ToolInfoV0(command: commandInfo) + let validResult = try promptingSystem.createCLIArgs( + predefinedArgs: ["--type", "executable"], + toolInfoJson: toolInfo + ) + #expect(validResult.contains("executable")) + + // Invalid value should throw + #expect(throws: Error.self) { + _ = try promptingSystem.createCLIArgs( + predefinedArgs: ["--type", "invalid"], + toolInfoJson: toolInfo + ) + } + } + + // 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 = TemplateCLIConstructor(hasTTY: false) + let toolInfo = ToolInfoV0(command: mainCommand) + + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["init", "--name", "TestPackage"], + toolInfoJson: toolInfo + ) + + #expect(result.contains("init")) + #expect(result.contains("--name")) + #expect(result.contains("TestPackage")) + } + + @Test + func handlesBranchingSubcommandDetection() throws { + let subcommand = self.createTestCommand( + name: "init", + arguments: [self.createRequiredOption(name: "name")] + ) + + let branchingSubcommand = self.createTestCommand( + name: "ios" + ) + + let mainCommand = self.createTestCommand( + name: "package", + arguments: [self.createRequiredOption(name: "package-path")], + subcommands: [subcommand, branchingSubcommand], + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let toolInfo = ToolInfoV0(command: mainCommand) + + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--package-path", "foo", "init", "--name", "TestPackage"], + toolInfoJson: toolInfo + ) + + #expect(result.contains("init")) + #expect(result.contains("--name")) + #expect(result.contains("TestPackage")) + } + + // MARK: - Error Handling Tests + + @Test + func handlesInvalidArgumentNames() throws { + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand( + arguments: [self.createRequiredOption(name: "name")] + ) + + // Should throw errors if invalid predefined arguments have been given. + let toolInfo = ToolInfoV0(command: commandInfo) + #expect(throws: Error.self) { + let _ = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "TestPackage", "--unknown", "value"], + toolInfoJson: toolInfo + ) + } + } + + @Test + func handlesMissingValueForOption() throws { + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand( + arguments: [self.createRequiredOption(name: "name")] + ) + + let toolInfo = ToolInfoV0(command: commandInfo) + #expect(throws: Error.self) { + _ = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name"], + toolInfoJson: toolInfo + ) + } + } + + @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 = TemplateCLIConstructor(hasTTY: false) + let toolInfo = ToolInfoV0(command: mainCommand) + + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["package", "create", "--name", "MyPackage"], + toolInfoJson: toolInfo + ) + + #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 = TemplateCLIConstructor(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 toolInfo = ToolInfoV0(command: complexCommand) + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "TestPackage", "--verbose", "CustomTarget"], + toolInfoJson: toolInfo + ) + + #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 = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand( + arguments: [ + self.createOptionalOption(name: "output", defaultValue: "default"), + self.createOptionalFlag(name: "verbose", defaultValue: "false"), + ] + ) + + let toolInfoJSON = ToolInfoV0(command: commandInfo) + let result = try promptingSystem.createCLIArgs(predefinedArgs: [], toolInfoJson: toolInfoJSON) + + // 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 = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [repeatingArg]) + + let toolInfoJSON = ToolInfoV0(command: commandInfo) + let result = try promptingSystem.createCLIArgs(predefinedArgs: ["--define", "FOO=bar", "--define", "BAZ=qux"], toolInfoJson: toolInfoJSON) + + #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 = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [completionArg]) + + let toolInfoJSON = ToolInfoV0(command: commandInfo) + // Valid completion value should work + let validResult = try promptingSystem.createCLIArgs(predefinedArgs: ["--platform", "iOS"], toolInfoJson: toolInfoJSON) + #expect(validResult.contains("iOS")) + + // Invalid completion value should throw + #expect(throws: Error.self) { + _ = try promptingSystem.createCLIArgs(predefinedArgs: ["--platform", "Linux"], toolInfoJson: toolInfoJSON) + } + } + + @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 = ArgumentResponse( + argument: flagArg, + values: ["true"], + isExplicitlyUnset: false + ) + #expect(flagResponse.commandLineFragments == ["--verbose"]) + + let optionResponse = ArgumentResponse( + argument: optionArg, + values: ["./output"], + isExplicitlyUnset: false + ) + #expect(optionResponse.commandLineFragments == ["--output", "./output"]) + + let positionalResponse = ArgumentResponse( + argument: positionalArg, + values: ["MyTarget"], + isExplicitlyUnset: false + ) + #expect(positionalResponse.commandLineFragments == ["MyTarget"]) + } + + @Test + func handlesMissingArgumentErrors() throws { + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand( + arguments: [ + self.createRequiredOption(name: "required-arg"), + self.createOptionalOption(name: "optional-arg"), + ] + ) + + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Should throw when required argument is missing + #expect(throws: Error.self) { + _ = try promptingSystem.createCLIArgs(predefinedArgs: ["--optional-arg", "value"], toolInfoJson: toolInfoJSON) + } + } + + // MARK: - Parsing Strategy Tests + + @Test + func handlesParsingStrategies() throws { + let upToNextOptionArg = self.createRequiredOption( + name: "files", + parsingStrategy: .upToNextOption, + isRepeating: true + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [upToNextOptionArg]) + + let toolInfoJSON = ToolInfoV0(command: commandInfo) + let result = try promptingSystem.createCLIArgs(predefinedArgs: ["--files", "file1.swift", "file2.swift", "file3.swift"], toolInfoJson: toolInfoJSON) + + #expect(result.contains("--files")) + #expect(result.contains("file1.swift")) + #expect(result.contains("file2.swift")) + #expect(result.contains("file3.swift")) + } + + @Test + func handlesPostTerminatorStrategy() throws { + let preTerminatorArg = self.createRequiredOption(name: "name") + let postTerminatorArg = ArgumentInfoV0( + kind: .positional, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: true, + 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 = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [preTerminatorArg, postTerminatorArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "TestName", "--", "file1", "file2", "--option"], + toolInfoJson: toolInfoJSON + ) + + // Pre-terminator should be parsed normally + #expect(result.contains("--name")) + #expect(result.contains("TestName")) + // Post-terminator should capture everything after -- + #expect(result.contains("file1")) + #expect(result.contains("file2")) + #expect(result.contains("--option")) // Even options are treated as values after -- + } + + // MARK: - Error Handling Tests + + @Test + func handlesUnknownOptions() throws { + let nameArg = self.createRequiredOption(name: "name") + let formatArg = self.createRequiredOption(name: "format") + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [nameArg, formatArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Test unknown long option + #expect(throws: (any Error).self) { + try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "test", "--format", "json", "--unknown"], + toolInfoJson: toolInfoJSON + ) + } + + // Test unknown short option + #expect(throws: (any Error).self) { + try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "test", "--format", "json", "-q"], + toolInfoJson: toolInfoJSON + ) + } + } + + @Test + func handlesMissingRequiredValues() throws { + let nameArg = self.createRequiredOption(name: "name") + let formatArg = self.createRequiredOption(name: "format") + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [nameArg, formatArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Test missing value for option + #expect(throws: (any Error).self) { + try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "test", "--format"], + toolInfoJson: toolInfoJSON + ) + } + + // Test missing required argument entirely + #expect(throws: (any Error).self) { + try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "test"], + toolInfoJson: toolInfoJSON + ) + } + } + + @Test + func handlesUnexpectedArguments() throws { + let nameArg = self.createRequiredOption(name: "name") + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [nameArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Test single unexpected argument + #expect(throws: (any Error).self) { + try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "test", "unexpected"], + toolInfoJson: toolInfoJSON + ) + } + + // Test multiple unexpected arguments + #expect(throws: (any Error).self) { + try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "test", "unexpected1", "unexpected2"], + toolInfoJson: toolInfoJSON + ) + } + } + + @Test + func handlesInvalidEnumValues() throws { + let enumArg = self.createRequiredOption( + name: "format", + allValues: ["json", "xml", "yaml"] + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [enumArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Test invalid enum value + #expect(throws: (any Error).self) { + try promptingSystem.createCLIArgs( + predefinedArgs: ["--format", "invalid"], + toolInfoJson: toolInfoJSON + ) + } + + // Test valid enum value should work + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--format", "json"], + toolInfoJson: toolInfoJSON + ) + #expect(result.contains("--format")) + #expect(result.contains("json")) + } + + // MARK: - Edge Case Tests + + @Test + func handlesEmptyInput() throws { + let optionalArg = self.createOptionalOption(name: "name", defaultValue: "default") + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [optionalArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Empty input should work with optional arguments + let result = try promptingSystem.createCLIArgs( + predefinedArgs: [], + toolInfoJson: toolInfoJSON + ) + + // Should use default value + #expect(result.contains("--name")) + #expect(result.contains("default")) + } + + @Test + func handlesMalformedArguments() throws { + let nameArg = self.createRequiredOption(name: "name") + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [nameArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Test malformed long option (triple dash) + #expect(throws: (any Error).self) { + try promptingSystem.createCLIArgs( + predefinedArgs: ["---name", "value"], + toolInfoJson: toolInfoJSON + ) + } + + // Test empty option name + #expect(throws: (any Error).self) { + try promptingSystem.createCLIArgs( + predefinedArgs: ["--", "value"], + toolInfoJson: toolInfoJSON + ) + } + } + + @Test + func handlesSpecialCharactersInValues() throws { + let nameArg = self.createRequiredOption(name: "name") + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [nameArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Test values with special characters + let specialValues = [ + "value with spaces", + "value-with-dashes", + "value_with_underscores", + "value.with.dots", + "value@with@symbols", + "value/with/slashes" + ] + + for specialValue in specialValues { + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", specialValue], + toolInfoJson: toolInfoJSON + ) + #expect(result.contains("--name")) + #expect(result.contains(specialValue)) + } + } + + @Test + func handlesEqualsSignInOptions() throws { + let nameArg = self.createRequiredOption(name: "name") + let formatArg = self.createRequiredOption(name: "format") + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [nameArg, formatArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Test equals sign syntax + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name=TestName", "--format=json"], + toolInfoJson: toolInfoJSON + ) + + #expect(result.contains("--name")) + #expect(result.contains("TestName")) + #expect(result.contains("--format")) + #expect(result.contains("json")) + } + + @Test + func handlesUnicodeCharacters() throws { + let nameArg = self.createRequiredOption(name: "name") + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [nameArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Test Unicode characters + let unicodeValues = [ + "测试", // Chinese + "テスト", // Japanese + "тест", // Russian + "🎯📱💻", // Emojis + "café", // Accented characters + ] + + for unicodeValue in unicodeValues { + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", unicodeValue], + toolInfoJson: toolInfoJSON + ) + #expect(result.contains("--name")) + #expect(result.contains(unicodeValue)) + } + } + + // MARK: - Positional Argument Tests + + @Test + func handlesSinglePositionalArgument() throws { + let positionalArg = ArgumentInfoV0( + kind: .positional, + 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: "Package name", + discussion: nil + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [positionalArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["MyPackage"], + toolInfoJson: toolInfoJSON + ) + + #expect(result.contains("MyPackage")) + } + + @Test + func handlesMultiplePositionalArguments() throws { + let nameArg = ArgumentInfoV0( + kind: .positional, + 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: "Package name", + discussion: nil + ) + + let pathArg = ArgumentInfoV0( + kind: .positional, + shouldDisplay: true, + sectionTitle: nil, + isOptional: false, + isRepeating: false, + parsingStrategy: .default, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "path")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "path"), + valueName: "path", + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "Package path", + discussion: nil + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [nameArg, pathArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["MyPackage", "/path/to/package"], + toolInfoJson: toolInfoJSON + ) + + #expect(result.contains("MyPackage")) + #expect(result.contains("/path/to/package")) + } + + @Test + func handlesRepeatingPositionalArguments() throws { + let filesArg = ArgumentInfoV0( + kind: .positional, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: true, + parsingStrategy: .default, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "files")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "files"), + valueName: "files", + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "Input files", + discussion: nil + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [filesArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["file1.swift", "file2.swift", "file3.swift"], + toolInfoJson: toolInfoJSON + ) + + #expect(result.contains("file1.swift")) + #expect(result.contains("file2.swift")) + #expect(result.contains("file3.swift")) + } + + @Test //RELOOK + func handlesPositionalWithTerminator() throws { + let nameArg = ArgumentInfoV0( + kind: .positional, + shouldDisplay: true, + sectionTitle: nil, + isOptional: false, + isRepeating: false, + parsingStrategy: .postTerminator, + 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: "Package name", + discussion: nil + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [nameArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Test positional argument that looks like an option after terminator + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--", "--package-name"], + toolInfoJson: toolInfoJSON + ) + + #expect(result.contains("--package-name")) + } + + // MARK: - Short Option and Combined Short Option Tests + + private func createShortOption( + name: String, + shortName: Character, + isOptional: Bool = false, + isRepeating: Bool = false + ) -> ArgumentInfoV0 { + ArgumentInfoV0( + kind: .option, + shouldDisplay: true, + sectionTitle: nil, + isOptional: isOptional, + isRepeating: isRepeating, + parsingStrategy: .default, + names: [ + ArgumentInfoV0.NameInfoV0(kind: .short, name: String(shortName)), + ArgumentInfoV0.NameInfoV0(kind: .long, name: name) + ], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .short, name: String(shortName)), + valueName: name, + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "\(name.capitalized) parameter", + discussion: nil + ) + } + + private func createShortFlag( + name: String, + shortName: Character, + isOptional: Bool = true + ) -> ArgumentInfoV0 { + ArgumentInfoV0( + kind: .flag, + shouldDisplay: true, + sectionTitle: nil, + isOptional: isOptional, + isRepeating: false, + parsingStrategy: .default, + names: [ + ArgumentInfoV0.NameInfoV0(kind: .short, name: String(shortName)), + ArgumentInfoV0.NameInfoV0(kind: .long, name: name) + ], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .short, name: String(shortName)), + valueName: name, + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "\(name.capitalized) flag", + discussion: nil + ) + } + + @Test + func handlesShortOptions() throws { + let nameArg = self.createShortOption(name: "name", shortName: "n") + let formatArg = self.createShortOption(name: "format", shortName: "f") + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [nameArg, formatArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["-n", "TestName", "-f", "json"], + toolInfoJson: toolInfoJSON + ) + + #expect(result.contains("-n")) + #expect(result.contains("TestName")) + #expect(result.contains("-f")) + #expect(result.contains("json")) + } + + @Test + func handlesShortFlags() throws { + let verboseFlag = self.createShortFlag(name: "verbose", shortName: "v") + let debugFlag = self.createShortFlag(name: "debug", shortName: "d") + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [verboseFlag, debugFlag]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["-v", "-d"], + toolInfoJson: toolInfoJSON + ) + + #expect(result.contains("-v")) + #expect(result.contains("-d")) + } + + @Test + func handlesCombinedShortFlags() throws { + let verboseFlag = self.createShortFlag(name: "verbose", shortName: "v") + let debugFlag = self.createShortFlag(name: "debug", shortName: "d") + let forceFlag = self.createShortFlag(name: "force", shortName: "f") + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [verboseFlag, debugFlag, forceFlag]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Test combined short flags like -vdf + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["-vdf"], + toolInfoJson: toolInfoJSON + ) + + // Should expand to individual flags + #expect(result.contains("-v")) + #expect(result.contains("-d")) + #expect(result.contains("-f")) + } + + @Test + func handlesShortOptionWithEqualsSign() throws { + let nameArg = self.createShortOption(name: "name", shortName: "n") + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [nameArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["-n=TestName"], + toolInfoJson: toolInfoJSON + ) + + #expect(result.contains("-n")) + #expect(result.contains("TestName")) + } + // MARK: - Advanced Terminator Tests + @Test + func handlesTerminatorSeparation() throws { + let nameArg = self.createRequiredOption(name: "name") + let filesArg = ArgumentInfoV0( + kind: .positional, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: true, + parsingStrategy: .postTerminator, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "files")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "files"), + valueName: "files", + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "Input files", + discussion: nil + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [nameArg, filesArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Test that options before -- are parsed as options, after -- as positional + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "TestName", "--", "--file1", "--file2", "-option"], + toolInfoJson: toolInfoJSON + ) + + #expect(result.contains("--name")) + #expect(result.contains("TestName")) + #expect(result.contains("--file1")) + #expect(result.contains("--file2")) + #expect(result.contains("-option")) + } + + @Test + func handlesEmptyTerminator() throws { + let nameArg = self.createRequiredOption(name: "name") + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [nameArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Test terminator with no arguments after it + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "TestName", "--"], + toolInfoJson: toolInfoJSON + ) + + #expect(result.contains("--name")) + #expect(result.contains("TestName")) + } + + @Test + func handlesMultipleTerminators() throws { + let filesArg = ArgumentInfoV0( + kind: .positional, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: true, + parsingStrategy: .postTerminator, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "files")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "files"), + valueName: "files", + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "Input files", + discussion: nil + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [filesArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Test multiple terminators - subsequent ones should be treated as values + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--", "file1", "--", "file2"], + toolInfoJson: toolInfoJSON + ) + + #expect(result.contains("file1")) + #expect(result.contains("--")) // Second -- should be treated as a value + #expect(result.contains("file2")) + } + + // MARK: - Array/Repeating Argument Tests + + @Test + func handlesRepeatingOptions() throws { + let includeArg = self.createRequiredOption( + name: "include", + isRepeating: true + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [includeArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--include", "path1", "--include", "path2", "--include", "path3"], + toolInfoJson: toolInfoJSON + ) + + // Should preserve multiple instances + #expect(result.filter { $0 == "--include" }.count == 3) + #expect(result.contains("path1")) + #expect(result.contains("path2")) + #expect(result.contains("path3")) + } + + @Test + func handlesRepeatingOptionsWithUpToNextOption() throws { + let filesArg = self.createRequiredOption( + name: "files", + parsingStrategy: .upToNextOption, + isRepeating: true + ) + let outputArg = self.createRequiredOption(name: "output") + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [filesArg, outputArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--files", "file1.swift", "file2.swift", "file3.swift", "--output", "result.txt"], + toolInfoJson: toolInfoJSON + ) + + #expect(result.contains("--files")) + #expect(result.contains("file1.swift")) + #expect(result.contains("file2.swift")) + #expect(result.contains("file3.swift")) + #expect(result.contains("--output")) + #expect(result.contains("result.txt")) + } + + @Test + func handlesArrayOfFlags() throws { + let verboseFlag = ArgumentInfoV0( + kind: .flag, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: true, // Repeating flag (counts verbosity level) + parsingStrategy: .default, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "verbose")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "verbose"), + valueName: "verbose", + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "Increase verbosity level", + discussion: nil + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [verboseFlag]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--verbose", "--verbose", "--verbose"], + toolInfoJson: toolInfoJSON + ) + + // Should preserve all verbose flags (for counting) + #expect(result.filter { $0 == "--verbose" }.count == 3) + } + + @Test + func handlesEmptyRepeatingArguments() throws { + let filesArg = ArgumentInfoV0( + kind: .positional, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: true, + parsingStrategy: .default, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "files")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "files"), + valueName: "files", + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "Input files", + discussion: nil + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [filesArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Should handle no input for optional repeating arguments + let result = try promptingSystem.createCLIArgs( + predefinedArgs: [], + toolInfoJson: toolInfoJSON + ) + + // For optional repeating arguments, empty input should be valid + #expect(result.isEmpty || result.allSatisfy { !$0.hasSuffix(".swift") }) + } + + // MARK: - Comprehensive Parsing Strategy Tests + + @Test + func handlesScanningForValueStrategy() throws { + let arg1 = self.createRequiredOption( + name: "name", + parsingStrategy: .scanningForValue + ) + let arg2 = self.createRequiredOption( + name: "format", + parsingStrategy: .scanningForValue + ) + let arg3 = self.createRequiredOption( + name: "input", + parsingStrategy: .scanningForValue + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [arg1, arg2, arg3]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Test 1: Normal order + let result1 = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "Foo", "--format", "Bar", "--input", "Baz"], + toolInfoJson: toolInfoJSON + ) + #expect(result1.contains("--name")) + #expect(result1.contains("Foo")) + #expect(result1.contains("--format")) + #expect(result1.contains("Bar")) + #expect(result1.contains("--input")) + #expect(result1.contains("Baz")) + + // Test 2: Scanning finds values after other options + let result2 = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "--format", "Foo", "Bar", "--input", "Baz"], + toolInfoJson: toolInfoJSON + ) + #expect(result2.contains("Foo")) + #expect(result2.contains("Bar")) + #expect(result2.contains("Baz")) + } + + @Test + func handlesUnconditionalStrategy() throws { + let arg1 = self.createRequiredOption( + name: "name", + parsingStrategy: .unconditional + ) + let arg2 = self.createRequiredOption( + name: "format", + parsingStrategy: .unconditional + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [arg1, arg2]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + // Test unconditional parsing - takes next value regardless + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["--name", "--name", "--format", "--format"], + toolInfoJson: toolInfoJSON + ) + + // Should treat option names as values + #expect(result.contains("--name")) + #expect(result.contains("--format")) + } + + @Test + func handlesAllRemainingInputStrategy() throws { + let remainingArg = ArgumentInfoV0( + kind: .positional, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: true, + parsingStrategy: .allRemainingInput, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "remaining")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "remaining"), + valueName: "remaining", + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "Remaining arguments", + discussion: nil + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand(arguments: [remainingArg]) + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + let result = try promptingSystem.createCLIArgs( + predefinedArgs: ["file1.swift", "file2.swift", "--unknown", "value"], + toolInfoJson: toolInfoJSON + ) + + // All remaining input should be captured + #expect(result.contains("file1.swift")) + #expect(result.contains("file2.swift")) + #expect(result.contains("--unknown")) + #expect(result.contains("value")) + } + + @Test + func handlesTerminatorParsing() throws { + let postTerminatorArg = ArgumentInfoV0( + kind: .positional, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: true, + 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 = TemplateCLIConstructor(hasTTY: false) + let commandInfo = self.createTestCommand( + arguments: [ + self.createRequiredOption(name: "name"), + postTerminatorArg, + ] + ) + + let toolInfoJSON = ToolInfoV0(command: commandInfo) + + let result = try promptingSystem.createCLIArgs(predefinedArgs: ["--name", "TestPackage", "--", "arg1", "arg2"], toolInfoJson: toolInfoJSON) + + #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) + } + + @Test + func handlesTemplateWithNoArguments() throws { + let noArgsCommand = CommandInfoV0( + superCommands: [], + shouldDisplay: true, + commandName: "no-args-template", + abstract: "Template with no arguments", + discussion: "A template that requires no user input", + defaultSubcommand: nil, + subcommands: [], + arguments: [] + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + + let toolInfoJSON = ToolInfoV0(command: noArgsCommand) + + let result = try promptingSystem.createCLIArgs(predefinedArgs: [], toolInfoJson: toolInfoJSON) + + #expect(result.isEmpty) + + #expect(throws: TemplateError.unexpectedArguments(["--some-flag", "extra-arg"]).self) { + try promptingSystem.createCLIArgs(predefinedArgs: ["--some-flag", "extra-arg"], toolInfoJson: toolInfoJSON) + } + } + + @Test + func handlesTemplateWithEmptyArgumentsArray() throws { + // Test template with empty arguments array (not nil, but empty) + let emptyArgsCommand = CommandInfoV0( + superCommands: [], + shouldDisplay: true, + commandName: "empty-args-template", + abstract: "Template with empty arguments array", + discussion: "A template with an empty arguments array", + defaultSubcommand: nil, + subcommands: [], + arguments: [] // Explicitly empty array + ) + + let promptingSystem = TemplateCLIConstructor(hasTTY: false) + + let toolInfoJSON = ToolInfoV0(command: emptyArgsCommand) + let result = try promptingSystem.createCLIArgs(predefinedArgs: [], toolInfoJson: toolInfoJSON) + + #expect(result.isEmpty) + } + } + + // MARK: - Template Plugin Coordinator Tests + + @Suite( + .serialized, + .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, fileSystem: InMemoryFileSystem()) + + 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(.skip("Intermittent failures when loading package graph, needs investigating")) + func loadsPackageGraphInTemporaryWorkspace() async throws { + 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, fileSystem: InMemoryFileSystem()) + 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, fileSystem: InMemoryFileSystem()) + + 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( + .skip("Intermittent failures when loading package graph, needs investigating"), + .tags( + Tag.TestSize.medium, + Tag.Feature.Command.Package.Init, + ), + ) + struct TemplatePluginRunnerTests { + @Test(.serialized) + func handlesPluginExecutionForValidPackage() async throws { + + 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 { + + 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: - InitTemplatePackage Tests + + @Suite( + .serialized, + .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( + .serialized, + .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 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( + .serialized, + .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(.disabled("Disabled as it is already tested via swift package init")) + func standardPackageInitializerFallback() async throws { + try await testWithTemporaryDirectory { tmpPath in + let packagePath = tmpPath.appending("Foo") + try localFileSystem.createDirectory(packagePath) + + let options = try GlobalOptions.parse(["--package-path", packagePath.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(packagePath.appending("Package.swift"))) + #expect(try localFileSystem + .getDirectoryContents(packagePath.appending("Sources").appending("TestPackage")) == ["TestPackage.swift"] + ) + } + } + } + + // MARK: - Template Test Prompting System Tests + + @Suite( + .skip("Disabled until we integrate proper parsing"), + .tags( + Tag.TestSize.medium, + Tag.Feature.Command.Package.Init, + ), + ) + struct TemplateTestPromptingSystemTests { + // 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, + isRepeating: Bool = false + ) -> ArgumentInfoV0 { + ArgumentInfoV0( + kind: .option, + shouldDisplay: true, + sectionTitle: nil, + isOptional: false, + isRepeating: isRepeating, + 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 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 + ) + } + + // MARK: - Basic Creation and Command Path Generation Tests + + @Test + func createsTestPromptingSystemSuccessfully() throws { + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + let emptyCommand = self.createTestCommand(name: "empty") + + let commandPaths = try promptingSystem.generateCommandPaths( + rootCommand: emptyCommand, + args: [], + branches: [] + ) + #expect(!commandPaths.isEmpty) + } + + @Test + func generatesCommandPathsWithBranchFiltering() throws { + let initSubcommand = self.createTestCommand( + name: "init", + arguments: [self.createRequiredOption(name: "name")] + ) + let swiftSubcommand = self.createTestCommand( + name: "swift", + arguments: [self.createOptionalFlag(name: "verbose")] + ) + + let rootCommand = self.createTestCommand( + name: "package", + subcommands: [initSubcommand, swiftSubcommand] + ) + + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + + let filteredPaths = try promptingSystem.generateCommandPaths( + rootCommand: rootCommand, + args: ["--name", "TestProject"], + branches: ["init"] + ) + + #expect(!filteredPaths.isEmpty) + let hasInitPath = filteredPaths.contains { path in + path.fullPathKey.contains("init") + } + #expect(hasInitPath) + + let hasSwiftPath = filteredPaths.contains { path in + path.fullPathKey.contains("swift") + } + #expect(!hasSwiftPath) // Should be filtered out + } + + @Test + func handlesArgumentInheritanceBetweenCommandLevels() throws { + let parentArg = self.createRequiredOption(name: "parent-option") + let childArg = self.createRequiredOption(name: "child-option") + + let childCommand = self.createTestCommand( + name: "child", + arguments: [childArg] + ) + + let parentCommand = self.createTestCommand( + name: "parent", + arguments: [parentArg], + subcommands: [childCommand] + ) + + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + + let commandPaths = try promptingSystem.generateCommandPaths( + rootCommand: parentCommand, + args: ["--parent-option", "value1", "child", "--child-option", "value2"], + branches: [] + ) + + #expect(!commandPaths.isEmpty) + let childPath = commandPaths[0].commandChain.first { command in + command.commandName == "child" + } + #expect(childPath != nil) + + if let path = childPath { + let childArgument = path.arguments.first { argument in + argument.argument.valueName == "child-option" + } + #expect(childArgument?.values.contains("value2") ?? false == true) + } + } + + @Test + func dfsWithInheritanceExploresAllPaths() throws { + let level1Command = self.createTestCommand( + name: "level1", + arguments: [self.createRequiredOption(name: "arg1")] + ) + let level2Command = self.createTestCommand( + name: "level2", + arguments: [self.createRequiredOption(name: "arg2")], + subcommands: [level1Command] + ) + + let rootCommand = self.createTestCommand( + name: "root", + subcommands: [level1Command, level2Command] + ) + + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + + let commandPaths = try promptingSystem.generateCommandPaths( + rootCommand: rootCommand, + args: ["--arg1", "val1", "--arg2", "val2"], + branches: [] + ) + + #expect(commandPaths.count >= 2) // Should have multiple paths + let hasLevel1Path = commandPaths.contains { $0.fullPathKey.contains("level1") } + let hasLevel2Path = commandPaths.contains { $0.fullPathKey.contains("level2") } + #expect(hasLevel1Path) + #expect(hasLevel2Path) + } + + // MARK: - Argument Parsing and Validation Tests + + @Test + func parseAndMatchArgumentsHandlesDifferentTypes() throws { + let flagArg = self.createOptionalFlag(name: "verbose") + let optionArg = self.createRequiredOption(name: "output") + let restrictedArg = self.createRequiredOption( + name: "type", + allValues: ["executable", "library"] + ) + + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + let command = self.createTestCommand( + arguments: [flagArg, optionArg, restrictedArg] + ) + + guard let definedArgs = command.arguments else { + Issue.record("command does not contain arguments") + return + } + + let (parsed, leftover) = try promptingSystem.parseAndMatchArguments( + ["--verbose", "--output", "file.txt", "--type", "executable", "randomExtraPositionalArgument"], + definedArgs: definedArgs, + subcommands: [] + ) + + #expect(parsed.count == 3) + #expect(leftover.count == 0) // zero, as we parsed randomExtraPositionalArgument as an extra argument, it gets ignored. + + let verboseArg = parsed.first { $0.argument.preferredName?.name == "verbose" } + #expect(verboseArg?.values == ["true"]) + + let outputArg = parsed.first { $0.argument.preferredName?.name == "output" } + #expect(outputArg?.values == ["file.txt"]) + + let typeArg = parsed.first { $0.argument.preferredName?.name == "type" } + #expect(typeArg?.values == ["executable"]) + } + + @Test + func validatesMissingRequiredArgumentsWithoutTTY() throws { + let requiredArg = self.createRequiredOption(name: "name") + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + let command = self.createTestCommand(arguments: [requiredArg]) + + #expect(throws: Error.self) { + _ = try promptingSystem.generateCommandPaths( + rootCommand: command, + args: [], + branches: [] + ) + } + } + + @Test + func handlesValidationAgainstAllowedValues() throws { + let restrictedArg = self.createRequiredOption( + name: "platform", + allValues: ["iOS", "macOS", "watchOS"] + ) + + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + let command = self.createTestCommand(arguments: [restrictedArg]) + + // Valid value should work + let validPaths = try promptingSystem.generateCommandPaths( + rootCommand: command, + args: ["--platform", "iOS"], + branches: [] + ) + #expect(!validPaths.isEmpty) + + // Invalid value should throw + #expect(throws: Error.self) { + _ = try promptingSystem.generateCommandPaths( + rootCommand: command, + args: ["--platform", "Linux"], + branches: [] + ) + } + } + + @Test + func handlesDefaultValuesCorrectly() throws { + let optionWithDefault = 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 destination", + discussion: nil + ) + + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + let command = self.createTestCommand(arguments: [optionWithDefault]) + + let paths = try promptingSystem.generateCommandPaths( + rootCommand: command, + args: [], + branches: [] + ) + + #expect(!paths.isEmpty) + let outputArgument = paths.first!.commandChain.first!.arguments.first { argument in + argument.argument.valueName == "output" + } + + #expect(outputArgument?.values.contains("stdout") ?? false == true) + } + + // MARK: - Parsing Strategy Tests + + @Test + func handlesUpToNextOptionParsingStrategy() throws { + let upToNextOptionArg = ArgumentInfoV0( + kind: .option, + shouldDisplay: true, + sectionTitle: nil, + isOptional: false, + isRepeating: false, + parsingStrategy: .upToNextOption, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "files")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "files"), + valueName: "files", + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "Input files", + discussion: nil + ) + + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + let command = self.createTestCommand(arguments: [upToNextOptionArg]) + + guard let definedArgs = command.arguments else { + Issue.record("command does not contain arguments") + return + } + + let (parsed, leftover) = try promptingSystem.parseAndMatchArguments( + ["--files", "file1.swift", "file2.swift", "--other"], + definedArgs: definedArgs, + subcommands: [] + ) + + let filesArg = parsed.first { $0.argument.preferredName?.name == "files" } + #expect(filesArg?.values == ["file1.swift", "file2.swift"]) + #expect(leftover == ["--other"]) + } + + @Test + func handlesAllRemainingInputStrategy() throws { + let allRemainingArg = ArgumentInfoV0( + kind: .option, + shouldDisplay: true, + sectionTitle: nil, + isOptional: false, + isRepeating: false, + parsingStrategy: .allRemainingInput, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "args")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "args"), + valueName: "args", + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "All remaining arguments", + discussion: nil + ) + + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + let command = self.createTestCommand(arguments: [allRemainingArg]) + + guard let definedArgs = command.arguments else { + Issue.record("command does not contain arguments") + return + } + + let (parsed, leftover) = try promptingSystem.parseAndMatchArguments( + ["--args", "arg1", "arg2", "arg3"], + definedArgs: definedArgs, + subcommands: [] + ) + + let argsArg = parsed.first { $0.argument.preferredName?.name == "args" } + #expect(argsArg?.values == ["arg1", "arg2", "arg3"]) + #expect(leftover.isEmpty) + } + + // MARK: - Command Line Fragment Generation Tests + + @Test + func commandLineFragmentGenerationWithMixedArguments() throws { + let flagArg = self.createOptionalFlag(name: "verbose", defaultValue: "false") + let optionArg = self.createRequiredOption(name: "output") + 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: nil, + abstract: "Define parameter", + discussion: nil + ) + + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + let command = self.createTestCommand( + arguments: [flagArg, optionArg, multiValueArg] + ) + + let paths = try promptingSystem.generateCommandPaths( + rootCommand: command, + args: ["--verbose", "--output", "result.txt", "--define", "FOO=1", "--define", "BAR=2"], + branches: [] + ) + + #expect(!paths.isEmpty) + let path = paths.first! + + #expect(path.commandChain.first!.arguments.count == 3) + + let arguments = path.commandChain[0].arguments + let verboseArg = arguments.first { $0.argument.valueName == "verbose" } + let outputArg = arguments.first { $0.argument.valueName == "output" } + let defineArg = arguments.first { $0.argument.valueName == "define" } + + #expect(verboseArg?.values == ["true"]) + #expect(outputArg?.values == ["result.txt"]) + #expect(defineArg?.values == ["FOO=1", "BAR=2"]) + } + + @Test + func handlesExplicitlyUnsetArguments() throws { + let optionalArg = ArgumentInfoV0( + kind: .option, + shouldDisplay: true, + sectionTitle: nil, + isOptional: true, + isRepeating: false, + parsingStrategy: .default, + names: [ArgumentInfoV0.NameInfoV0(kind: .long, name: "optional")], + preferredName: ArgumentInfoV0.NameInfoV0(kind: .long, name: "optional"), + valueName: "optional", + defaultValue: nil, + allValueStrings: nil, + allValueDescriptions: nil, + completionKind: nil, + abstract: "Optional parameter", + discussion: nil + ) + + let response = TemplateTestPromptingSystem.ArgumentResponse( + argument: optionalArg, + values: [], + isExplicitlyUnset: true + ) + + #expect(response.isExplicitlyUnset) + #expect(response.commandLineFragments.isEmpty) + } + + // MARK: - Error Handling Tests + + @Test + func handlesInvalidArgumentGracefully() throws { + let validArg = self.createRequiredOption(name: "name") + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + let command = self.createTestCommand(arguments: [validArg]) + + guard let definedArgs = command.arguments else { + Issue.record("command does not contain arguments") + return + } + + let (parsed, leftover) = try promptingSystem.parseAndMatchArguments( + ["--name", "TestProject", "--unknown", "value"], + definedArgs: definedArgs, + subcommands: [] + ) + + #expect(parsed.count == 1) + #expect(leftover == ["--unknown", "value"]) + + let nameArg = parsed.first { $0.argument.preferredName?.name == "name" } + #expect(nameArg?.values == ["TestProject"]) + } + + @Test + func handlesMissingValueForRequiredOption() throws { + let requiredArg = self.createRequiredOption(name: "name") + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + let command = self.createTestCommand(arguments: [requiredArg]) + + guard let definedArgs = command.arguments else { + Issue.record("command does not contain arguments") + return + } + + #expect(throws: Error.self) { + _ = try promptingSystem.parseAndMatchArguments( + ["--name"], + definedArgs: definedArgs, + subcommands: [] + ) + } + } + + // MARK: - Integration Tests + + @Test + func complexCommandStructureWithMultiplePaths() throws { + let initArg = self.createRequiredOption(name: "name") + let buildArg = self.createOptionalFlag(name: "verbose") + let testArg = self.createOptionalFlag(name: "parallel") + + let initCommand = self.createTestCommand(name: "init", arguments: [initArg]) + let buildCommand = self.createTestCommand(name: "build", arguments: [buildArg]) + let testCommand = self.createTestCommand(name: "test", arguments: [testArg]) + + let rootCommand = self.createTestCommand( + name: "swift", + subcommands: [initCommand, buildCommand, testCommand] + ) + + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + + let paths = try promptingSystem.generateCommandPaths( + rootCommand: rootCommand, + args: ["--name", "TestPackage", "--verbose", "--parallel"], + branches: [] + ) + + #expect(paths.count >= 3) // Should generate paths for init, build, and test + + let initPaths = paths.filter { $0.fullPathKey.contains("init") } + let buildPaths = paths.filter { $0.fullPathKey.contains("build") } + let testPaths = paths.filter { $0.fullPathKey.contains("test") } + + #expect(!initPaths.isEmpty) + #expect(!buildPaths.isEmpty) + #expect(!testPaths.isEmpty) + } + + @Test + func branchFilteringWorksWithNestedSubcommands() throws { + let leafCommand = self.createTestCommand(name: "leaf") + let nestedCommand = self.createTestCommand(name: "nested", subcommands: [leafCommand]) + let rootCommand = self.createTestCommand(name: "root", subcommands: [nestedCommand]) + + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + + let filteredPaths = try promptingSystem.generateCommandPaths( + rootCommand: rootCommand, + args: [], + branches: ["nested"] + ) + + let hasNestedPath = filteredPaths.contains { path in + path.fullPathKey.contains("nested") + } + #expect(hasNestedPath) + + let hasLeafPath = filteredPaths.contains { path in + path.fullPathKey.contains("leaf") + } + #expect(!hasLeafPath) // Should be filtered out + } + + @Test + func handlesEmptyCommandStructure() throws { + let emptyCommand = self.createTestCommand(name: "empty") + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + + let paths = try promptingSystem.generateCommandPaths( + rootCommand: emptyCommand, + args: [], + branches: [] + ) + + #expect(paths.count == 1) + //#expect(paths.first?.fullPathKey == ["empty"]) + #expect(paths.first?.fullPathKey.isEmpty == true) + } + + @Test + func handlesTemplateWithNoArguments() throws { + // Test template with explicitly no arguments (nil arguments property) + let noArgsCommand = CommandInfoV0( + superCommands: [], + shouldDisplay: true, + commandName: "no-args-template", + abstract: "Template with no arguments", + discussion: "A template that requires no user input", + defaultSubcommand: nil, + subcommands: [], + arguments: [] + ) + + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + + let paths = try promptingSystem.generateCommandPaths( + rootCommand: noArgsCommand, + args: [], + branches: [] + ) + + #expect(paths.count == 1) + #expect(paths.first?.fullPathKey.isEmpty == true) + + // Should handle additional input gracefully by treating as leftover + let pathsWithExtra = try promptingSystem.generateCommandPaths( + rootCommand: noArgsCommand, + args: ["--some-flag", "extra-arg"], + branches: [] + ) + + #expect(pathsWithExtra.count == 1) + // Extra arguments should not appear in the path since no arguments are defined + #expect(pathsWithExtra.first?.fullPathKey.isEmpty == true) + } + + @Test + func handlesTemplateWithEmptyArgumentsArray() throws { + // Test template with empty arguments array (not nil, but empty) + let emptyArgsCommand = CommandInfoV0( + superCommands: [], + shouldDisplay: true, + commandName: "empty-args-template", + abstract: "Template with empty arguments array", + discussion: "A template with an empty arguments array", + defaultSubcommand: nil, + subcommands: [], + arguments: [] + ) + + let promptingSystem = TemplateTestPromptingSystem(hasTTY: false) + + let paths = try promptingSystem.generateCommandPaths( + rootCommand: emptyArgsCommand, + args: [], + branches: [] + ) + + #expect(paths.count == 1) + #expect(paths.first?.fullPathKey.isEmpty == true) + } + } + + @Suite( + .serialized, + .tags( + Tag.TestSize.large, + Tag.Feature.Command.Test, + ), + ) + struct TestTemplateCommandTests { + @Suite( + .serialized, + .tags( + Tag.TestSize.small, + Tag.Feature.Command.Test, + ), + ) + struct TemplateTestingDirectoryManagerTests { + @Test + func createOutputDirectory() throws { + let options = try GlobalOptions.parse([]) + + let fileSystem = InMemoryFileSystem() + try fileSystem.createDirectory(AbsolutePath("/tmp"), recursive: true) + + let tool = try SwiftCommandState.makeMockState(options: options, fileSystem: fileSystem) + + let tempDirectoryPath = AbsolutePath("/tmp/test") + try tool.fileSystem.createDirectory(tempDirectoryPath) + + let templateTestingDirectoryManager = TemplateTestingDirectoryManager( + fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope + ) + + let outputDirectory = tempDirectoryPath.appending(component: "foo") + + try templateTestingDirectoryManager.createOutputDirectory( + outputDirectoryPath: outputDirectory, + swiftCommandState: tool + ) + + #expect(try tool.fileSystem.isDirectory(outputDirectory)) + } + + @Test + func omitOutputDirectoryCreation() throws { + + let options = try GlobalOptions.parse([]) + let fileSystem = InMemoryFileSystem() + try fileSystem.createDirectory(AbsolutePath("/tmp"), recursive: true) + + let tool = try SwiftCommandState.makeMockState(options: options, fileSystem: fileSystem) + + let tempDirectory = AbsolutePath("/tmp/test") + try tool.fileSystem.createDirectory(tempDirectory) + + let templateTestingDirectoryManager = TemplateTestingDirectoryManager( + fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope + ) + + let outputDirectory = tempDirectory.appending(component: "foo") + try tool.fileSystem.createDirectory(outputDirectory) + + try templateTestingDirectoryManager.createOutputDirectory( + outputDirectoryPath: outputDirectory, + swiftCommandState: tool + ) + + // should not throw error if the directory exists + #expect(try tool.fileSystem.isDirectory(outputDirectory)) + } + + @Test + func ManifestFileExistsInOutputDirectory() throws { + + let fileSystem = InMemoryFileSystem() + let tmpDir = AbsolutePath("/tmp") + try fileSystem.createDirectory(tmpDir, recursive: true) + + let options = try GlobalOptions.parse(["--package-path", tmpDir.pathString]) + + let outputDirectory = tmpDir.appending(component: "foo") + + try fileSystem.createDirectory(outputDirectory) + fileSystem.createEmptyFiles(at: outputDirectory, files: "/Package.swift") + + let tool = try SwiftCommandState.makeMockState(options: options, fileSystem: fileSystem) + + let templateTestingDirectoryManager = TemplateTestingDirectoryManager( + fileSystem: tool.fileSystem, observabilityScope: tool.observabilityScope + ) + + #expect(throws: DirectoryManagerError.foundManifestFile(path: outputDirectory)) { + try templateTestingDirectoryManager.createOutputDirectory( + outputDirectoryPath: outputDirectory, + swiftCommandState: tool + ) + } + + } + } + + // to be tested + + /* + + test commandFragments prompting + + test dry Run + + + redirectStDoutandStDerr and deferral + + End2End for this, 1 where generation errror, 1 build errorr, one thats clean + */ + } + +} diff --git a/Tests/CommandsTests/TestCommandTests.swift b/Tests/CommandsTests/TestCommandTests.swift index 38961e48428..c2f27cbb14b 100644 --- a/Tests/CommandsTests/TestCommandTests.swift +++ b/Tests/CommandsTests/TestCommandTests.swift @@ -21,6 +21,35 @@ import _InternalTestSupport import TSCTestSupport import Testing +// to delete later +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 + + @Suite( .serialized, // to limit the number of swift executable running. .tags( @@ -1250,5 +1279,4 @@ struct TestCommandTests { ProcessInfo.hostOperatingSystem == .windows } } - } diff --git a/Tests/PackageLoadingTests/PD_Next_LoadingTests.swift b/Tests/PackageLoadingTests/PD_Next_LoadingTests.swift index d254a47112d..964f1b39a93 100644 --- a/Tests/PackageLoadingTests/PD_Next_LoadingTests.swift +++ b/Tests/PackageLoadingTests/PD_Next_LoadingTests.swift @@ -15,6 +15,11 @@ import PackageLoading import PackageModel import _InternalTestSupport import XCTest +import Basics +import PackageLoading +import PackageModel +import _InternalTestSupport +import Testing final class PackageDescriptionNextLoadingTests: PackageDescriptionLoadingTests { override var toolsVersion: ToolsVersion { @@ -39,4 +44,49 @@ final class PackageDescriptionNextLoadingTests: PackageDescriptionLoadingTests { } } } + + func testTemplate() async throws { + let content = """ + // swift-tools-version:999.0.0 + import PackageDescription + + let package = Package( + name: "SimpleTemplateExample", + products: .template(name: "ExecutableTemplate"), + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-system.git", from: "1.4.2"), + ], + targets: .template( + name: "ExecutableTemplate", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "SystemPackage", package: "swift-system"), + ], + + initialPackageType: .executable, + description: "This is a simple template that uses Swift string interpolation." + ) + ) + """ + let observability = ObservabilitySystem.makeForTesting() + + let (_, validationDiagnostics) = try await PackageDescriptionLoadingTests + .loadAndValidateManifest( + content, + toolsVersion: .vNext, + packageKind: .fileSystem(.root), + manifestLoader: ManifestLoader( + toolchain: try! UserToolchain.default + ), + observabilityScope: observability.topScope + ) + try expectDiagnostics(validationDiagnostics) { results in + results.checkIsEmpty() + } + try expectDiagnostics(observability.diagnostics) { results in + results.checkIsEmpty() + } + } } + 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") }