diff --git a/Sources/SkipBuild/Commands/Autoskip.swift b/Sources/SkipBuild/Commands/Autoskip.swift new file mode 100644 index 00000000..a9bddd58 --- /dev/null +++ b/Sources/SkipBuild/Commands/Autoskip.swift @@ -0,0 +1,274 @@ +// Copyright (c) 2023 - 2026 Skip +// Licensed under the GNU Affero General Public License v3.0 +// SPDX-License-Identifier: AGPL-3.0-only + +import Foundation +import ArgumentParser +import SkipSyntax + +/// The mode for autoskip: how the package should be transpiled/compiled for Android. +enum AutoskipMode: String, ExpressibleByArgument, CaseIterable { + /// Skip Lite: Swift is transpiled to Kotlin + case lite + /// Skip Fuse: Swift is compiled natively on Android + case fuse + /// Skip Fuse with kotlincompat bridging option + case fuseKotlincompat = "fuse-kotlincompat" +} + +/// Shared options for the --autoskip flag used by both `skip test` and `skip export`. +struct AutoskipOptions: ParsableArguments { + @Option(name: [.long], help: ArgumentHelp("Enable autoskip mode (lite, fuse, fuse-kotlincompat)", valueName: "mode")) + var autoskip: AutoskipMode? = nil + + @Flag(name: [.long], help: ArgumentHelp("Revert autoskip changes after completion")) + var autoskipRevert: Bool = false + + @Option(name: [.long], help: ArgumentHelp("Override the exported package name", valueName: "name")) + var autoskipPackage: String? = nil +} + +/// The environment variable that gates autoskip Package.swift additions. +let autoskipEnvironmentKey = "SKIP_ONE" + +/// Manages the temporary modification of a package to make it Skip-aware. +@available(macOS 13, iOS 16, tvOS 16, watchOS 8, *) +struct AutoskipContext { + let mode: AutoskipMode + let projectPath: String + let packageName: String? + let revert: Bool + + /// Files created by autoskip that should be cleaned up on revert. + private(set) var createdPaths: [String] = [] + /// Original Package.swift content for reverting. + private(set) var originalPackageSwift: String? + + /// The version to use for Skip package dependencies. + private var skipPackageVersion: String { + #if DEBUG + return "1.0.0" + #else + return skipVersion + #endif + } + + init(mode: AutoskipMode, projectPath: String, packageName: String?, revert: Bool) { + self.mode = mode + self.projectPath = projectPath + self.packageName = packageName + self.revert = revert + } + + /// Apply autoskip modifications to the package. + /// Returns the additional environment variables to set when running subprocesses. + mutating func apply(packageJSON: PackageManifest) throws -> [String: String] { + let packageSwiftPath = (projectPath as NSString).appendingPathComponent("Package.swift") + + // Save original for revert + originalPackageSwift = try String(contentsOfFile: packageSwiftPath, encoding: .utf8) + + // Get target info from the package manifest + let regularTargets = packageJSON.targets.compactMap(\.a).filter({ $0.type == "regular" }) + let testTargets = packageJSON.targets.compactMap(\.a).filter({ $0.type == "test" }) + + // Create Skip/skip.yml for each regular target + for target in regularTargets { + let targetSourceDir: String + if let path = target.path { + targetSourceDir = (projectPath as NSString).appendingPathComponent(path) + } else { + targetSourceDir = (projectPath as NSString).appendingPathComponent("Sources/\(target.name)") + } + + let skipDir = (targetSourceDir as NSString).appendingPathComponent("Skip") + let skipYmlPath = (skipDir as NSString).appendingPathComponent("skip.yml") + + // Only create if it doesn't already exist + if !FileManager.default.fileExists(atPath: skipYmlPath) { + try FileManager.default.createDirectory(atPath: skipDir, withIntermediateDirectories: true) + createdPaths.append(skipDir) + + var yml = """ + # Auto-generated by Skip autoskip — will be reverted + # Skip configuration for \(target.name) module + """ + + switch mode { + case .lite: + yml += """ + + skip: + mode: 'transpiled' + + """ + case .fuse: + yml += """ + + skip: + mode: 'native' + + """ + case .fuseKotlincompat: + yml += """ + + skip: + mode: 'native' + bridging: + enabled: true + options: 'kotlincompat' + + """ + } + + try yml.write(toFile: skipYmlPath, atomically: true, encoding: .utf8) + } + } + + // Create Skip/skip.yml and XCSkipTests.swift for each test target + for target in testTargets { + let targetSourceDir: String + if let path = target.path { + targetSourceDir = (projectPath as NSString).appendingPathComponent(path) + } else { + targetSourceDir = (projectPath as NSString).appendingPathComponent("Tests/\(target.name)") + } + + let skipDir = (targetSourceDir as NSString).appendingPathComponent("Skip") + let skipYmlPath = (skipDir as NSString).appendingPathComponent("skip.yml") + + if !FileManager.default.fileExists(atPath: skipYmlPath) { + try FileManager.default.createDirectory(atPath: skipDir, withIntermediateDirectories: true) + createdPaths.append(skipDir) + + let yml = """ + # Auto-generated by Skip autoskip — will be reverted + # Skip configuration for \(target.name) module + + """ + + try yml.write(toFile: skipYmlPath, atomically: true, encoding: .utf8) + } + + // Generate XCSkipTests.swift test harness if it doesn't exist + let xcSkipTestsPath = (targetSourceDir as NSString).appendingPathComponent("XCSkipTests.swift") + if !FileManager.default.fileExists(atPath: xcSkipTestsPath) { + let harness = """ + // Auto-generated by Skip autoskip — will be reverted + #if os(macOS) || os(Linux) + import Foundation + import XCTest + import SkipTest + + /// This test case will run the transpiled tests for the Skip module. + final class XCSkipTests: XCTestCase, XCGradleHarness { + public func testSkipModule() async throws { + try await runGradleTests() + } + } + #endif + + """ + try harness.write(toFile: xcSkipTestsPath, atomically: true, encoding: .utf8) + createdPaths.append(xcSkipTestsPath) + } + } + + // Generate the Package.swift appendix + let appendix = try generatePackageAppendix(regularTargets: regularTargets, testTargets: testTargets) + + // Append to Package.swift + let newPackageSwift = originalPackageSwift! + "\n" + appendix + try newPackageSwift.write(toFile: packageSwiftPath, atomically: true, encoding: .utf8) + + // Return environment with SKIP_ONE set + var env: [String: String] = [autoskipEnvironmentKey: "1"] + if mode == .fuseKotlincompat { + env["SKIP_BRIDGE"] = "1" + } + return env + } + + /// Revert all autoskip modifications. + func revertChanges() throws { + // Restore original Package.swift + if let original = originalPackageSwift { + let packageSwiftPath = (projectPath as NSString).appendingPathComponent("Package.swift") + try original.write(toFile: packageSwiftPath, atomically: true, encoding: .utf8) + } + + // Remove created Skip directories + for path in createdPaths { + try? FileManager.default.removeItem(atPath: path) + } + } + + /// Generate the code to append to Package.swift, guarded by SKIP_ONE environment variable. + private func generatePackageAppendix(regularTargets: [PackageManifest.Target], testTargets: [PackageManifest.Target]) throws -> String { + let isNative = mode == .fuse || mode == .fuseKotlincompat + + // Determine which framework dependency to add based on mode + let frameworkDep: String + let frameworkProduct: String + if isNative { + frameworkDep = ".package(url: \"https://source.skip.tools/skip-fuse.git\", from: \"\(skipPackageVersion)\")" + frameworkProduct = ".product(name: \"SkipFuse\", package: \"skip-fuse\")" + } else { + frameworkDep = ".package(url: \"https://source.skip.tools/skip-foundation.git\", from: \"\(skipPackageVersion)\")" + frameworkProduct = ".product(name: \"SkipFoundation\", package: \"skip-foundation\")" + } + + var code = """ + // MARK: - Skip Autoskip Configuration + // This block is auto-generated by `skip --autoskip` and adds Skip cross-platform support. + // It is gated by the SKIP_ONE environment variable and does not affect normal builds. + if Context.environment["\(autoskipEnvironmentKey)"] ?? "0" != "0" { + // Ensure minimum platform versions required by Skip + package.platforms = [.iOS(.v16), .macOS(.v13), .tvOS(.v16), .watchOS(.v9), .macCatalyst(.v16)] + + // Add Skip package dependencies + package.dependencies += [ + .package(url: "https://source.skip.tools/skip.git", from: "\(skipPackageVersion)"), + \(frameworkDep), + ] + + // Add skipstone plugin and Skip dependencies to all targets + for target in package.targets { + target.plugins = (target.plugins ?? []) + [.plugin(name: "skipstone", package: "skip")] + + if target.type == .regular { + target.dependencies += [\(frameworkProduct)] + } else if target.type == .test { + target.dependencies += [.product(name: "SkipTest", package: "skip")] + } + } + + """ + + if mode == .fuseKotlincompat { + code += """ + + // Enable bridging for kotlincompat mode + package.dependencies += [.package(url: "https://source.skip.tools/skip-bridge.git", from: "\(skipPackageVersion)")] + for target in package.targets { + if target.type == .regular { + target.dependencies += [.product(name: "SkipBridge", package: "skip-bridge")] + } + } + // All library types must be dynamic to support bridging + package.products = package.products.map({ product in + guard let libraryProduct = product as? Product.Library else { return product } + return .library(name: libraryProduct.name, type: .dynamic, targets: libraryProduct.targets) + }) + + """ + } + + code += """ + } + + """ + + return code + } +} diff --git a/Sources/SkipBuild/Commands/ExportCommand.swift b/Sources/SkipBuild/Commands/ExportCommand.swift index 9d30acea..7d4e855e 100644 --- a/Sources/SkipBuild/Commands/ExportCommand.swift +++ b/Sources/SkipBuild/Commands/ExportCommand.swift @@ -90,6 +90,9 @@ Build and export the Skip modules defined in the Package.swift, with libraries e @Option(help: ArgumentHelp("Destination architectures for native libraries", valueName: "arch")) var arch: [AndroidArchArgument] = [] + @OptionGroup(title: "Autoskip Options") + var autoskipOptions: AutoskipOptions + func performCommand(with out: MessageQueue) async { await withLogStream(with: out) { try await runExport(with: out) @@ -105,8 +108,25 @@ Build and export the Skip modules defined in the Package.swift, with libraries e throw error("must specify at least one of --release or --debug") } + // Set up autoskip if requested + var autoskipContext: AutoskipContext? = nil + var autoskipEnv: [String: String] = [:] + if let autoskipMode = autoskipOptions.autoskip { + let prePackageJSON = try await parseSwiftPackage(with: out, at: project) + var ctx = AutoskipContext(mode: autoskipMode, projectPath: project, packageName: autoskipOptions.autoskipPackage, revert: autoskipOptions.autoskipRevert) + autoskipEnv = try ctx.apply(packageJSON: prePackageJSON) + autoskipContext = ctx + } + + defer { + if autoskipOptions.autoskipRevert, let ctx = autoskipContext { + try? ctx.revertChanges() + } + } + + // Re-parse package after autoskip modifications (if any) so targets have plugins let packageJSON = try await parseSwiftPackage(with: out, at: project) - let packageName: String = self.package ?? packageJSON.name + let packageName: String = self.autoskipOptions.autoskipPackage ?? self.package ?? packageJSON.name if build == true { @@ -155,6 +175,7 @@ Build and export the Skip modules defined in the Package.swift, with libraries e let outputFolderAbsolute = try AbsolutePath(validating: outputFolder, relativeTo: fs.currentWorkingDirectory!) var env = ProcessInfo.processInfo.environmentWithDefaultToolPaths // environment that includes a default ANDROID_HOME + env.merge(autoskipEnv, uniquingKeysWith: { _, new in new }) if !arch.isEmpty { // take the arch flag(s) and set them in the `SKIP_EXPORT_ARCHS` environment, which will be processed by the AndroidCommand when it sees the SkipBridge `--arch automatic` setting diff --git a/Sources/SkipBuild/Commands/TestCommand.swift b/Sources/SkipBuild/Commands/TestCommand.swift index 6cccdc4e..db85cc22 100644 --- a/Sources/SkipBuild/Commands/TestCommand.swift +++ b/Sources/SkipBuild/Commands/TestCommand.swift @@ -56,6 +56,9 @@ struct TestCommand: SkipCommand, StreamingCommand, ToolOptionsCommand { @Option(name: [.long], help: ArgumentHelp("Output summary table", valueName: "path")) var summaryFile: String? + @OptionGroup(title: "Autoskip Options") + var autoskipOptions: AutoskipOptions + func performCommand(with out: MessageQueue) async { await withLogStream(with: out) { try await runTestCommand(with: out) @@ -100,13 +103,32 @@ extension TestCommand { return } + // Set up autoskip if requested + var autoskipContext: AutoskipContext? = nil + var additionalEnv: [String: String] = [:] + if let autoskipMode = autoskipOptions.autoskip { + let packageJSON = try await parseSwiftPackage(with: out, at: project) + var ctx = AutoskipContext(mode: autoskipMode, projectPath: project, packageName: autoskipOptions.autoskipPackage, revert: autoskipOptions.autoskipRevert) + additionalEnv = try ctx.apply(packageJSON: packageJSON) + autoskipContext = ctx + } + + defer { + if autoskipOptions.autoskipRevert, let ctx = autoskipContext { + try? ctx.revertChanges() + } + } + let xunit = xunit ?? ".build/xcunit-\(UUID().uuidString).xml" - try await run(with: out, "Build Project", ["swift", "build", "--build-tests", "--verbose", "--configuration", configuration, "--package-path", project]) + var env = ProcessInfo.processInfo.environmentWithDefaultToolPaths + env.merge(additionalEnv, uniquingKeysWith: { _, new in new }) + + try await run(with: out, "Build Project", ["swift", "build", "--build-tests", "--verbose", "--configuration", configuration, "--package-path", project], environment: env) var testResult: Result? = nil if test == true { - testResult = try await run(with: out, "Test project", ["swift", "test", "--parallel", "-c", configuration, "--enable-code-coverage", "--xunit-output", xunit, "--package-path", project]) + testResult = try await run(with: out, "Test project", ["swift", "test", "--parallel", "-c", configuration, "--enable-code-coverage", "--xunit-output", xunit, "--package-path", project], environment: env) } else if self.xunit == nil { // we can only use the generated xunit if we are running the tests throw SkipDriveError(errorDescription: "Must either specify --xunit path or run tests with --test")