Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 274 additions & 0 deletions Sources/SkipBuild/Commands/Autoskip.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
23 changes: 22 additions & 1 deletion Sources/SkipBuild/Commands/ExportCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {

Expand Down Expand Up @@ -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
Expand Down
26 changes: 24 additions & 2 deletions Sources/SkipBuild/Commands/TestCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<ProcessOutput, Error>? = 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")
Expand Down
Loading