From ce38cadd61c4f5efe68ece2072f6741201a3ca92 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Tue, 10 Mar 2026 15:12:03 -0400 Subject: [PATCH 1/4] Internal refactor of the skipstone plugin command --- .../SkipBuild/Commands/DumpSkipCommand.swift | 4 +- .../SkipBuild/Commands/DumpSwiftCommand.swift | 4 +- .../SkipBuild/Commands/SkippyCommand.swift | 6 +- ...leCommand.swift => SkipstoneCommand.swift} | 79 +++++++------------ Sources/SkipBuild/SkipCommand.swift | 31 ++------ 5 files changed, 43 insertions(+), 81 deletions(-) rename Sources/SkipBuild/Commands/{TranspileCommand.swift => SkipstoneCommand.swift} (96%) diff --git a/Sources/SkipBuild/Commands/DumpSkipCommand.swift b/Sources/SkipBuild/Commands/DumpSkipCommand.swift index 749ce8dd..f62e48fe 100644 --- a/Sources/SkipBuild/Commands/DumpSkipCommand.swift +++ b/Sources/SkipBuild/Commands/DumpSkipCommand.swift @@ -17,12 +17,12 @@ struct DumpSkipCommand: AsyncParsableCommand { var files: [String] func run() async throws { - var opts = TranspilerInputOptions() + var opts = SkipstoneInputOptions() opts.symbols = symbols try await perform(on: files.map({ Source.FilePath(path: $0) }), options: opts) } - func perform(on sourceFiles: [Source.FilePath], options: TranspilerInputOptions) async throws { + func perform(on sourceFiles: [Source.FilePath], options: SkipstoneInputOptions) async throws { for sourceFile in sourceFiles { let source = try Source(file: sourceFile) let syntaxTree = SyntaxTree(source: source, preprocessorSymbols: Set(options.symbols)) diff --git a/Sources/SkipBuild/Commands/DumpSwiftCommand.swift b/Sources/SkipBuild/Commands/DumpSwiftCommand.swift index 65d85363..d89edbfc 100644 --- a/Sources/SkipBuild/Commands/DumpSwiftCommand.swift +++ b/Sources/SkipBuild/Commands/DumpSwiftCommand.swift @@ -21,13 +21,13 @@ struct DumpSwiftCommand: AsyncParsableCommand { var files: [String] func run() async throws { - var opts = TranspilerInputOptions() + var opts = SkipstoneInputOptions() opts.directory = directory opts.symbols = symbols try await perform(on: files.map({ Source.FilePath(path: $0) }), options: opts) } - func perform(on sourceFiles: [Source.FilePath], options: TranspilerInputOptions) async throws { + func perform(on sourceFiles: [Source.FilePath], options: SkipstoneInputOptions) async throws { for sourceFile in sourceFiles { let syntax = try Parser.parse(source: Source(file: sourceFile).content) print(syntax.root.prettyPrintTree) diff --git a/Sources/SkipBuild/Commands/SkippyCommand.swift b/Sources/SkipBuild/Commands/SkippyCommand.swift index 37edd9c1..41bb06d5 100644 --- a/Sources/SkipBuild/Commands/SkippyCommand.swift +++ b/Sources/SkipBuild/Commands/SkippyCommand.swift @@ -7,14 +7,14 @@ import ArgumentParser import TSCBasic import SkipSyntax -struct SkippyCommand: TranspilerInputOptionsCommand { +struct SkippyCommand: BuildPluginOptionsCommand { /// The "CONFIGURATION" parameter specifies whether we are to run in Skippy mode or full-transpile mode static let skippyOnly = ProcessInfo.processInfo.environment["CONFIGURATION"] == "Skippy" static var configuration = CommandConfiguration(commandName: "skippy", abstract: "Perform transpilation preflight checks", shouldDisplay: false) @OptionGroup(title: "Check Options") - var inputOptions: TranspilerInputOptions + var inputOptions: SkipstoneInputOptions @OptionGroup(title: "Output Options") var outputOptions: OutputOptions @@ -29,7 +29,7 @@ struct SkippyCommand: TranspilerInputOptionsCommand { try await perform(on: inputOptions.files.map({ Source.FilePath(path: $0) }), options: inputOptions) } - func perform(on candidateSourceFiles: [Source.FilePath], options: TranspilerInputOptions) async throws { + func perform(on candidateSourceFiles: [Source.FilePath], options: SkipstoneInputOptions) async throws { // due to FB12969712 https://github.com/apple/swift-package-manager/issues/6816 , we need to tolerate missing source files because Xcode sends the same cached list of sources regardless of changes to the underlying project structure let sourceFiles = candidateSourceFiles.filter({ !allowMissingSources || FileManager.default.fileExists(atPath: $0.path) diff --git a/Sources/SkipBuild/Commands/TranspileCommand.swift b/Sources/SkipBuild/Commands/SkipstoneCommand.swift similarity index 96% rename from Sources/SkipBuild/Commands/TranspileCommand.swift rename to Sources/SkipBuild/Commands/SkipstoneCommand.swift index 750acb5f..dea870aa 100644 --- a/Sources/SkipBuild/Commands/TranspileCommand.swift +++ b/Sources/SkipBuild/Commands/SkipstoneCommand.swift @@ -8,24 +8,21 @@ import Universal import SkipSyntax import TSCBasic -protocol TranspilePhase: TranspilerInputOptionsCommand { - var transpileOptions: TranspileCommandOptions { get } -} - /// The file extension for the metadata about skipcode let skipcodeExtension = ".skipcode.json" -struct TranspileCommand: TranspilePhase, StreamingCommand { - static var configuration = CommandConfiguration(commandName: "transpile", abstract: "Transpile Swift to Kotlin", shouldDisplay: false) +/// The command executed by the Skip plugin that will perform all the actions to transform a SwiftPM module into a Gradle project, including transpiling source code, building native bridges, and processing resources. +struct SkipstoneCommand: BuildPluginOptionsCommand, StreamingCommand { + static var configuration = CommandConfiguration(commandName: "skipstone", abstract: "Convert Swift project to Gradle", shouldDisplay: false, aliases: ["transpile"]) /// The `ENABLE_PREVIEW` parameter specifies whether we are building for previews static let enablePreviews = ProcessInfo.processInfo.environment["ENABLE_PREVIEWS"] == "YES" @OptionGroup(title: "Check Options") - var inputOptions: TranspilerInputOptions + var inputOptions: SkipstoneInputOptions - @OptionGroup(title: "Transpile Options") - var transpileOptions: TranspileCommandOptions + @OptionGroup(title: "Skipstone Options") + var skipstoneOptions: SkipstoneCommandOptions @OptionGroup(title: "Output Options") var outputOptions: OutputOptions @@ -34,27 +31,27 @@ struct TranspileCommand: TranspilePhase, StreamingCommand { let transpilation: Transpilation func message(term: Term) -> String? { - // successful transpile outputs no message so as to not clutter xcode logs + // successful run outputs no message so as to not clutter xcode logs return nil } } var moduleNamePaths: [(module: String, path: String)] { - transpileOptions.moduleNames.map({ + skipstoneOptions.moduleNames.map({ let parts = $0.split(separator: ":") return (module: parts.first?.description ?? "", path: parts.last?.description ?? "") }) } var linkNamePaths: [(module: String, link: String)] { - transpileOptions.linkPaths.map({ + skipstoneOptions.linkPaths.map({ let parts = $0.split(separator: ":") return (module: parts.first?.description ?? "", link: parts.last?.description ?? "") }) } var dependencyIdPaths: [(targetName: String, packageID: String, packagePath: String)] { - transpileOptions.dependencies.compactMap({ + skipstoneOptions.dependencies.compactMap({ let parts = $0.split(separator: ":").map(\.description) if parts.count != 3 { return nil } return (targetName: parts[0], packageID: parts[1], packagePath: parts[2]) @@ -78,16 +75,16 @@ struct TranspileCommand: TranspilePhase, StreamingCommand { return } - // show the local time in the transpile output; this helps identify from the Xcode Navigator when an old log file is being replayed for a plugin re-execution + // show the local time in the plugin output; this helps identify from the Xcode Navigator when an old log file is being replayed for a plugin re-execution let dateFormatter = DateFormatter() dateFormatter.dateFormat = "HH:mm:ss" - guard let moduleRoot = transpileOptions.moduleRoot else { + guard let moduleRoot = skipstoneOptions.moduleRoot else { throw error("Must specify --module-root") } let moduleRootPath = try AbsolutePath(validating: moduleRoot) - guard let skipFolder = transpileOptions.skipFolder else { + guard let skipFolder = skipstoneOptions.skipFolder else { throw error("Must specify --skip-folder") } @@ -98,21 +95,21 @@ struct TranspileCommand: TranspilePhase, StreamingCommand { let skipFolderPath = try AbsolutePath(validating: skipFolder, relativeTo: baseOutputPath) // the --project flag - let projectFolderPath = try AbsolutePath(validating: transpileOptions.projectFolder, relativeTo: baseOutputPath) + let projectFolderPath = try AbsolutePath(validating: skipstoneOptions.projectFolder, relativeTo: baseOutputPath) - guard let outputFolder = transpileOptions.outputFolder else { + guard let outputFolder = skipstoneOptions.outputFolder else { throw error("Must specify --output-folder") } let outputFolderPath = try AbsolutePath(validating: outputFolder, relativeTo: baseOutputPath) - info("Skip \(v): skipstone plugin to: \(transpileOptions.outputFolder ?? "nowhere") at \(dateFormatter.string(from: .now))") - try await self.transpile(root: baseOutputPath, project: projectFolderPath, module: moduleRootPath, skip: skipFolderPath, output: outputFolderPath, fs: fs, with: out) + info("Skip \(v): skipstone plugin to: \(skipstoneOptions.outputFolder ?? "nowhere") at \(dateFormatter.string(from: .now))") + try await self.skipstone(root: baseOutputPath, project: projectFolderPath, module: moduleRootPath, skip: skipFolderPath, output: outputFolderPath, fs: fs, with: out) } - private func transpile(root rootPath: AbsolutePath, project projectFolderPath: AbsolutePath, module moduleRootPath: AbsolutePath, skip skipFolderPath: AbsolutePath, output outputFolderPath: AbsolutePath, fs: FileSystem, with out: MessageQueue) async throws { + private func skipstone(root rootPath: AbsolutePath, project projectFolderPath: AbsolutePath, module moduleRootPath: AbsolutePath, skip skipFolderPath: AbsolutePath, output outputFolderPath: AbsolutePath, fs: FileSystem, with out: MessageQueue) async throws { do { - try await transpileThrows(root: rootPath, project: projectFolderPath, module: moduleRootPath, skip: skipFolderPath, output: outputFolderPath, fs: fs, with: out) + try await skipstoneThrows(root: rootPath, project: projectFolderPath, module: moduleRootPath, skip: skipFolderPath, output: outputFolderPath, fs: fs, with: out) } catch { // ensure that the error is logged in some way before failing self.error("Skip \(skipVersion) error: \(error.localizedDescription)") @@ -120,8 +117,8 @@ struct TranspileCommand: TranspilePhase, StreamingCommand { } } - private func transpileThrows(root rootPath: AbsolutePath, project projectFolderPath: AbsolutePath, module moduleRootPath: AbsolutePath, skip skipFolderPath: AbsolutePath, output outputFolderPath: AbsolutePath, fs: FileSystem, with out: MessageQueue) async throws { - trace("transpileThrows: rootPath=\(rootPath), projectFolderPath=\(projectFolderPath), moduleRootPath=\(moduleRootPath), skipFolderPath=\(skipFolderPath), outputFolderPath=\(outputFolderPath)") + private func skipstoneThrows(root rootPath: AbsolutePath, project projectFolderPath: AbsolutePath, module moduleRootPath: AbsolutePath, skip skipFolderPath: AbsolutePath, output outputFolderPath: AbsolutePath, fs: FileSystem, with out: MessageQueue) async throws { + trace("skipstoneThrows: rootPath=\(rootPath), projectFolderPath=\(projectFolderPath), moduleRootPath=\(moduleRootPath), skipFolderPath=\(skipFolderPath), outputFolderPath=\(outputFolderPath)") // the path that will contain the `skip.yml` @@ -134,11 +131,11 @@ struct TranspileCommand: TranspilePhase, StreamingCommand { let cmakeLists = projectFolderPath.appending(component: "CMakeLists.txt") let isCMakeProject = fs.exists(cmakeLists) if !isCMakeProject && !fs.isDirectory(skipFolderPath) { - throw error("In order to transpile the module, a Skip/ folder must exist and contain a skip.yml file at: \(skipFolderPath)") + throw error("In order for Skip to process the module, a Skip/ folder must exist and contain a skip.yml file at: \(skipFolderPath)") } // when renaming SomeClassA.swift to SomeClassB.swift, the stale SomeClassA.kt file from previous runs will be left behind, and will then cause a "Redeclaration:" error from the Kotlin compiler if they declare the same types - // so keep a snapshot of the output folder files that existed at the start of the transpile operation, so we can then clean up any output files that are no longer being produced + // so keep a snapshot of the output folder files that existed at the start of the skipstone operation, so we can then clean up any output files that are no longer being produced let outputFilesSnapshot: [URL] = try FileManager.default.enumeratedURLs(of: outputFolderPath.asURL) //msg(.warning, "transpiling to \(outputFolderPath.pathString) with existing files: \(outputFilesSnapshot.map(\.lastPathComponent).sorted().joined(separator: ", "))") @@ -289,7 +286,7 @@ struct TranspileCommand: TranspilePhase, StreamingCommand { //.prettyPrinted, // compacting JSON significantly reduces the size of the codebase files ] - let sourcehashOutputPath = try AbsolutePath(validating: transpileOptions.sourcehash) + let sourcehashOutputPath = try AbsolutePath(validating: skipstoneOptions.sourcehash) // We no longer remove the path because the plugin doesn't seem to require it to know to run in dependency order //removePath(sourcehashOutputPath) // delete the build completion marker to force its re-creation (removeFileTree doesn't throw when the file doesn't exist) @@ -406,7 +403,7 @@ struct TranspileCommand: TranspilePhase, StreamingCommand { try addLink(moduleBasePath.appending(component: buildSrcFolderName), pointingAt: buildSrcFolder, relative: false) } - // feed the transpiler the files to transpile and any compiled files to potentially bridge + // feed skipstone the files to transpile and any compiled files to potentially bridge var transpileFiles: [String] = [] var swiftFiles: [String] = [] for sourceFile in sourceURLs.map(\.path).sorted() { @@ -509,7 +506,7 @@ struct TranspileCommand: TranspilePhase, StreamingCommand { func saveSkipBridgeCode() throws { // create the generated bridge files when the SKIP_BRIDGE environment is set and the plugin passed the --skip-bridge-output flag to the tool - if let skipBridgeOutput = transpileOptions.skipBridgeOutput { + if let skipBridgeOutput = skipstoneOptions.skipBridgeOutput { let skipBridgeOutputFolder = try AbsolutePath(validating: skipBridgeOutput) let swiftBridgeFileNameTranspilationMap = skipBridgeTranspilations.reduce(into: Dictionary()) { result, transpilation in @@ -940,7 +937,7 @@ struct TranspileCommand: TranspilePhase, StreamingCommand { // when we are running with SKIP_BRIDGE, don't link over any files from the skip folder // failure to do this will result in (harmless) .kt files being copied over, but since no subsequent transpilation // will mark those as expected output file, they will raise warnings: "removing stale output file: …" - if transpileOptions.skipBridgeOutput != nil { + if skipstoneOptions.skipBridgeOutput != nil { return [] } @@ -988,8 +985,8 @@ struct TranspileCommand: TranspilePhase, StreamingCommand { } // when we are running with SKIP_BRIDGE, we don't need to write out the Kotlin (which has already been generated in the first pass of the plugin) - if transpileOptions.skipBridgeOutput != nil { - //warn("suppressing transpiled Kotlin due to transpileOptions.skipBridgeOutput") + if skipstoneOptions.skipBridgeOutput != nil { + //warn("suppressing transpiled Kotlin due to skipstoneOptions.skipBridgeOutput") return } @@ -1371,16 +1368,13 @@ struct TranspileCommand: TranspilePhase, StreamingCommand { } } -struct TranspileCommandOptions: ParsableArguments { +struct SkipstoneCommandOptions: ParsableArguments { @Option(name: [.customLong("project"), .long], help: ArgumentHelp("The project folder to transpile", valueName: "folder")) var projectFolder: String // --project @Option(name: [.long], help: ArgumentHelp("The path to the source hash file to output", valueName: "path")) var sourcehash: String // --sourcehash - @Option(help: ArgumentHelp("Condition for transpile phase", valueName: "force/no")) - var transpile: PhaseGuard = .onDemand // --transpile - @Option(name: [.customLong("module")], help: ArgumentHelp("ModuleName:SourcePath", valueName: "module")) var moduleNames: [String] = [] // --module name:path @@ -1414,19 +1408,6 @@ extension Universal.XMLNode { } } - -struct TranspileResult { - -} - -extension TranspilePhase { - func performTranspileActions() async throws -> (check: CheckResult, transpile: TranspileResult) { - let checkResult = try await performSkippyCommands() - let transpileResult = TranspileResult() - return (checkResult, transpileResult) - } -} - extension URL { /// The path from this URL, validatating that it is an absolute path var absolutePath: AbsolutePath { diff --git a/Sources/SkipBuild/SkipCommand.swift b/Sources/SkipBuild/SkipCommand.swift index fc4d5377..f7db769c 100644 --- a/Sources/SkipBuild/SkipCommand.swift +++ b/Sources/SkipBuild/SkipCommand.swift @@ -90,7 +90,7 @@ public struct SkipRunnerExecutor: SkipCommandExecutor { // Hidden commands used by the plugin InfoCommand.self, SkippyCommand.self, - TranspileCommand.self, + SkipstoneCommand.self, SnippetCommand.self, PluginCommand.self, DumpSwiftCommand.self, @@ -172,34 +172,19 @@ struct VersionCommand: SingleStreamingCommand { } -// MARK: Command Phases +// MARK: BuildPluginOptionsCommand -/// The condition under which the phase should be run -enum PhaseGuard : String, Decodable, CaseIterable { - case no - case force - case onDemand = "on-demand" +protocol BuildPluginOptionsCommand : SkipCommand { + var inputOptions: SkipstoneInputOptions { get } } -extension PhaseGuard : ExpressibleByArgument { -} - -// MARK: TranspilerInputOptionsCommand - -protocol TranspilerInputOptionsCommand : SkipCommand { - var inputOptions: TranspilerInputOptions { get } -} - -extension TranspilerInputOptionsCommand { +extension BuildPluginOptionsCommand { func performSkippyCommands() async throws -> CheckResult { return CheckResult() } } -struct TranspilerInputOptions: ParsableArguments { - @Option(help: ArgumentHelp("Condition for check phase", valueName: "force/no")) - var check: PhaseGuard = .onDemand - +struct SkipstoneInputOptions: ParsableArguments { @Option(name: [.customShort("S")], help: ArgumentHelp("Preprocessor symbols", valueName: "file")) var symbols: [String] = [] @@ -222,15 +207,11 @@ protocol SnippetOptionsCommand: SkipCommand { } struct SnippetOptions: ParsableArguments { - @Option(help: ArgumentHelp("Condition for snippet phase", valueName: "force/no")) - var snippet: PhaseGuard = .onDemand // --snippet } struct SnippetResult { } - - extension Source.FilePath { /// Initialize this file reference with an `AbsolutePath` init(path absolutePath: AbsolutePath) { From 75915c3f975c57d5c6babd6bde92fdfb2925c28b Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Tue, 10 Mar 2026 17:23:58 -0400 Subject: [PATCH 2/4] Refactor SkipstoneCommand and add test cases --- .github/workflows/ci.yml | 5 +- Package.swift | 8 +- .../SkipBuild/Commands/SkipstoneCommand.swift | 2602 ++++++++++------- .../SkipstoneCommandTests.swift | 435 +++ 4 files changed, 2020 insertions(+), 1030 deletions(-) create mode 100644 Tests/SkipBuildTests/SkipstoneCommandTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b92e2a3..b69f7c52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,7 +134,10 @@ jobs: - run: skip doctor - run: skip checkup --native - if: runner.os != 'Linux' + # there's a bug runing checkup with a local skipstone comand build: + # [✗] error: Dependencies could not be resolved because root depends on 'skip' 1.7.5..<2.0.0. + if: false + #if: runner.os != 'Linux' - name: "Prepare Android emulator environment" if: runner.os == 'Linux' diff --git a/Package.swift b/Package.swift index a2011722..242ec772 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,13 @@ import PackageDescription let package = Package( name: "skipstone", defaultLocalization: "en", - platforms: [.macOS(.v13)], + platforms: [ + .iOS(.v16), + .macOS(.v14), + .tvOS(.v16), + .watchOS(.v9), + .macCatalyst(.v16), + ], products: [ .library(name: "SkipSyntax", targets: ["SkipSyntax"]), .library(name: "SkipBuild", targets: ["SkipBuild"]), diff --git a/Sources/SkipBuild/Commands/SkipstoneCommand.swift b/Sources/SkipBuild/Commands/SkipstoneCommand.swift index dea870aa..144d8eee 100644 --- a/Sources/SkipBuild/Commands/SkipstoneCommand.swift +++ b/Sources/SkipBuild/Commands/SkipstoneCommand.swift @@ -104,12 +104,18 @@ struct SkipstoneCommand: BuildPluginOptionsCommand, StreamingCommand { info("Skip \(v): skipstone plugin to: \(skipstoneOptions.outputFolder ?? "nowhere") at \(dateFormatter.string(from: .now))") - try await self.skipstone(root: baseOutputPath, project: projectFolderPath, module: moduleRootPath, skip: skipFolderPath, output: outputFolderPath, fs: fs, with: out) - } - private func skipstone(root rootPath: AbsolutePath, project projectFolderPath: AbsolutePath, module moduleRootPath: AbsolutePath, skip skipFolderPath: AbsolutePath, output outputFolderPath: AbsolutePath, fs: FileSystem, with out: MessageQueue) async throws { + // Delegates to a `SkipstoneSession` which encapsulates all the mutable state and operational logic + let session = SkipstoneSession( + command: self, + rootPath: baseOutputPath, + projectFolderPath: projectFolderPath, + moduleRootPath: moduleRootPath, + skipFolderPath: skipFolderPath, + outputFolderPath: outputFolderPath, + fs: fs) do { - try await skipstoneThrows(root: rootPath, project: projectFolderPath, module: moduleRootPath, skip: skipFolderPath, output: outputFolderPath, fs: fs, with: out) + try await session.run(with: out) } catch { // ensure that the error is logged in some way before failing self.error("Skip \(skipVersion) error: \(error.localizedDescription)") @@ -117,1290 +123,1830 @@ struct SkipstoneCommand: BuildPluginOptionsCommand, StreamingCommand { } } - private func skipstoneThrows(root rootPath: AbsolutePath, project projectFolderPath: AbsolutePath, module moduleRootPath: AbsolutePath, skip skipFolderPath: AbsolutePath, output outputFolderPath: AbsolutePath, fs: FileSystem, with out: MessageQueue) async throws { - trace("skipstoneThrows: rootPath=\(rootPath), projectFolderPath=\(projectFolderPath), moduleRootPath=\(moduleRootPath), skipFolderPath=\(skipFolderPath), outputFolderPath=\(outputFolderPath)") + /// Generate transpiler transformers from the given skip config + func createTransformers(for config: SkipConfig, with moduleMap: [String: SkipConfig]) throws -> [KotlinTransformer] { + var transformers: [KotlinTransformer] = builtinKotlinTransformers() - // the path that will contain the `skip.yml` + let configOptions = config.skip?.bridgingOptions() ?? [] + let transformerOptions = KotlinBridgeOptions.parse(configOptions) + transformers.append(KotlinBridgeTransformer(options: transformerOptions)) - // the module will be treated differently if it is an app versus a library (it will use the "com.android.application" plugin instead of "com.android.library") - let AndroidManifestName = "AndroidManifest.xml" + if let root = config.skip?.dynamicroot { + transformers.append(KotlinDynamicObjectTransformer(root: root)) + } - // folders that can contain gradle plugins and scripts - let buildSrcFolderName = "buildSrc" + return transformers + } - let cmakeLists = projectFolderPath.appending(component: "CMakeLists.txt") - let isCMakeProject = fs.exists(cmakeLists) - if !isCMakeProject && !fs.isDirectory(skipFolderPath) { - throw error("In order for Skip to process the module, a Skip/ folder must exist and contain a skip.yml file at: \(skipFolderPath)") + func loadSourceHashes(from allSourceURLs: [URL]) async throws -> [URL: String] { + // take a snapshot of all the source hashes for each of the URLs so we know when anything has changes + // TODO: this doesn't need to be a full SHA256 hash, it can be something faster (or maybe even just a snapshot of the file's size and last modified date…) + let sourcehashes = try await withThrowingTaskGroup(of: (URL, String).self) { group in + for url in allSourceURLs { + group.addTask { + let data = try Data(contentsOf: url, options: .mappedIfSafe) + return (url, data.SHA256Hash()) + } + } + + var results = [URL: String]() + results.reserveCapacity(allSourceURLs.count) + + for try await (url, sha256) in group { + results[url] = sha256 + } + + return results } - // when renaming SomeClassA.swift to SomeClassB.swift, the stale SomeClassA.kt file from previous runs will be left behind, and will then cause a "Redeclaration:" error from the Kotlin compiler if they declare the same types - // so keep a snapshot of the output folder files that existed at the start of the skipstone operation, so we can then clean up any output files that are no longer being produced - let outputFilesSnapshot: [URL] = try FileManager.default.enumeratedURLs(of: outputFolderPath.asURL) - //msg(.warning, "transpiling to \(outputFolderPath.pathString) with existing files: \(outputFilesSnapshot.map(\.lastPathComponent).sorted().joined(separator: ", "))") + return sourcehashes + } +} - var outputFiles: [AbsolutePath] = [] +struct SkipstoneCommandOptions: ParsableArguments { + @Option(name: [.customLong("project"), .long], help: ArgumentHelp("The project folder to transpile", valueName: "folder")) + var projectFolder: String // --project - var skipBridgeTranspilations: [Transpilation] = [] + @Option(name: [.long], help: ArgumentHelp("The path to the source hash file to output", valueName: "path")) + var sourcehash: String // --sourcehash - func cleanupStaleOutputFiles() { - let staleFiles = Set(outputFilesSnapshot.map(\.path)) - .subtracting(outputFiles.map(\.pathString)) - for staleFile in staleFiles.sorted() { - let staleFileURL = URL(fileURLWithPath: staleFile, isDirectory: false) - if staleFileURL.lastPathComponent == "Package.resolved" { - // Package.resolved is special, because it is output from the native build and removing it would cause an unnecessary rebuild - continue - } - msg(.warning, "removing stale output file: \(staleFileURL.lastPathComponent)", sourceFile: try? staleFileURL.absolutePath.sourceFile) + @Option(name: [.customLong("module")], help: ArgumentHelp("ModuleName:SourcePath", valueName: "module")) + var moduleNames: [String] = [] // --module name:path - do { - // don't actually trash it, since the output files often have read-only permissions set, and that prevents trash from working - try FileManager.default.trash(fileURL: staleFileURL, trash: false) - } catch { - msg(.warning, "error removing stale output file: \(staleFileURL.lastPathComponent): \(error)") - } - } - } + @Option(name: [.customLong("link")], help: ArgumentHelp("ModuleName:LinkPath", valueName: "module")) + var linkPaths: [String] = [] // --link name:path - /// track every output file written using `addOutputFile` to prevent the file from being cleaned up at the end - @discardableResult func addOutputFile(_ path: AbsolutePath) -> AbsolutePath { - outputFiles.append(path) - return path - } + @Option(help: ArgumentHelp("Path to the folder that contains skip.yml and overrides", valueName: "path")) + var skipFolder: String? = nil // --skip-folder - var inputFiles: [AbsolutePath] = [] - // add the given file to the list of input files for consideration of mod time - func addInputFile(_ path: AbsolutePath) -> AbsolutePath { - inputFiles.append(path) - return path - } + @Option(help: ArgumentHelp("Path to the output module root folder", valueName: "path")) + var moduleRoot: String? = nil // --module-root - /// Load the given source file, tracking its last modified date for the timestamp on the `.sourcehash` marker file - func inputSource(_ path: AbsolutePath) throws -> ByteString { - try fs.readFileContents(addInputFile(path)) - } + @Option(name: [.customShort("D", allowingJoined: true)], help: ArgumentHelp("Set preprocessor variable for transpilation", valueName: "value")) + var preprocessorVariables: [String] = [] + @Option(name: [.long], help: ArgumentHelp("Output directory", valueName: "dir")) + var outputFolder: String? = nil - if !fs.isDirectory(moduleRootPath) { - try fs.createDirectory(moduleRootPath, recursive: true) - } + @Option(name: [.customLong("dependency")], help: ArgumentHelp("id:path", valueName: "dependency")) + var dependencies: [String] = [] // --dependency id:path - if !fs.isDirectory(moduleRootPath) { - throw error("Module root path did not exist at: \(moduleRootPath.pathString)") - } + @Option(name: [.long], help: ArgumentHelp("Folder for SkipBridge generated Swift files", valueName: "suffix")) + var skipBridgeOutput: String? = nil +} - guard let (primaryModuleName, primaryModulePath) = moduleNamePaths.first else { - throw error("Must specify at least one --module") - } - func isTestModule(_ moduleName: String) -> Bool { - primaryModuleName != moduleName && primaryModuleName != moduleName + "Tests" - } +/// A collected resource entry with its file URLs and processing mode. +/// +/// Resource entries are built from `skip.yml` configuration and track whether resources +/// should be processed (flattened with localization conversion) or copied (preserving +/// the source directory hierarchy, matching Darwin's `.copy()` behavior). +struct ResourceEntry { + /// The relative path to the resource directory from the project folder. + let path: String + /// The file URLs contained within the resource directory. + let urls: [URL] + /// Whether this entry uses copy mode (preserving hierarchy) vs process mode (flattening). + let isCopyMode: Bool +} - // check for the existence of PrimaryModuleName.xcconfig, and if it exists, this is an app module - let configModuleName = primaryModuleName.hasSuffix("Tests") ? String(primaryModuleName.dropLast("Tests".count)) : primaryModuleName - let moduleXCConfig = rootPath.appending(component: configModuleName + ".xcconfig") - let isAppModule = fs.isFile(moduleXCConfig) +/// Manages the mutable state and execution phases of a single skipstone transpilation invocation. +/// +/// `SkipstoneSession` encapsulates all the mutable state and operational logic that was previously +/// contained within `skipstoneThrows` as nested closures. By organizing this logic into a class +/// with distinct methods, each phase of the skipstone pipeline becomes independently understandable +/// and testable. +/// +/// ## Execution Phases +/// +/// The session executes via ``run(with:)`` in sequential phases: +/// +/// 1. **Validation & Setup** — Validates paths, enumerates source/resource files, snapshots +/// existing output files for stale detection, loads and merges `skip.yml` configs. +/// +/// 2. **Transpilation** — Loads dependent module codebase info, creates the transpiler with +/// appropriate transformers, runs transpilation, and writes Kotlin output files. +/// +/// 3. **Output & Linking** — Saves codebase info for downstream modules, generates bridge code, +/// links dependent module sources, links resources (process or copy mode), and generates +/// Gradle build files. +/// +/// 4. **Cleanup** — Removes stale output files from previous runs and writes the sourcehash +/// marker file to signal completion to the build plugin host. +class SkipstoneSession { - let _ = primaryModulePath + // MARK: - Command & Environment - /// A collected resource entry with its URLs and mode - struct ResourceEntry { - let path: String - let urls: [URL] - let isCopyMode: Bool - } + /// The command that created this session, used for logging and accessing parsed options. + private let command: SkipstoneCommand - func buildSourceList() throws -> (sources: [URL], resources: [URL]) { - let projectBaseURL = projectFolderPath.asURL - let allProjectFiles: [URL] = try FileManager.default.enumeratedURLs(of: projectBaseURL) + /// The filesystem abstraction for all file operations. + let fs: FileSystem - let swiftPathExtensions: Set = ["swift"] - let sourceURLs: [URL] = allProjectFiles.filter({ swiftPathExtensions.contains($0.pathExtension) }) + // MARK: - Immutable Paths - let projectResourcesURL = projectBaseURL.appendingPathComponent("Resources", isDirectory: true) - let resourceURLs: [URL] = try FileManager.default.enumeratedURLs(of: projectResourcesURL) + /// The working directory root path. + let rootPath: AbsolutePath - return (sources: sourceURLs, resources: resourceURLs) - } + /// Path to the Swift source project folder (e.g., Sources/ModuleName). + let projectFolderPath: AbsolutePath - let (sourceURLs, resourceURLs) = try buildSourceList() + /// Path to the Gradle module root output directory. + let moduleRootPath: AbsolutePath - let moduleBasePath = moduleRootPath.parentDirectory + /// Path to the Skip/ configuration folder containing skip.yml and overrides. + let skipFolderPath: AbsolutePath - // always touch the sourcehash file with the most recent source hashes in order to update the output file time - /// Create a link from the source to the destination; this is used for resources and custom Kotlin files in order to permit edits to target file and have them reflected in the original source - func addLink(_ linkSource: AbsolutePath, pointingAt destPath: AbsolutePath, relative: Bool, replace: Bool = true, copyReadOnlyFiles: Bool = true) throws { - msg(.trace, "linking: \(linkSource) to: \(destPath)") + /// Path to the Kotlin/Java output folder (e.g., src/main/kotlin or src/test/kotlin). + let outputFolderPath: AbsolutePath - if replace && fs.isSymlink(destPath) { - removePath(destPath) // clear any pre-existing symlink - } + // MARK: - Constants - if let existingSymlinkDestination = try? FileManager.default.destinationOfSymbolicLink(atPath: linkSource.pathString) { - if existingSymlinkDestination == destPath.pathString { - msg(.trace, "retaining existing link from \(destPath.pathString) to \(existingSymlinkDestination)") - addOutputFile(linkSource) // remember that we are using the linkSource file - return - } - } + /// The filename for the Android manifest, which requires special output path handling. + let androidManifestName = "AndroidManifest.xml" - let destInfo = try fs.getFileInfo(destPath) - let modTime = destInfo.modTime - let perms = destInfo.posixPermissions + /// The folder name for Gradle build scripts and plugins. + let buildSrcFolderName = "buildSrc" - // 0o200 adds owner write permission (write = 2, owner = 2) - let writablePermissions = perms | 0o200 + // MARK: - Accumulated Output State - // when the source file is not writable, we copy the file insead of linking it, because otherwise Gradle may fail to overwrite the desination the second time it tries to copy it - // https://github.com/skiptools/skip/issues/296 - let shouldCopy = copyReadOnlyFiles && !fs.isDirectory(linkSource) && (perms != writablePermissions) + /// All output files written during this session. Used for stale file detection: + /// any file in the output folder not in this list after the session is considered stale. + var outputFiles: [AbsolutePath] = [] - removePath(linkSource) // remove any existing link in order to re-create it - if shouldCopy { - msg(.trace, "copying \(destPath) to \(linkSource)") - try fs.copy(from: destPath, to: addOutputFile(linkSource)) - //try fs.chmod(.userWritable, path: destPath) - try FileManager.default.setAttributes([.posixPermissions: writablePermissions], ofItemAtPath: linkSource.pathString) - } else { - msg(.trace, "linking \(destPath) to \(linkSource)") - try fs.createSymbolicLink(addOutputFile(linkSource), pointingAt: destPath, relative: relative) - } + /// All input files read during this session, tracked for modification timestamps. + var inputFiles: [AbsolutePath] = [] - // set the output link mod time to match the source link mod time + /// Bridge transpilation outputs accumulated during the transpilation phase, + /// consumed later when saving bridge code. + var skipBridgeTranspilations: [Transpilation] = [] - // this will try to set the mod time of the *destination* file, which is incorrect (and also not allowed, since the dest is likely outside of our sandboxed write folder list) - //try FileManager.default.setAttributes([.modificationDate: modTime], ofItemAtPath: linkSource.pathString) + // MARK: - Phase-Derived State (set during run) - // using setResourceValue instead does apply it to the link - // https://stackoverflow.com/questions/10608724/set-modification-date-on-symbolic-link-in-cocoa - try (linkSource.asURL as NSURL).setResourceValue(modTime, forKey: .contentModificationDateKey) - } + /// Snapshot of output file URLs taken at session start, for stale file comparison. + private var outputFilesSnapshot: [URL] = [] - // the shared JSON encoder for serializing .skipcode.json codebase and .sourcehash marker contents - let encoder = JSONEncoder() - encoder.outputFormatting = [ - .sortedKeys, // needed for deterministic output - .withoutEscapingSlashes, - //.prettyPrinted, // compacting JSON significantly reduces the size of the codebase files - ] + /// Source .swift file URLs enumerated from the project folder. + private var sourceURLs: [URL] = [] - let sourcehashOutputPath = try AbsolutePath(validating: skipstoneOptions.sourcehash) - // We no longer remove the path because the plugin doesn't seem to require it to know to run in dependency order - //removePath(sourcehashOutputPath) // delete the build completion marker to force its re-creation (removeFileTree doesn't throw when the file doesn't exist) - - // load and merge each of the skip.yml files for the dependent modules - let (baseSkipConfig, mergedSkipConfig, configMap) = try loadSkipConfig(merge: true) - let hasSkipFuse = configMap.keys.contains("SkipFuse") - - // Build resource entries from skip.yml configuration, falling back to the default Resources/ folder - let resourceEntries: [ResourceEntry] = try { - let projectBaseURL = projectFolderPath.asURL - if let resourceConfigs = baseSkipConfig.skip?.resources { - return try resourceConfigs.map { config in - let resourceDirURL = projectBaseURL.appendingPathComponent(config.path, isDirectory: true) - let urls: [URL] = try FileManager.default.enumeratedURLs(of: resourceDirURL) - return ResourceEntry(path: config.path, urls: urls, isCopyMode: config.isCopyMode) - } - } else if !resourceURLs.isEmpty { - return [ResourceEntry(path: "Resources", urls: resourceURLs, isCopyMode: false)] - } else { - return [] - } - }() + /// Resource file URLs from the default Resources/ folder. + private var resourceURLs: [URL] = [] - func moduleMode(for moduleName: String?) -> ModuleMode { - let moduleMode: String? + /// The base (unmerged) skip.yml config for this module. + private var baseSkipConfig: SkipConfig! - if let moduleName { - moduleMode = configMap[moduleName]?.skip?.mode - } else { - moduleMode = baseSkipConfig.skip?.mode - } + /// The merged skip.yml config combining all dependent module configs. + private var mergedSkipConfig: SkipConfig! - switch moduleMode { - case "native": return .native - case "transpiled": return .transpiled - case "automatic", .none: return hasSkipFuse && (moduleName == primaryModuleName || moduleName == nil) ? .native : .transpiled - default: - error("Unknown skip mode for module \(moduleName ?? primaryModuleName): \(moduleMode ?? "none")") - return .transpiled - } - } + /// Map of module name to its parsed skip.yml config. + private var configMap: [String: SkipConfig]! - let isNativeModule = moduleMode(for: nil) == .native + /// Whether SkipFuse is present in the dependency graph. + private var hasSkipFuse: Bool = false - // also add any files in the skipFolderFile to the list of sources (including the skip.yml and other metadata files) - let skipFolderPathContents = try FileManager.default.enumeratedURLs(of: skipFolderPath.asURL) - .filter({ (try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true }) + /// Whether this module operates in native (non-transpiled) mode. + private var isNativeModule: Bool = false - let sourcehashes = try await loadSourceHashes(from: sourceURLs + skipFolderPathContents) + /// The Kotlin package name for this module (e.g., "skip.foundation"). + private var packageName: String! - // touch the build marker with the most recent file time from the complete build list - // if we were to touch it afresh every time, the plugin would be re-executed every time - defer { - // finally, remove any "stale" files from the output folder that probably indicate a deleted or renamed file once all the known outputs have been written - cleanupStaleOutputFiles() + /// Structured resource entries built from skip.yml configuration. + private var resourceEntries: [ResourceEntry] = [] - do { - // touch the source hash file with a new timestamp to signal to the plugin host that our output file has been written - try saveSourcehashFile() - } catch { - msg(.warning, "could not create build completion marker: \(error)") - } - } + /// SHA256 hashes of all source files, used for change detection. + private var sourcehashes: [URL: String] = [:] - let buildGradle = moduleRootPath.appending(component: "build.gradle.kts") + /// Codebase info loaded from dependent modules, populated during transpilation. + private var codebaseInfo: CodebaseInfo! - let codebaseInfo = try await loadCodebaseInfo() // initialize the codebaseinfo and load DependentModuleName.skipcode.json + /// Kotlin filenames that have manual overrides from the Skip/ folder. + private var overriddenKotlinFiles: [String] = [] - let autoBridge: AutoBridge = primaryModuleName == "SkipSwiftUI" ? .none : baseSkipConfig.skip?.isAutoBridgingEnabled() == true ? .public : .default - let dynamicRoot = baseSkipConfig.skip?.dynamicroot + // MARK: - Computed Properties - // projects with a CMakeLists.txt file are built as a native Android library - // these are only used for purely native code libraries, and so we short-circuit the build generation - if isCMakeProject { - // Link ext/ to the relative cmake target - let extLink = moduleRootPath.appending(component: "ext") - try addLink(extLink, pointingAt: projectFolderPath, relative: false) - } + /// The parent directory of moduleRootPath, used as the base for relative output paths. + var moduleBasePath: AbsolutePath { moduleRootPath.parentDirectory } - // the standard base name for Gradle Kotlin and Java source files - let kotlinOutputFolder = try AbsolutePath(outputFolderPath, validating: "kotlin") - let javaOutputFolder = try AbsolutePath(outputFolderPath, validating: "java") + /// Module name/path pairs from --module arguments, forwarded from the command. + var moduleNamePaths: [(module: String, path: String)] { command.moduleNamePaths } - // the standard base name for resources, which will be linked from a path like: src/main/resources/package/name/resname.ext - //let resourcesOutputFolder = try AbsolutePath(outputFolderPath, validating: "resources") // traditional Java resources folder - let resourcesOutputFolder = try AbsolutePath(outputFolderPath, validating: "assets") // Android AssetManager folder + /// Module name/link pairs from --link arguments, forwarded from the command. + var linkNamePaths: [(module: String, link: String)] { command.linkNamePaths } - // Android-specific resources like res/values/strings.xml - let resOutputFolder = try AbsolutePath(outputFolderPath, validating: "res") + /// Dependency id/path triples from --dependency arguments, forwarded from the command. + var dependencyIdPaths: [(targetName: String, packageID: String, packagePath: String)] { command.dependencyIdPaths } - if !fs.isDirectory(kotlinOutputFolder) { - // e.g.: ~Library/Developer/Xcode/DerivedData/PACKAGE-ID/SourcePackages/plugins/skiphub.output/SkipFoundationKotlinTests/skipstone/SkipFoundation/src/test/kotlin - //throw error("Folder specified by --output-folder did not exist: \(outputFolder)") - try fs.createDirectory(kotlinOutputFolder, recursive: true) - } + /// The skipstone-specific command options. + var skipstoneOptions: SkipstoneCommandOptions { command.skipstoneOptions } - // now make a link from src/androidTest/kotlin to src/test/kotlin so the same tests will run against an Android emulator/device with the ANDROID_SERIAL environment - if primaryModuleName.hasSuffix("Tests") { - let androidTestOutputFolder = try AbsolutePath(outputFolderPath, validating: "../androidTest") - removePath(androidTestOutputFolder) // remove any existing link in order to re-create it - try fs.createSymbolicLink(addOutputFile(androidTestOutputFolder), pointingAt: outputFolderPath, relative: true) - } + // MARK: - JSON Encoder - let packageName = baseSkipConfig.skip?.package ?? KotlinTranslator.packageName(forModule: primaryModuleName) + /// Shared JSON encoder configured for deterministic output, used for + /// serializing `.skipcode.json` codebase and `.sourcehash` marker contents. + let encoder: JSONEncoder = { + let e = JSONEncoder() + e.outputFormatting = [ + .sortedKeys, // needed for deterministic output + .withoutEscapingSlashes, + ] + return e + }() + + // MARK: - Initialization + + /// Creates a new session with the given command and pre-validated paths. + /// + /// The initializer stores references but performs no I/O. All work is deferred + /// to ``run(with:)`` and its phase methods. + /// + /// - Parameters: + /// - command: The parsed skipstone command, providing options and logging. + /// - rootPath: The filesystem root / working directory. + /// - projectFolderPath: Path to the Swift project source folder. + /// - moduleRootPath: Path to the Gradle module root output. + /// - skipFolderPath: Path to the Skip/ configuration folder. + /// - outputFolderPath: Path to the Kotlin/Java output folder. + /// - fs: The filesystem abstraction for file operations. + init(command: SkipstoneCommand, rootPath: AbsolutePath, projectFolderPath: AbsolutePath, moduleRootPath: AbsolutePath, skipFolderPath: AbsolutePath, outputFolderPath: AbsolutePath, fs: FileSystem) { + self.command = command + self.rootPath = rootPath + self.projectFolderPath = projectFolderPath + self.moduleRootPath = moduleRootPath + self.skipFolderPath = skipFolderPath + self.outputFolderPath = outputFolderPath + self.fs = fs + } - let transformers: [KotlinTransformer] = try createTransformers(for: baseSkipConfig, with: configMap) + // MARK: - Logging (forwarded to command) - let overridden = try linkSkipFolder(skipFolderPath, to: kotlinOutputFolder, topLevel: true) - let overriddenKotlinFiles = overridden.map({ $0.basename }) + func trace(_ message: @autoclosure () -> String) { + command.trace(message()) + } - // the contents of a folder named "buildSrc" are linked at the top level to contain scripts and plugins - let buildSrcFolder = skipFolderPath.appending(component: buildSrcFolderName) - if fs.isDirectory(buildSrcFolder) { - try addLink(moduleBasePath.appending(component: buildSrcFolderName), pointingAt: buildSrcFolder, relative: false) - } + func info(_ message: @autoclosure () -> String, sourceFile: Source.FilePath? = nil) { + command.info(message(), sourceFile: sourceFile) + } - // feed skipstone the files to transpile and any compiled files to potentially bridge - var transpileFiles: [String] = [] - var swiftFiles: [String] = [] - for sourceFile in sourceURLs.map(\.path).sorted() { - if isNativeModule { - swiftFiles.append(sourceFile) - } else { - transpileFiles.append(sourceFile) - } - } - let transpiler = Transpiler(packageName: packageName, transpileFiles: transpileFiles.map(Source.FilePath.init(path:)), bridgeFiles: swiftFiles.map(Source.FilePath.init(path:)), autoBridge: autoBridge, isBridgeGatherEnabled: dynamicRoot != nil, codebaseInfo: codebaseInfo, preprocessorSymbols: Set(inputOptions.symbols), transformers: transformers) + func warn(_ message: @autoclosure () -> String, sourceFile: Source.FilePath? = nil) { + command.warn(message(), sourceFile: sourceFile) + } - try await transpiler.transpile(handler: handleTranspilation) - try saveCodebaseInfo() // save out the ModuleName.skipcode.json - try saveSkipBridgeCode() + @discardableResult func error(_ message: @autoclosure () -> String, sourceFile: Source.FilePath? = nil) -> ValidationError { + command.error(message(), sourceFile: sourceFile) + } - let sourceModules = try linkDependentModuleSources() - try linkResources() + func msg(_ kind: Message.Kind, _ message: @autoclosure () -> String, sourceFile: Source.FilePath? = nil) { + command.msg(kind, message(), sourceFile: sourceFile) + } - try generateGradle(for: sourceModules, with: mergedSkipConfig, isApp: isAppModule) + // MARK: - Main Execution - return // done + /// Executes all phases of the skipstone pipeline. + /// + /// This method orchestrates the full skipstone invocation by calling phase methods + /// in sequence. A `defer` block ensures that stale file cleanup and the sourcehash + /// marker are always written, even if an error occurs. + /// + /// - Parameter out: The message queue for yielding transpilation results to the build plugin host. + func run(with out: MessageQueue) async throws { + let primaryModuleName = try requirePrimaryModule().module - // MARK: Transpilation helper functions + trace("skipstoneThrows: rootPath=\(rootPath), projectFolderPath=\(projectFolderPath), moduleRootPath=\(moduleRootPath), skipFolderPath=\(skipFolderPath), outputFolderPath=\(outputFolderPath)") - /// The relative path for cached codebase info JSON - func moduleExportPath(forModule moduleName: String) throws -> RelativePath { - try RelativePath(validating: moduleName + skipcodeExtension) - } + try validateSkipFolder() + try snapshotExistingOutputFiles() + try ensureModuleRootExists() - func loadCodebaseInfo() async throws -> CodebaseInfo { - let decoder = JSONDecoder() - var dependentModuleExports: [CodebaseInfo.ModuleExport] = [] + let (sources, resources) = try buildSourceList() + self.sourceURLs = sources + self.resourceURLs = resources - // go through the '--link modulename:../../some/path' arguments and try to load the modulename.skipcode.json symbols from the previous module's transpilation output - for (linkModuleName, relativeLinkPath) in linkNamePaths { - let linkModuleRoot = moduleRootPath - .parentDirectory - .appending(try RelativePath(validating: relativeLinkPath)) + try loadAndMergeConfiguration() + try await computeSourceHashes() - let dependencyModuleExport = linkModuleRoot - .parentDirectory - .appending(try moduleExportPath(forModule: linkModuleName)) - - do { - let exportLoadStart = Date().timeIntervalSinceReferenceDate - trace("dependencyModuleExport \(dependencyModuleExport): exists \(fs.exists(dependencyModuleExport))") - let exportData = try inputSource(dependencyModuleExport).withData { Data($0) } - let export = try decoder.decode(CodebaseInfo.ModuleExport.self, from: exportData) - dependentModuleExports.append(export) - let exportLoadEnd = Date().timeIntervalSinceReferenceDate - info("\(dependencyModuleExport.basename) codebase (\(exportData.count.byteCount)) loaded (\(Int64((exportLoadEnd - exportLoadStart) * 1000)) ms) for \(linkModuleName)", sourceFile: dependencyModuleExport.sourceFile) - } catch let e { - throw error("Skip: error loading codebase for \(linkModuleName): \(e.localizedDescription)", sourceFile: dependencyModuleExport.sourceFile) - } - } + defer { finalizeSession() } - let codebaseInfo = CodebaseInfo(moduleName: primaryModuleName) - codebaseInfo.dependentModules = dependentModuleExports - return codebaseInfo - } + self.codebaseInfo = try await loadCodebaseInfo() - func writeChanges(tag: String, to outputFilePath: AbsolutePath, contents: any DataProtocol, readOnly: Bool) throws { - let changed = try fs.writeChanges(path: addOutputFile(outputFilePath), makeReadOnly: readOnly, bytes: ByteString(contents)) - info("\(outputFilePath.relative(to: moduleBasePath).pathString) (\(contents.count.byteCount)) \(tag) \(!changed ? "unchanged" : "written")", sourceFile: outputFilePath.sourceFile) + let autoBridge: AutoBridge = primaryModuleName == "SkipSwiftUI" ? .none : baseSkipConfig.skip?.isAutoBridgingEnabled() == true ? .public : .default + let dynamicRoot = baseSkipConfig.skip?.dynamicroot + + if isCMakeProject { + try linkCMakeProject() } - func saveSourcehashFile() throws { - if !fs.isDirectory(moduleBasePath) { - try fs.createDirectory(moduleBasePath, recursive: true) - } + let kotlinOutputFolder = try setupOutputFolders() + try setupTransformersAndOverrides(kotlinOutputFolder: kotlinOutputFolder) - struct SourcehashContents : Encodable { - /// The version of Skip that generates this marker file - let skipstone: String = skipVersion + try await runTranspiler(autoBridge: autoBridge, dynamicRoot: dynamicRoot, kotlinOutputFolder: kotlinOutputFolder, with: out) - /// The relative input paths and hashes for source files, in order to identify when input contents or file lists have changed - let sourcehashes: [String: String] - } + try saveCodebaseInfo() + try saveSkipBridgeCode() - // create relative source paths so we do not encode full paths in the output - let sourcePathHashes: [(String, String)] = sourcehashes.compactMap { url, sourcehash in - let absolutePath = url.path - if !absolutePath.hasPrefix(projectFolderPath.pathString) { - return .none - } + let sourceModules = try linkDependentModuleSources() + try linkResources() - let relativePath = absolutePath.dropFirst(projectFolderPath.pathString.count).trimmingPrefix(while: { $0 == "/" }) - return (relativePath.description, sourcehash) - } + try generateGradle(for: sourceModules, with: mergedSkipConfig, isApp: isAppModule) + } - let sourcehash = SourcehashContents(sourcehashes: Dictionary(sourcePathHashes, uniquingKeysWith: { $1 })) - try writeChanges(tag: "sourcehash", to: sourcehashOutputPath, contents: try encoder.encode(sourcehash), readOnly: false) - } + // MARK: - Phase 1: Validation & Setup - func saveCodebaseInfo() throws { - let outputFilePath = try moduleBasePath.appending(moduleExportPath(forModule: primaryModuleName)) - let moduleExport = CodebaseInfo.ModuleExport(of: codebaseInfo) - try writeChanges(tag: "codebase", to: outputFilePath, contents: encoder.encode(moduleExport), readOnly: true) + /// Returns the primary module name and path from the command's --module arguments. + /// + /// - Throws: `ValidationError` if no --module argument was provided. + /// - Returns: The first module name/path tuple. + func requirePrimaryModule() throws -> (module: String, path: String) { + guard let primary = moduleNamePaths.first else { + throw error("Must specify at least one --module") } + return primary + } - func saveSkipBridgeCode() throws { - // create the generated bridge files when the SKIP_BRIDGE environment is set and the plugin passed the --skip-bridge-output flag to the tool - if let skipBridgeOutput = skipstoneOptions.skipBridgeOutput { - let skipBridgeOutputFolder = try AbsolutePath(validating: skipBridgeOutput) + /// The primary module name, extracted from the first --module argument. + var primaryModuleName: String { + moduleNamePaths.first?.module ?? "" + } - let swiftBridgeFileNameTranspilationMap = skipBridgeTranspilations.reduce(into: Dictionary()) { result, transpilation in - result[transpilation.output.file.name] = transpilation - } + /// Whether this module is an app (vs a library), determined by the presence of an `.xcconfig` file. + var isAppModule: Bool { + let configModuleName = primaryModuleName.hasSuffix("Tests") ? String(primaryModuleName.dropLast("Tests".count)) : primaryModuleName + let moduleXCConfig = rootPath.appending(component: configModuleName + ".xcconfig") + return fs.isFile(moduleXCConfig) + } - for swiftSourceFile in sourceURLs.filter({ $0.pathExtension == "swift"}) { - let swiftFileBase = swiftSourceFile.deletingPathExtension().lastPathComponent - let swiftBridgeFileName = swiftFileBase.appending(Source.FilePath.bridgeFileSuffix) - let swiftBridgeOutputPath = skipBridgeOutputFolder.appending(components: [swiftBridgeFileName]) + /// The path to the module's xcconfig file (used for app module manifest configuration). + var moduleXCConfig: AbsolutePath { + let configModuleName = primaryModuleName.hasSuffix("Tests") ? String(primaryModuleName.dropLast("Tests".count)) : primaryModuleName + return rootPath.appending(component: configModuleName + ".xcconfig") + } - // FIXME: this doesn't handle the case where there are multiple files with the same name in a project (e.g., Folder1/Utils.swift and Folder2/Utils.swift). We would need to handle un-flattened project hierarchies to get past this - let bridgeContents: String - if let bridgeTranspilation = swiftBridgeFileNameTranspilationMap[swiftBridgeFileName] { - bridgeContents = bridgeTranspilation.output.content - } else { - bridgeContents = "" - } - try writeChanges(tag: "skipbridge", to: swiftBridgeOutputPath, contents: bridgeContents.utf8Data, readOnly: true) - } + /// Whether the project uses CMake (has a CMakeLists.txt in the project folder). + var isCMakeProject: Bool { + let cmakeLists = projectFolderPath.appending(component: "CMakeLists.txt") + return fs.exists(cmakeLists) + } - // write support files - for supportFileName in [KotlinDynamicObjectTransformer.supportFileName, KotlinBundleTransformer.supportFileName, KotlinFoundationBridgeTransformer.supportFileName] { - let supportContents: String - if let supportTranspilation = swiftBridgeFileNameTranspilationMap[supportFileName] { - supportContents = supportTranspilation.output.content - } else { - supportContents = "" - } - let supportOutputPath = skipBridgeOutputFolder.appending(components: [supportFileName]) - try writeChanges(tag: "skipbridge", to: supportOutputPath, contents: supportContents.utf8Data, readOnly: true) - } + /// Validates that the Skip/ folder exists (unless this is a CMake project). + /// + /// - Throws: `ValidationError` if the Skip/ folder is missing and this isn't a CMake project. + func validateSkipFolder() throws { + if !isCMakeProject && !fs.isDirectory(skipFolderPath) { + throw error("In order for Skip to process the module, a Skip/ folder must exist and contain a skip.yml file at: \(skipFolderPath)") + } + } - return - } + /// Takes a snapshot of all files currently in the output folder. + /// + /// This snapshot is compared against ``outputFiles`` at session end to identify + /// stale files from previous runs that should be cleaned up. + func snapshotExistingOutputFiles() throws { + self.outputFilesSnapshot = try FileManager.default.enumeratedURLs(of: outputFolderPath.asURL) + } - // if the package is to be bridged, then create a src/main/swift folder that links to the source package - guard isNativeModule || !skipBridgeTranspilations.isEmpty else { - return - } + /// Ensures the module root directory exists, creating it if needed. + /// + /// - Throws: `ValidationError` if the directory cannot be created. + func ensureModuleRootExists() throws { + if !fs.isDirectory(moduleRootPath) { + try fs.createDirectory(moduleRootPath, recursive: true) + } + if !fs.isDirectory(moduleRootPath) { + throw error("Module root path did not exist at: \(moduleRootPath.pathString)") + } + } - // Link src/main/swift/ to the absolute Swift project folder - let swiftLinkFolder = try AbsolutePath(outputFolderPath, validating: "swift") - try fs.createDirectory(swiftLinkFolder, recursive: true) - - // create Packages/swift-package-name links for all the project's package dependencies so we use the local versions in our swift build rather than downloading the remote dependencies - // this will sync with Xcode's workspace, which will enable local package development of dependencies to work the same with this derived package as it does in Xcode - let packagesLinkFolder = try AbsolutePath(swiftLinkFolder, validating: "Packages") - try fs.createDirectory(packagesLinkFolder, recursive: true) - - // to use the package, we could do the equivalent of `swift package edit --path /path/to/local/package-id package-id, - // but this would involve writing to the .build/workspace-state.json file with the "edited" property, which is - // not a stable or well-documented format, and would require a lot of other metadata about the package; - // so instead we tack on some code to the Package.swift file that we output - // - // We pass dependencies as an inout parameter to bypass Swift 6+ requiring that it be @MainActor. - var packageAddendum = """ - - /// Convert remote dependencies into their locally-cached versions. - /// This allows us to re-use dependencies from the parent - /// Xcode/SwiftPM process without redundently cloning them. - func useLocalPackage(named packageName: String, id packageID: String, dependencies: inout [Package.Dependency]) { - func localDependency(name: String?, location: String) -> Package.Dependency? { - if name == packageID || location.hasSuffix("/" + packageID) || location.hasSuffix("/" + packageID + ".git") { - return Package.Dependency.package(path: "Packages/" + packageID) - } else { - return nil - } - } - dependencies = dependencies.map { dep in - switch dep.kind { - case let .sourceControl(name: name, location: location, requirement: _): - return localDependency(name: name, location: location) ?? dep - case let .fileSystem(name: name, path: location): - return localDependency(name: name, location: location) ?? dep - default: - return dep - } - } - } - - """ + /// Enumerates the project folder to find Swift source files and resource files. + /// + /// Source files are any `.swift` files in the project folder. Resource files + /// are files under the `Resources/` subdirectory. + /// + /// - Returns: A tuple of (sourceURLs, resourceURLs). + func buildSourceList() throws -> (sources: [URL], resources: [URL]) { + let projectBaseURL = projectFolderPath.asURL + let allProjectFiles: [URL] = try FileManager.default.enumeratedURLs(of: projectBaseURL) - var createdIds: Set = [] + let swiftPathExtensions: Set = ["swift"] + let sourceURLs: [URL] = allProjectFiles.filter({ swiftPathExtensions.contains($0.pathExtension) }) - let moduleLinkPaths = Dictionary(self.linkNamePaths, uniquingKeysWith: { $1 }) + let projectResourcesURL = projectBaseURL.appendingPathComponent("Resources", isDirectory: true) + let resourceURLs: [URL] = try FileManager.default.enumeratedURLs(of: projectResourcesURL) - for (targetName, packageName, var packagePath) in self.dependencyIdPaths { - // the package name in the Package.swift typically the last part of the repository name (e.g., "swift-algorithms" in https://github.com/apple/swift-algorithms.git ), but for other packages it isn't (e.g., "Lottie" for https://github.com/airbnb/lottie-ios.git ); we need to use the repository name - let packageID = packagePath.split(separator: "/").last?.description ?? packagePath + return (sources: sourceURLs, resources: resourceURLs) + } - if !createdIds.insert(packageID).inserted { - // only create the link once, even if specified multiple times - continue - } + // MARK: - Phase 2: Configuration Loading - // check whether the linked target is another linked Skip folder, and if so, check whether it has a derived src/main/swift folder (which indicates that it is a bridging package in which case we need the package to reference the *derived* sources rather than the *original* sources) - if let relativeLinkPath = moduleLinkPaths[targetName] { - let linkModuleRoot = moduleRootPath - .parentDirectory - .appending(try RelativePath(validating: relativeLinkPath)) - let linkModuleSrcMainSwift = linkModuleRoot.appending(components: "src", "main", "swift") - if fs.exists(linkModuleSrcMainSwift) { - info("override link path for \(targetName) from \(packagePath) to \(linkModuleSrcMainSwift.pathString)") - packagePath = linkModuleSrcMainSwift.pathString - } - } + /// Loads and merges skip.yml configs from this module and all its dependencies. + /// + /// After this method completes, ``baseSkipConfig``, ``mergedSkipConfig``, + /// ``configMap``, ``hasSkipFuse``, ``isNativeModule``, ``packageName``, + /// and ``resourceEntries`` are all populated. + func loadAndMergeConfiguration() throws { + let (base, merged, map) = try loadSkipConfig(merge: true) + self.baseSkipConfig = base + self.mergedSkipConfig = merged + self.configMap = map + self.hasSkipFuse = map.keys.contains("SkipFuse") - let dependencyPackageLink = try AbsolutePath(packagesLinkFolder, validating: packageID) - let destinationPath = try AbsolutePath(validating: packagePath) - try addLink(dependencyPackageLink, pointingAt: destinationPath, relative: false) + self.resourceEntries = try Self.buildResourceEntries( + config: base, resourceURLs: resourceURLs, projectBaseURL: projectFolderPath.asURL) - packageAddendum += """ - useLocalPackage(named: "\(packageName)", id: "\(packageID)", dependencies: &package.dependencies) - - """ + self.isNativeModule = Self.resolveModuleMode( + moduleName: nil, configMap: map, baseConfig: base, + hasSkipFuse: hasSkipFuse, primaryModuleName: primaryModuleName) == .native + + self.packageName = base.skip?.package ?? KotlinTranslator.packageName(forModule: primaryModuleName) + } + + /// Loads a single skip.yml file, optionally filtering `export: false` blocks. + /// + /// - Parameters: + /// - path: Absolute path to the skip.yml file. + /// - forExport: When true, blocks marked `export: false` are stripped. + /// - Returns: The parsed `SkipConfig`. + func loadSkipYAML(path: AbsolutePath, forExport: Bool) throws -> SkipConfig { + do { + var yaml = try inputSource(path).withData(YAML.parse(_:)) + if yaml.object == nil { + yaml = .object([:]) } - // The source of the link tree needs to be the root project for the module in question, which we don't have access to (it can't be the `rootPath`, since that will be the topmost package that resulted in the transpiler invocation, which may not be the module in question). - // So we need to guess from the projectFolderPath, which will be something like `/path/to/project-name/Sources/TargetName` by tacking `../..` to the end of the path. - // WARNING: this is delicate, because there is nothing guaranteeing that the project follows the convention of `Sources/TargetName` for their modules! - //let mirrorSource = rootPath - let mirrorSource = projectFolderPath.appending(components: "..", "..") - - //warn("creating absolute merged link tree from: swiftLinkFolder=\(swiftLinkFolder) to mirrorSource=\(mirrorSource) (rootPath=\(rootPath)) with dependencyIdPaths=\(dependencyIdPaths)") - try createMirroredLinkTree(swiftLinkFolder, pointingAt: mirrorSource, shallow: true, excluding: ["Packages", "Package.resolved", ".build", ".swiftpm", "skip-export", "build"]) { destPath, path in - trace("createMirroredLinkTree for \(path.pathString)->\(destPath)") - - // manually add the packageAddendum the Package.swift - if path.basename == "Package.swift" && !self.dependencyIdPaths.isEmpty { - let packageContents = try fs.readFileContents(path).withData { $0 + packageAddendum.utf8Data } - try writeChanges(tag: "skippackage", to: destPath, contents: packageContents, readOnly: true) - return false // override the linking of the file - } else { - return true - } + if forExport { + yaml = Self.filterExportYAML(yaml) ?? yaml } + return try yaml.json().decode() + } catch let e { + throw error("The skip.yml file at \(path) could not be loaded: \(e)", sourceFile: path.sourceFile) } + } - func generateGradle(for sourceModules: [String], with skipConfig: SkipConfig, isApp: Bool) throws { - try generateGradleWrapperProperties() - try generateProguardFile(packageName) - try generatePerModuleGradle() - try generateGradleProperties() - try generateSettingsGradle() + /// Loads the skip.yml config, optionally merged with dependent module configs. + /// + /// When `merge` is true, iterates through all --module dependencies, loads each + /// module's skip.yml, and produces an aggregate config. The aggregate includes + /// auto-generated Gradle dependency blocks and, for app modules, manifest + /// placeholder configuration from the .xcconfig file. + /// + /// - Parameters: + /// - merge: Whether to merge with dependent module configs. Defaults to true. + /// - configFileName: The config filename. Defaults to "skip.yml". + /// - Returns: A tuple of (base config, merged config, per-module config map). + func loadSkipConfig(merge: Bool = true, configFileName: String = "skip.yml") throws -> (base: SkipConfig, merged: SkipConfig, configMap: [String: SkipConfig]) { + let configStart = Date().timeIntervalSinceReferenceDate + let skipConfigPath = skipFolderPath.appending(component: configFileName) + let currentModuleConfig = try loadSkipYAML(path: skipConfigPath, forExport: false) + + var configMap: [String: SkipConfig] = [:] + configMap[primaryModuleName] = currentModuleConfig + + let currentModuleJSON = try currentModuleConfig.json() + info("loading skip.yml from \(skipConfigPath)", sourceFile: skipConfigPath.sourceFile) + + if !merge { + return (currentModuleConfig, currentModuleConfig, configMap) + } - func generatePerModuleGradle() throws { - let buildContents = (skipConfig.build ?? .init()).generate(context: .init(dsl: .kotlin)) + var aggregateJSON: Universal.JSON = [:] - // we output as a joined string because there is a weird stdout bug with the tool or plugin executor somewhere that causes multi-line strings to be output in the wrong order - trace("created gradle: \(buildContents.split(separator: "\n").map({ $0.trimmingCharacters(in: .whitespaces) }).joined(separator: "; "))") + for (moduleName, modulePath) in moduleNamePaths { + trace("moduleName: \(moduleName) modulePath: \(modulePath) primaryModuleName: \(primaryModuleName)") + if moduleName == primaryModuleName { + continue + } - let contents = """ - // build.gradle.kts generated by Skip for \(primaryModuleName) + let moduleSkipBasePath = try AbsolutePath(validating: modulePath, relativeTo: moduleRootPath.parentDirectory) + .appending(components: ["Skip"]) + + let moduleSkipConfigPath = moduleSkipBasePath.appending(component: configFileName) + + if fs.isFile(moduleSkipConfigPath) { + let skipConfigLoadStart = Date().timeIntervalSinceReferenceDate + let isTestPeer = primaryModuleName == moduleName + "Tests" + trace("primaryModuleName: \(primaryModuleName) moduleName: \(moduleName) isTestPeer=\(isTestPeer)") + let isForExport = !isTestPeer + let moduleConfig = try loadSkipYAML(path: moduleSkipConfigPath, forExport: isForExport) + configMap[moduleName] = moduleConfig + let skipConfigLoadEnd = Date().timeIntervalSinceReferenceDate + info("\(moduleName) skip.yml config loaded (\(Int64((skipConfigLoadEnd - skipConfigLoadStart) * 1000)) ms)", sourceFile: moduleSkipConfigPath.sourceFile) + aggregateJSON = try aggregateJSON.merged(with: moduleConfig.json()) + } + } - """ + buildContents + aggregateJSON = try aggregateJSON.merged(with: currentModuleJSON) - try writeChanges(tag: "gradle project", to: buildGradle, contents: contents.utf8Data, readOnly: true) + // Merge auto-generated module dependency and app config blocks + do { + var moduleDependencyBlocks: [GradleBlock.BlockOrCommand] = [] + + for (moduleName, _) in moduleNamePaths { + if Self.isTestModule(moduleName, primaryModuleName: primaryModuleName) { + if moduleName == "SkipUnit" { + moduleDependencyBlocks += [ + .init("testImplementation(project(\":\(moduleName)\"))"), + .init("androidTestImplementation(project(\":\(moduleName)\"))") + ] + } else { + moduleDependencyBlocks += [ + .init("api(project(\":\(moduleName)\"))"), + ] + } + } } - func generateSettingsGradle() throws { - let settingsPath = moduleRootPath.parentDirectory.appending(component: "settings.gradle.kts") - var settingsContents = (skipConfig.settings ?? .init()).generate(context: .init(dsl: .kotlin)) + var localConfig = GradleBlock(contents: [.init(GradleBlock(block: "dependencies", contents: moduleDependencyBlocks))]) - settingsContents += """ + if isAppModule { + var manifestConfigLines: [String] = [] - rootProject.name = "\(packageName)" + let moduleXCConfigContents = try String(contentsOf: moduleXCConfig.asURL, encoding: .utf8) + for (key, value) in parseXCConfig(contents: moduleXCConfigContents) { + manifestConfigLines += [""" + manifestPlaceholders["\(key)"] = System.getenv("\(key)") ?: "\(value)" + """] + } - """ + manifestConfigLines += [""" + applicationId = manifestPlaceholders["PRODUCT_BUNDLE_IDENTIFIER"]?.toString().replace("-", "_") + """] - var bridgedModules: [String] = [] + manifestConfigLines += [""" + versionCode = (manifestPlaceholders["CURRENT_PROJECT_VERSION"]?.toString())?.toInt() + """] - func addIncludeModule(_ moduleName: String) { - settingsContents += """ - include(":\(moduleName)") - project(":\(moduleName)").projectDir = file("\(moduleName)") + manifestConfigLines += [""" + versionName = manifestPlaceholders["MARKETING_VERSION"]?.toString() + """] - """ + localConfig.contents?.append(.init(GradleBlock(block: "android", contents: [ + .init(GradleBlock(block: "defaultConfig", contents: manifestConfigLines.map({ .a($0) }))) + ]))) + } - if moduleMode(for: moduleName) == .native { - bridgedModules.append(moduleName) - } - } + aggregateJSON = try aggregateJSON.merged(with: JSON.object(["build": localConfig.json()])) + } - // always add the primary module include - if !sourceModules.contains(primaryModuleName) && !primaryModuleName.hasSuffix("Tests") { - addIncludeModule(primaryModuleName) - } + var aggregateSkipConfig: SkipConfig = try aggregateJSON.decode() + aggregateSkipConfig.build?.removeContent(withExports: true) + aggregateSkipConfig.settings?.removeContent(withExports: true) - for sourceModule in sourceModules { - addIncludeModule(sourceModule) - } + let configEnd = Date().timeIntervalSinceReferenceDate + info("skip.yml aggregate created (\(Int64((configEnd - configStart) * 1000)) ms) for modules: \(moduleNamePaths.map(\.module))") + return (currentModuleConfig, aggregateSkipConfig, configMap) + } - if !bridgedModules.isEmpty { - settingsContents += """ + // MARK: - Phase 3: Source Hash Computation - gradle.extra["bridgeModules"] = listOf("\(bridgedModules.joined(separator: "\", \""))") + /// Computes SHA256 hashes for all source files and Skip/ folder contents. + /// + /// These hashes are written to the sourcehash marker file to enable the build + /// plugin to detect when source content has changed. + func computeSourceHashes() async throws { + let skipFolderPathContents = try FileManager.default.enumeratedURLs(of: skipFolderPath.asURL) + .filter({ (try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true }) - """ - } + let sourceHashStart = Date().timeIntervalSinceReferenceDate + self.sourcehashes = try await command.loadSourceHashes(from: sourceURLs + skipFolderPathContents) + let sourceHashEnd = Date().timeIntervalSinceReferenceDate + info("source hashes calculated \(self.sourcehashes) files for modules: \(moduleNamePaths.map(\.module)) (\(Int64((sourceHashEnd - sourceHashStart) * 1000)) ms)") + } - try writeChanges(tag: "gradle settings", to: settingsPath, contents: settingsContents.utf8Data, readOnly: true) - } + // MARK: - Phase 4: Transpilation - /// Create the proguard-rules.pro file, which configures the optimization settings for release buils - func generateProguardFile(_ packageName: String) throws { - try writeChanges(tag: "proguard", to: moduleRootPath.appending(component: "proguard-rules.pro"), contents: FrameworkProjectLayout.defaultProguardContents(packageName).utf8Data, readOnly: true) - } + /// Loads codebase info from previously transpiled dependent modules. + /// + /// Iterates through --link arguments to find `ModuleName.skipcode.json` files + /// from prior transpilation runs. These provide type and function information + /// that the transpiler needs for cross-module references. + /// + /// - Returns: A populated `CodebaseInfo` with dependent module exports. + func loadCodebaseInfo() async throws -> CodebaseInfo { + let decoder = JSONDecoder() + var dependentModuleExports: [CodebaseInfo.ModuleExport] = [] + for (linkModuleName, relativeLinkPath) in linkNamePaths { + let linkModuleRoot = moduleRootPath + .parentDirectory + .appending(try RelativePath(validating: relativeLinkPath)) - /// Create the gradle-wrapper.properties file, which will dictate which version of Gradle that Android Studio should use to build the project. - func generateGradleWrapperProperties() throws { - let gradleWrapperFolder = moduleRootPath.parentDirectory.appending(components: "gradle", "wrapper") - try fs.createDirectory(gradleWrapperFolder, recursive: true) - let gradleWrapperPath = gradleWrapperFolder.appending(component: "gradle-wrapper.properties") - let gradeWrapperContents = FrameworkProjectLayout.defaultGradleWrapperProperties() - try writeChanges(tag: "gradle wrapper", to: gradleWrapperPath, contents: gradeWrapperContents.utf8Data, readOnly: true) - } + let dependencyModuleExport = linkModuleRoot + .parentDirectory + .appending(try moduleExportPath(forModule: linkModuleName)) - func generateGradleProperties() throws { - let gradlePropertiesPath = moduleRootPath.parentDirectory.appending(component: "gradle.properties") - - let defaultPropertiesString = FrameworkProjectLayout.defaultGradleProperties() - var properties: [String: String] = [:] - - for line in defaultPropertiesString.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty || trimmed.hasPrefix("#") { - continue - } - let parts = trimmed.split(separator: "=", maxSplits: 1) - if parts.count == 2 { - let key = String(parts[0]).trimmingCharacters(in: .whitespaces) - let value = String(parts[1]).trimmingCharacters(in: .whitespaces) - properties[key] = value - } - } - - // Merge with custom properties from skip.yml (custom properties override defaults) - if let customProperties = skipConfig.gradleProperties { - for (key, value) in customProperties { - properties[key] = value - } - } - - var gradePropertiesContents = "" - for (key, value) in properties.sorted(by: { $0.key < $1.key }) { - gradePropertiesContents += "\(key)=\(value)\n" - } - gradePropertiesContents += "\n" - - try writeChanges(tag: "gradle config", to: gradlePropertiesPath, contents: gradePropertiesContents.utf8Data, readOnly: true) + do { + let exportLoadStart = Date().timeIntervalSinceReferenceDate + trace("dependencyModuleExport \(dependencyModuleExport): exists \(fs.exists(dependencyModuleExport))") + let exportData = try inputSource(dependencyModuleExport).withData { Data($0) } + let export = try decoder.decode(CodebaseInfo.ModuleExport.self, from: exportData) + dependentModuleExports.append(export) + let exportLoadEnd = Date().timeIntervalSinceReferenceDate + info("\(dependencyModuleExport.basename) codebase (\(exportData.count.byteCount)) loaded (\(Int64((exportLoadEnd - exportLoadStart) * 1000)) ms) for \(linkModuleName)", sourceFile: dependencyModuleExport.sourceFile) + } catch let e { + throw error("Skip: error loading codebase for \(linkModuleName): \(e.localizedDescription)", sourceFile: dependencyModuleExport.sourceFile) } } - func loadSkipYAML(path: AbsolutePath, forExport: Bool) throws -> SkipConfig { - do { - var yaml = try inputSource(path).withData(YAML.parse(_:)) - if yaml.object == nil { // an empty file will appear as nil, so just convert to an empty dictionary - yaml = .object([:]) - } + let codebaseInfo = CodebaseInfo(moduleName: primaryModuleName) + codebaseInfo.dependentModules = dependentModuleExports + return codebaseInfo + } - // go through all the top-level "export: false" blocks and remove them when the config is being imported elsewhere - if forExport { - func filterExport(from yaml: YAML) -> YAML? { - guard var obj = yaml.object else { - if let array = yaml.array { - return .array(array.compactMap(filterExport(from:))) - } else { - return yaml - } - } - for (key, value) in obj { - if key == "export" { - if value.boolean == false { - // skip over the whole dict - return nil - } - } else { - obj[key] = filterExport(from: value) - } - } - return .object(obj) - } + /// Links the CMake project's ext/ directory to the project folder. + func linkCMakeProject() throws { + let extLink = moduleRootPath.appending(component: "ext") + try addLink(extLink, pointingAt: projectFolderPath, relative: false) + } - yaml = filterExport(from: yaml) ?? yaml - } - return try yaml.json().decode() - } catch let e { - throw error("The skip.yml file at \(path) could not be loaded: \(e)", sourceFile: path.sourceFile) - } + /// Creates output folders and sets up the androidTest symlink for test modules. + /// + /// - Returns: The path to the Kotlin output folder. + func setupOutputFolders() throws -> AbsolutePath { + let kotlinOutputFolder = try AbsolutePath(outputFolderPath, validating: "kotlin") + + if !fs.isDirectory(kotlinOutputFolder) { + try fs.createDirectory(kotlinOutputFolder, recursive: true) } - /// Loads the `skip.yml` config, optionally merged with the `skip.yml` of all the module dependencies - func loadSkipConfig(merge: Bool = true, configFileName: String = "skip.yml") throws -> (base: SkipConfig, merged: SkipConfig, configMap: [String: SkipConfig]) { - let configStart = Date().timeIntervalSinceReferenceDate - let skipConfigPath = skipFolderPath.appending(component: configFileName) - let currentModuleConfig = try loadSkipYAML(path: skipConfigPath, forExport: false) + // Link src/androidTest/kotlin → src/test/kotlin for test modules + if primaryModuleName.hasSuffix("Tests") { + let androidTestOutputFolder = try AbsolutePath(outputFolderPath, validating: "../androidTest") + removePath(androidTestOutputFolder) + try fs.createSymbolicLink(addOutputFile(androidTestOutputFolder), pointingAt: outputFolderPath, relative: true) + } - var configMap: [String: SkipConfig] = [:] - configMap[primaryModuleName] = currentModuleConfig + return kotlinOutputFolder + } - let currentModuleJSON = try currentModuleConfig.json() - info("loading skip.yml from \(skipConfigPath)", sourceFile: skipConfigPath.sourceFile) + /// Creates transpiler transformers and links override files from the Skip/ folder. + /// + /// Override `.kt` files in the Skip/ folder take precedence over transpiled output. + /// The `buildSrc` folder, if present, is also linked for Gradle build scripts. + /// + /// - Parameter kotlinOutputFolder: The Kotlin output folder to link overrides into. + func setupTransformersAndOverrides(kotlinOutputFolder: AbsolutePath) throws { + let transformers = try command.createTransformers(for: baseSkipConfig, with: configMap) + let overridden = try linkSkipFolder(skipFolderPath, to: kotlinOutputFolder, topLevel: true) + self.overriddenKotlinFiles = overridden.map({ $0.basename }) - if !merge { - return (currentModuleConfig, currentModuleConfig, configMap) // just the unmerged base YAML - } + let buildSrcFolder = skipFolderPath.appending(component: buildSrcFolderName) + if fs.isDirectory(buildSrcFolder) { + try addLink(moduleBasePath.appending(component: buildSrcFolderName), pointingAt: buildSrcFolder, relative: false) + } - // build up a merged YAML from the base dependencies to the current module - var aggregateJSON: Universal.JSON = [:] + // Store transformers for use by the transpiler + self._transformers = transformers + } - for (moduleName, modulePath) in moduleNamePaths { - trace("moduleName: \(moduleName) modulePath: \(modulePath) primaryModuleName: \(primaryModuleName)") - if moduleName == primaryModuleName { - // don't merge the primary module name with itself - continue - } + /// Stored transformers, set by ``setupTransformersAndOverrides(kotlinOutputFolder:)``. + private var _transformers: [KotlinTransformer] = [] + + /// Creates and runs the transpiler, handling each transpilation result. + /// + /// Source files are categorized into transpilation targets (transpiled mode) or + /// native bridge files (native mode). The transpiler processes all files and + /// calls ``handleTranspilation(transpilation:kotlinOutputFolder:with:)`` for each result. + /// + /// - Parameters: + /// - autoBridge: The auto-bridge mode for the transpiler. + /// - dynamicRoot: The dynamic root class name, if any. + /// - kotlinOutputFolder: The Kotlin output folder path. + /// - out: The message queue for yielding results. + func runTranspiler(autoBridge: AutoBridge, dynamicRoot: String?, kotlinOutputFolder: AbsolutePath, with out: MessageQueue) async throws { + let (transpileFiles, swiftFiles) = Self.categorizeSourceFiles(sourceURLs: sourceURLs, isNative: isNativeModule) + + let transpiler = Transpiler( + packageName: packageName, + transpileFiles: transpileFiles.map(Source.FilePath.init(path:)), + bridgeFiles: swiftFiles.map(Source.FilePath.init(path:)), + autoBridge: autoBridge, + isBridgeGatherEnabled: dynamicRoot != nil, + codebaseInfo: codebaseInfo, + preprocessorSymbols: Set(command.inputOptions.symbols), + transformers: _transformers) + + try await transpiler.transpile(handler: { transpilation in + try await self.handleTranspilation(transpilation: transpilation, kotlinOutputFolder: kotlinOutputFolder, with: out) + }) + } - let moduleSkipBasePath = try AbsolutePath(validating: modulePath, relativeTo: moduleRootPath.parentDirectory) - .appending(components: ["Skip"]) - - let moduleSkipConfigPath = moduleSkipBasePath.appending(component: configFileName) - - if fs.isFile(moduleSkipConfigPath) { - let skipConfigLoadStart = Date().timeIntervalSinceReferenceDate - let isTestPeer = primaryModuleName == moduleName + "Tests" // test peers have the same module name - trace("primaryModuleName: \(primaryModuleName) moduleName: \(moduleName) isTestPeer=\(isTestPeer)") // SkipLibTests moduleName: SkipLib - let isForExport = !isTestPeer - let moduleConfig = try loadSkipYAML(path: moduleSkipConfigPath, forExport: isForExport) - configMap[moduleName] = moduleConfig // remember the raw config for use in configuring transpiler plug-ins - let skipConfigLoadEnd = Date().timeIntervalSinceReferenceDate - info("\(moduleName) skip.yml config loaded (\(Int64((skipConfigLoadEnd - skipConfigLoadStart) * 1000)) ms)", sourceFile: moduleSkipConfigPath.sourceFile) - aggregateJSON = try aggregateJSON.merged(with: moduleConfig.json()) - } - } + /// Handles a single transpilation result by writing output files and forwarding messages. + /// + /// Bridge transpilations are accumulated for later processing. Regular transpilations + /// are written to the Kotlin output folder with source mapping files. + /// + /// - Parameters: + /// - transpilation: The transpilation result to process. + /// - kotlinOutputFolder: The Kotlin output folder path. + /// - out: The message queue for yielding results. + func handleTranspilation(transpilation: Transpilation, kotlinOutputFolder: AbsolutePath, with out: MessageQueue) async throws { + for message in transpilation.messages { + await out.yield(message) + } - aggregateJSON = try aggregateJSON.merged(with: currentModuleJSON) + switch transpilation.outputType { + case .bridgeToSwift, .bridgeFromSwift: + skipBridgeTranspilations.append(transpilation) + return + case .default: + break + } - // finally, merge with a manually constructed SkipConfig that contains references to the modules this module depends on - do { - var moduleDependencyBlocks: [GradleBlock.BlockOrCommand] = [] - - for (moduleName, _) in moduleNamePaths { - // manually exclude our own module and tests names - if isTestModule(moduleName) { - if moduleName == "SkipUnit" { - moduleDependencyBlocks += [ - .init("testImplementation(project(\":\(moduleName)\"))"), - .init("androidTestImplementation(project(\":\(moduleName)\"))") - ] - } else { - moduleDependencyBlocks += [ - .init("api(project(\":\(moduleName)\"))"), - ] - } - } - } + if skipstoneOptions.skipBridgeOutput != nil { + return + } - var localConfig = GradleBlock(contents: [.init(GradleBlock(block: "dependencies", contents: moduleDependencyBlocks))]) + let sourcePath = try AbsolutePath(validating: transpilation.input.file.path) - // for app modules, import its settings into the manifestPlaceholders dictionary in the `android { defaultConfig { } }` block - if isAppModule { - var manifestConfigLines: [String] = [] + let (outputFile, changed, overridden) = try saveTranspilation(transpilation: transpilation, kotlinOutputFolder: kotlinOutputFolder) - let moduleXCConfigContents = try String(contentsOf: moduleXCConfig.asURL, encoding: .utf8) - for (key, value) in parseXCConfig(contents: moduleXCConfigContents) { - manifestConfigLines += [""" - manifestPlaceholders["\(key)"] = System.getenv("\(key)") ?: "\(value)" - """] - } + info("\(outputFile.relative(to: moduleBasePath).pathString) (\(transpilation.output.content.lengthOfBytes(using: .utf8).byteCount)) transpilation \(overridden ? "overridden" : !changed ? "unchanged" : "saved") from \(sourcePath.basename) (\(transpilation.input.content.lengthOfBytes(using: .utf8).byteCount)) in \(Int64(transpilation.duration * 1000)) ms", sourceFile: overridden ? transpilation.input.file : outputFile.sourceFile) + for message in transpilation.messages { + if message.kind == .error { + await out.finish(throwing: message) + return + } + } - // now do some manual configuration of the android properties - manifestConfigLines += [""" - applicationId = manifestPlaceholders["PRODUCT_BUNDLE_IDENTIFIER"]?.toString().replace("-", "_") - """] + let output = SkipstoneCommand.Output(transpilation: transpilation) + await out.yield(output) + } - manifestConfigLines += [""" - versionCode = (manifestPlaceholders["CURRENT_PROJECT_VERSION"]?.toString())?.toInt() - """] + /// Writes a single transpilation's Kotlin output and source map to disk. + /// + /// If the output filename has been overridden by a file in the Skip/ folder, + /// the transpiled output is skipped. + /// + /// - Parameters: + /// - transpilation: The transpilation result to save. + /// - kotlinOutputFolder: The Kotlin output folder path. + /// - Returns: A tuple of (output path, whether the file changed, whether it was overridden). + func saveTranspilation(transpilation: Transpilation, kotlinOutputFolder: AbsolutePath) throws -> (output: AbsolutePath, changed: Bool, overridden: Bool) { + trace("path: \(kotlinOutputFolder)") + + let kotlinName = transpilation.kotlinFileName + guard let outputFilePath = try Self.resolveSourceFileOutputPath( + for: kotlinName, packageName: packageName, + kotlinFolder: kotlinOutputFolder, + javaFolder: try AbsolutePath(outputFolderPath, validating: "java"), + manifestName: androidManifestName, basePath: nil) else { + throw error("No output path for \(kotlinName)") + } - manifestConfigLines += [""" - versionName = manifestPlaceholders["MARKETING_VERSION"]?.toString() - """] + if overriddenKotlinFiles.contains(kotlinName) { + return (output: outputFilePath, changed: false, overridden: true) + } - localConfig.contents?.append(.init(GradleBlock(block: "android", contents: [ - .init(GradleBlock(block: "defaultConfig", contents: manifestConfigLines.map({ .a($0) }))) - ]))) - } + let kotlinBytes = ByteString(encodingAsUTF8: transpilation.output.content) + let fileWritten = try fs.writeChanges(path: addOutputFile(outputFilePath), checkSize: true, makeReadOnly: true, bytes: kotlinBytes) - aggregateJSON = try aggregateJSON.merged(with: JSON.object(["build": localConfig.json()])) - } + trace("wrote to: \(outputFilePath)\(!fileWritten ? " (unchanged)" : "")") - var aggregateSkipConfig: SkipConfig = try aggregateJSON.decode() - // clear exports and perform final item removal - aggregateSkipConfig.build?.removeContent(withExports: true) - aggregateSkipConfig.settings?.removeContent(withExports: true) + // Save the source map file + let sourceMappingPath = outputFilePath.parentDirectory.appending(component: "." + outputFilePath.basenameWithoutExt + ".sourcemap") + let sourceMapData = try self.encoder.encode(transpilation.outputMap) + try fs.writeChanges(path: addOutputFile(sourceMappingPath), makeReadOnly: true, bytes: ByteString(sourceMapData)) - let configEnd = Date().timeIntervalSinceReferenceDate - info("skip.yml aggregate created (\(Int64((configEnd - configStart) * 1000)) ms) for modules: \(moduleNamePaths.map(\.module))") - return (currentModuleConfig, aggregateSkipConfig, configMap) - } + return (output: outputFilePath, changed: fileWritten, overridden: false) + } - func sourceFileOutputPath(for baseSourceFileName: String, in basePath: AbsolutePath? = nil) throws -> AbsolutePath? { - if baseSourceFileName == "skip.yml" { - // skip metadata files are excluded from copy - return nil - } + // MARK: - Phase 5: Output Saving & Linking - // Kotlin (.kt) files go to src/main/kotlin/package/name/File.kt, and Java (.java) files go to src/main/java/package/name/File.kt - let rawSourceDestination = baseSourceFileName.hasSuffix(".kt") ? kotlinOutputFolder : javaOutputFolder - - // the "AndroidManifest.xml" file is special: it needs to go in the root src/main/ folder - let isManifest = baseSourceFileName == AndroidManifestName - // if an empty basePath, treat as a source file and place in package-derived folders - return try (basePath ?? rawSourceDestination - .appending(components: isManifest ? [".."] : packageName.split(separator: ".").map(\.description))) - .appending(RelativePath(validating: baseSourceFileName)) - } - - /// Copies over the overridden .kt files from `ModuleNameKotlin/Skip/*.kt` into the destination folder, - /// and makes links to any subdirectories, which enables the handling of `src/main/AndroidManifest.xml` - /// and other custom resources. - /// - /// Any Kotlin files that are overridden will not be transpiled. - func linkSkipFolder(_ path: AbsolutePath, to outputFilePath: AbsolutePath, topLevel: Bool) throws -> Set { - // when we are running with SKIP_BRIDGE, don't link over any files from the skip folder - // failure to do this will result in (harmless) .kt files being copied over, but since no subsequent transpilation - // will mark those as expected output file, they will raise warnings: "removing stale output file: …" - if skipstoneOptions.skipBridgeOutput != nil { - return [] + /// Saves the codebase info JSON for consumption by downstream module transpilations. + func saveCodebaseInfo() throws { + let outputFilePath = try moduleBasePath.appending(moduleExportPath(forModule: primaryModuleName)) + let moduleExport = CodebaseInfo.ModuleExport(of: codebaseInfo) + try writeChanges(tag: "codebase", to: outputFilePath, contents: encoder.encode(moduleExport), readOnly: true) + } + + /// Saves bridge code files or sets up the native Swift link tree. + /// + /// When `--skip-bridge-output` is set, writes generated bridge Swift files + /// for each source file. Otherwise, if the module is native or has bridge + /// transpilations, creates a mirrored link tree for native Swift compilation + /// on Android. + func saveSkipBridgeCode() throws { + if let skipBridgeOutput = skipstoneOptions.skipBridgeOutput { + let skipBridgeOutputFolder = try AbsolutePath(validating: skipBridgeOutput) + + let swiftBridgeFileNameTranspilationMap = skipBridgeTranspilations.reduce(into: Dictionary()) { result, transpilation in + result[transpilation.output.file.name] = transpilation } - var copiedFiles: Set = [] - for fileName in try fs.getDirectoryContents(path) { - if fileName.hasPrefix(".") { - continue // skip hidden files + for swiftSourceFile in sourceURLs.filter({ $0.pathExtension == "swift"}) { + let swiftFileBase = swiftSourceFile.deletingPathExtension().lastPathComponent + let swiftBridgeFileName = swiftFileBase.appending(Source.FilePath.bridgeFileSuffix) + let swiftBridgeOutputPath = skipBridgeOutputFolder.appending(components: [swiftBridgeFileName]) + + let bridgeContents: String + if let bridgeTranspilation = swiftBridgeFileNameTranspilationMap[swiftBridgeFileName] { + bridgeContents = bridgeTranspilation.output.content + } else { + bridgeContents = "" } + try writeChanges(tag: "skipbridge", to: swiftBridgeOutputPath, contents: bridgeContents.utf8Data, readOnly: true) + } - if path.basename == buildSrcFolderName || fileName == buildSrcFolderName { - continue // don't copy buildSrc into resources + for supportFileName in [KotlinDynamicObjectTransformer.supportFileName, KotlinBundleTransformer.supportFileName, KotlinFoundationBridgeTransformer.supportFileName] { + let supportContents: String + if let supportTranspilation = swiftBridgeFileNameTranspilationMap[supportFileName] { + supportContents = supportTranspilation.output.content + } else { + supportContents = "" } + let supportOutputPath = skipBridgeOutputFolder.appending(components: [supportFileName]) + try writeChanges(tag: "skipbridge", to: supportOutputPath, contents: supportContents.utf8Data, readOnly: true) + } + + return + } - let sourcePath = try AbsolutePath(path, validating: fileName) - let outputPath = try AbsolutePath(outputFilePath, validating: fileName) + guard isNativeModule || !skipBridgeTranspilations.isEmpty else { + return + } - if fs.isDirectory(sourcePath) { - // make recursive folders for sub-linked resources - let subPaths = try linkSkipFolder(sourcePath, to: outputPath, topLevel: false) - copiedFiles.formUnion(subPaths) + // Link src/main/swift/ to the Swift project folder for native compilation + let swiftLinkFolder = try AbsolutePath(outputFolderPath, validating: "swift") + try fs.createDirectory(swiftLinkFolder, recursive: true) + + let packagesLinkFolder = try AbsolutePath(swiftLinkFolder, validating: "Packages") + try fs.createDirectory(packagesLinkFolder, recursive: true) + + var packageAddendum = """ + + /// Convert remote dependencies into their locally-cached versions. + /// This allows us to re-use dependencies from the parent + /// Xcode/SwiftPM process without redundently cloning them. + func useLocalPackage(named packageName: String, id packageID: String, dependencies: inout [Package.Dependency]) { + func localDependency(name: String?, location: String) -> Package.Dependency? { + if name == packageID || location.hasSuffix("/" + packageID) || location.hasSuffix("/" + packageID + ".git") { + return Package.Dependency.package(path: "Packages/" + packageID) } else { - if let outputFilePath = try sourceFileOutputPath(for: sourcePath.basename, in: topLevel ? nil : outputFilePath) { - copiedFiles.insert(outputFilePath) - try fs.createDirectory(outputFilePath.parentDirectory, recursive: true) // ensure parent exists - // we make links instead of copying so the file can be edited from the gradle project structure without needing to be manually synchronized - try addLink(outputFilePath, pointingAt: sourcePath, relative: false) - info("\(outputFilePath.relative(to: moduleBasePath).pathString) override linked from project source \(sourcePath.pathString)", sourceFile: sourcePath.sourceFile) - } + return nil + } + } + dependencies = dependencies.map { dep in + switch dep.kind { + case let .sourceControl(name: name, location: location, requirement: _): + return localDependency(name: name, location: location) ?? dep + case let .fileSystem(name: name, path: location): + return localDependency(name: name, location: location) ?? dep + default: + return dep } } - return copiedFiles } + + """ - func handleTranspilation(transpilation: Transpilation) async throws { - for message in transpilation.messages { - await out.yield(message) - } + var createdIds: Set = [] + let moduleLinkPaths = Dictionary(self.linkNamePaths, uniquingKeysWith: { $1 }) - switch transpilation.outputType { - case .bridgeToSwift, .bridgeFromSwift: - skipBridgeTranspilations.append(transpilation) - return - case .default: - break + for (targetName, packageName, var packagePath) in self.dependencyIdPaths { + let packageID = packagePath.split(separator: "/").last?.description ?? packagePath + + if !createdIds.insert(packageID).inserted { + continue } - // when we are running with SKIP_BRIDGE, we don't need to write out the Kotlin (which has already been generated in the first pass of the plugin) - if skipstoneOptions.skipBridgeOutput != nil { - //warn("suppressing transpiled Kotlin due to skipstoneOptions.skipBridgeOutput") - return + if let relativeLinkPath = moduleLinkPaths[targetName] { + let linkModuleRoot = moduleRootPath + .parentDirectory + .appending(try RelativePath(validating: relativeLinkPath)) + let linkModuleSrcMainSwift = linkModuleRoot.appending(components: "src", "main", "swift") + if fs.exists(linkModuleSrcMainSwift) { + info("override link path for \(targetName) from \(packagePath) to \(linkModuleSrcMainSwift.pathString)") + packagePath = linkModuleSrcMainSwift.pathString + } } - let sourcePath = try AbsolutePath(validating: transpilation.input.file.path) + let dependencyPackageLink = try AbsolutePath(packagesLinkFolder, validating: packageID) + let destinationPath = try AbsolutePath(validating: packagePath) + try addLink(dependencyPackageLink, pointingAt: destinationPath, relative: false) - let (outputFile, changed, overridden) = try saveTranspilation() + packageAddendum += """ + useLocalPackage(named: "\(packageName)", id: "\(packageID)", dependencies: &package.dependencies) + + """ + } - // 2 separate log messages, one linking to the source swift and the second linking to the kotlin - // this makes the log rather noisy, and isn't very useful - //if !transpilation.isSourceFileSynthetic { - // info("\(sourcePath.basename) (\(byteCount(for: .init(sourceSize)))) transpiling to \(outputFile.basename)", sourceFile: transpilation.sourceFile) - //} + let mirrorSource = projectFolderPath.appending(components: "..", "..") - info("\(outputFile.relative(to: moduleBasePath).pathString) (\(transpilation.output.content.lengthOfBytes(using: .utf8).byteCount)) transpilation \(overridden ? "overridden" : !changed ? "unchanged" : "saved") from \(sourcePath.basename) (\(transpilation.input.content.lengthOfBytes(using: .utf8).byteCount)) in \(Int64(transpilation.duration * 1000)) ms", sourceFile: overridden ? transpilation.input.file : outputFile.sourceFile) + try createMirroredLinkTree(swiftLinkFolder, pointingAt: mirrorSource, shallow: true, excluding: ["Packages", "Package.resolved", ".build", ".swiftpm", "skip-export", "build"]) { destPath, path in + self.trace("createMirroredLinkTree for \(path.pathString)->\(destPath)") - for message in transpilation.messages { - //writeMessage(message) - if message.kind == .error { - // throw the first error we see - await out.finish(throwing: message) - return - } + if path.basename == "Package.swift" && !self.dependencyIdPaths.isEmpty { + let packageContents = try self.fs.readFileContents(path).withData { $0 + packageAddendum.utf8Data } + try self.writeChanges(tag: "skippackage", to: destPath, contents: packageContents, readOnly: true) + return false + } else { + return true } + } + } - let output = Output(transpilation: transpilation) - await out.yield(output) + /// Links dependent module output directories into the current module's output tree. + /// + /// Creates symbolic links from the dependent module build outputs to the current + /// module, allowing Gradle to see all modules in a unified project structure. + /// + /// - Returns: The list of dependent module names that were linked. + func linkDependentModuleSources() throws -> [String] { + var dependentModules: [String] = [] + let moduleBasePath = moduleRootPath.parentDirectory - func saveTranspilation() throws -> (output: AbsolutePath, changed: Bool, overridden: Bool) { - // the build plug-in's output folder base will be something like ~/Library/Developer/Xcode/DerivedData/Mod-ID/SourcePackages/plugins/module-name.output/ModuleNameKotlin/skipstone/ModuleName/src/test/kotlin - trace("path: \(kotlinOutputFolder)") + for (linkModuleName, relativeLinkPath) in linkNamePaths { + let linkModulePath = try moduleBasePath.appending(RelativePath(validating: linkModuleName)) + trace("relativeLinkPath: \(relativeLinkPath) moduleBasePath: \(moduleBasePath) linkModuleName: \(linkModuleName) -> linkModulePath: \(linkModulePath)") + try createMergedRelativeLinkTree(from: linkModulePath, to: relativeLinkPath, shallow: false) + dependentModules.append(linkModuleName) + } - let kotlinName = transpilation.kotlinFileName - guard let outputFilePath = try sourceFileOutputPath(for: kotlinName) else { - throw error("No output path for \(kotlinName)") - } + return dependentModules + } +} - if overriddenKotlinFiles.contains(kotlinName) { - return (output: outputFilePath, changed: false, overridden: true) - } +extension SkipstoneSession { + /// Links resource files from the project to the output assets folder. + /// + /// Iterates through ``resourceEntries`` and dispatches each entry to either + /// ``linkCopyResources(entry:resourcesBasePath:)`` or + /// ``linkProcessResources(entry:resourcesBasePath:)`` based on its mode. + func linkResources() throws { + let resourcesOutputFolder = try AbsolutePath(outputFolderPath, validating: "assets") + let resourcesBasePath = resourcesOutputFolder + .appending(components: packageName.split(separator: ".").map(\.description)) + .appending(component: "Resources") + + for entry in resourceEntries { + if entry.isCopyMode { + try linkCopyResources(entry: entry, resourcesBasePath: resourcesBasePath) + } else { + try linkProcessResources(entry: entry, resourcesBasePath: resourcesBasePath) + } + } + } - let kotlinBytes = ByteString(encodingAsUTF8: transpilation.output.content) - let fileWritten = try fs.writeChanges(path: addOutputFile(outputFilePath), checkSize: true, makeReadOnly: true, bytes: kotlinBytes) + /// Links resources in "copy" mode, preserving the full directory hierarchy. + /// + /// In copy mode, the resource folder name is preserved as a subdirectory prefix, + /// matching Darwin's `.copy()` behavior. For example, a file at + /// `ResourcesCopy/subdir/file.txt` is linked to `Resources/ResourcesCopy/subdir/file.txt`. + /// + /// - Parameters: + /// - entry: The resource entry to link. + /// - resourcesBasePath: The base output path for resources. + private func linkCopyResources(entry: ResourceEntry, resourcesBasePath: AbsolutePath) throws { + for resourceFile in entry.urls.map(\.path).sorted() { + let resourceFileCanonical = (resourceFile as NSString).standardizingPath + guard let resourceSourceURL = moduleNamePaths.compactMap({ (_, folder) -> URL? in + let folderCanonical = (folder as NSString).standardizingPath + guard resourceFileCanonical.hasPrefix(folderCanonical) else { return nil } + let relativePath = String(resourceFileCanonical.dropFirst(folderCanonical.count)).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return URL(fileURLWithPath: relativePath, relativeTo: URL(fileURLWithPath: folderCanonical, isDirectory: true)) + }).first else { + msg(.trace, "no module root parent for \(resourceFile)") + continue + } - trace("wrote to: \(outputFilePath)\(!fileWritten ? " (unchanged)" : "")", sourceFile: outputFilePath.sourceFile) + let sourcePath = try AbsolutePath(validating: resourceSourceURL.path) + let resourceComponents = try RelativePath(validating: resourceSourceURL.relativePath).components - // also save the output line mapping file: SomeFile.kt -> .SomeFile.sourcemap - let sourceMappingPath = outputFilePath.parentDirectory.appending(component: "." + outputFilePath.basenameWithoutExt + ".sourcemap") - let encoder = JSONEncoder() - encoder.outputFormatting = [ - .sortedKeys, // needed for deterministic output - .withoutEscapingSlashes, - //.prettyPrinted, - ] - let sourceMapData = try encoder.encode(transpilation.outputMap) - try fs.writeChanges(path: addOutputFile(sourceMappingPath), makeReadOnly: true, bytes: ByteString(sourceMapData)) + let resourceSourcePath = try RelativePath(validating: resourceComponents.joined(separator: "/")) + let destinationPath = resourcesBasePath.appending(resourceSourcePath) - return (output: outputFilePath, changed: fileWritten, overridden: false) + if sourcePath.parentDirectory.basename == buildSrcFolderName { + trace("skipping resource linking for buildSrc/") + } else if isCMakeProject { + trace("skipping resource linking for CMake project") + } else if fs.isFile(sourcePath) { + info("\(destinationPath.relative(to: moduleBasePath).pathString) copying to \(sourcePath.pathString)", sourceFile: sourcePath.sourceFile) + try fs.createDirectory(destinationPath.parentDirectory, recursive: true) + try addLink(destinationPath, pointingAt: sourcePath, relative: false) } } + } - /// Links each of the resource files passed to the transpiler to the underlying source files. - /// - Returns: the list of root resource folder(s) that contain the link(s) for the resources - func linkResources() throws { - let resourcesBasePath = resourcesOutputFolder - .appending(components: packageName.split(separator: ".").map(\.description)) - .appending(component: "Resources") + /// Links resources in "process" mode, flattening the hierarchy. + /// + /// In process mode, the resource directory prefix is stripped and files are placed + /// directly in the Resources/ output folder. Special handling is applied for: + /// - `.xcstrings` files, which are converted to `.strings` and `.stringsdict` localizations + /// - `res/` prefixed resources, which are placed in the Android res/ folder + /// + /// - Parameters: + /// - entry: The resource entry to link. + /// - resourcesBasePath: The base output path for resources. + private func linkProcessResources(entry: ResourceEntry, resourcesBasePath: AbsolutePath) throws { + let resOutputFolder = try AbsolutePath(outputFolderPath, validating: "res") - for entry in resourceEntries { - if entry.isCopyMode { - try linkCopyResources(entry: entry, resourcesBasePath: resourcesBasePath) - } else { - try linkProcessResources(entry: entry, resourcesBasePath: resourcesBasePath) - } + for resourceFile in entry.urls.map(\.path).sorted() { + let resourceFileCanonical = (resourceFile as NSString).standardizingPath + guard let resourceSourceURL = moduleNamePaths.compactMap({ (_, folder) -> URL? in + let folderCanonical = (folder as NSString).standardizingPath + guard resourceFileCanonical.hasPrefix(folderCanonical) else { return nil } + let relativePath = String(resourceFileCanonical.dropFirst(folderCanonical.count)).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return URL(fileURLWithPath: relativePath, relativeTo: URL(fileURLWithPath: folderCanonical, isDirectory: true)) + }).first else { + msg(.trace, "no module root parent for \(resourceFile)") + continue } - /// Links resources in "copy" mode, preserving the directory hierarchy relative to the resource folder - func linkCopyResources(entry: ResourceEntry, resourcesBasePath: AbsolutePath) throws { - for resourceFile in entry.urls.map(\.path).sorted() { - let resourceFileCanonical = (resourceFile as NSString).standardizingPath - guard let resourceSourceURL = moduleNamePaths.compactMap({ (_, folder) -> URL? in - let folderCanonical = (folder as NSString).standardizingPath - guard resourceFileCanonical.hasPrefix(folderCanonical) else { return nil } - let relativePath = String(resourceFileCanonical.dropFirst(folderCanonical.count)).trimmingCharacters(in: CharacterSet(charactersIn: "/")) - return URL(fileURLWithPath: relativePath, relativeTo: URL(fileURLWithPath: folderCanonical, isDirectory: true)) - }).first else { - msg(.trace, "no module root parent for \(resourceFile)") - continue - } + let sourcePath = try AbsolutePath(validating: resourceSourceURL.path) - let sourcePath = try AbsolutePath(validating: resourceSourceURL.path) - let resourceComponents = try RelativePath(validating: resourceSourceURL.relativePath).components - - // In copy mode, preserve the full directory hierarchy including the resource folder name - // (e.g., "ResourcesCopy/subdir/file.txt"), matching Darwin's .copy() behavior where - // the folder name becomes a subdirectory in the bundle. - let resourceSourcePath = try RelativePath(validating: resourceComponents.joined(separator: "/")) - let destinationPath = resourcesBasePath.appending(resourceSourcePath) - - if sourcePath.parentDirectory.basename == buildSrcFolderName { - trace("skipping resource linking for buildSrc/") - } else if isCMakeProject { - trace("skipping resource linking for CMake project") - } else if fs.isFile(sourcePath) { - info("\(destinationPath.relative(to: moduleBasePath).pathString) copying to \(sourcePath.pathString)", sourceFile: sourcePath.sourceFile) - try fs.createDirectory(destinationPath.parentDirectory, recursive: true) - try addLink(destinationPath, pointingAt: sourcePath, relative: false) - } + let resourceComponents = try RelativePath(validating: resourceSourceURL.relativePath).components + let components = resourceComponents.dropFirst(1) + let resourceSourcePath = try RelativePath(validating: components.joined(separator: "/")) + + if sourcePath.parentDirectory.basename == buildSrcFolderName { + trace("skipping resource linking for buildSrc/") + } else if isCMakeProject { + trace("skipping resource linking for CMake project") + } else if sourcePath.extension == "xcstrings" { + try convertStrings(resourceSourceURL: resourceSourceURL, sourcePath: sourcePath, resourcesBasePath: resourcesBasePath) + } else { + let isAndroidRes = resourceComponents.first == "res" + let destinationPath = (isAndroidRes ? resOutputFolder : resourcesBasePath).appending(resourceSourcePath) + + if fs.isFile(sourcePath) { + info("\(destinationPath.relative(to: moduleBasePath).pathString) linking to \(sourcePath.pathString)", sourceFile: sourcePath.sourceFile) + try fs.createDirectory(destinationPath.parentDirectory, recursive: true) + try addLink(destinationPath, pointingAt: sourcePath, relative: false) + } + } + } + } + + /// Converts `.xcstrings` files to `.strings` and `.stringsdict` localization files. + /// + /// Parses the Xcode string catalog JSON and generates per-locale `.strings` files + /// (for simple translations) and `.stringsdict` plist files (for plural rules), + /// mirroring the conversion Xcode performs for iOS builds. + /// + /// - Parameters: + /// - resourceSourceURL: The URL of the `.xcstrings` file. + /// - sourcePath: The absolute path to the `.xcstrings` file. + /// - resourcesBasePath: The base output path for localization folders. + private func convertStrings(resourceSourceURL: URL, sourcePath: AbsolutePath, resourcesBasePath: AbsolutePath) throws { + let xcstrings = try JSONDecoder().decode(LocalizableStringsDictionary.self, from: Data(contentsOf: resourceSourceURL)) + let defaultLanguage = xcstrings.sourceLanguage + let locales = Set(xcstrings.strings.values.compactMap(\.localizations?.keys).joined()) + for localeId in locales { + let lprojFolder = resourcesBasePath.appending(component: localeId + ".lproj") + let locBase = sourcePath.basenameWithoutExt + + var locdict: [String: String] = [:] + var plurals: [String: [String : LocalizableStringsDictionary.StringUnit]] = [:] + + for (key, value) in xcstrings.strings { + guard let localized = value.localizations?[localeId] else { + continue + } + if let value = localized.stringUnit?.value { + locdict[key] = value + } + if let pluralDict = localized.variations?.plural { + plurals[key] = pluralDict.mapValues(\.stringUnit) } } - /// Links resources in "process" mode, flattening the hierarchy and performing special processing for .xcstrings and other files - func linkProcessResources(entry: ResourceEntry, resourcesBasePath: AbsolutePath) throws { - for resourceFile in entry.urls.map(\.path).sorted() { - let resourceFileCanonical = (resourceFile as NSString).standardizingPath - guard let resourceSourceURL = moduleNamePaths.compactMap({ (_, folder) -> URL? in - let folderCanonical = (folder as NSString).standardizingPath - guard resourceFileCanonical.hasPrefix(folderCanonical) else { return nil } - let relativePath = String(resourceFileCanonical.dropFirst(folderCanonical.count)).trimmingCharacters(in: CharacterSet(charactersIn: "/")) - return URL(fileURLWithPath: relativePath, relativeTo: URL(fileURLWithPath: folderCanonical, isDirectory: true)) - }).first else { - // skip over resources that are not contained within the resource folder - msg(.trace, "no module root parent for \(resourceFile)") - continue + if !locdict.isEmpty { + func escape(_ string: String) throws -> String? { + let writingOptions: JSONSerialization.WritingOptions + if #available(iOS 13.0, macOS 15.0, *) { + writingOptions = [ + .sortedKeys, // needed for deterministic output + .withoutEscapingSlashes, + ] + } else { + writingOptions = [ + .sortedKeys, + ] } - let sourcePath = try AbsolutePath(validating: resourceSourceURL.path) - - let resourceComponents = try RelativePath(validating: resourceSourceURL.relativePath).components - // all resources get put into a single "Resources/" folder in the jar, so drop the first item and replace it with "Resources/" - let components = resourceComponents.dropFirst(1) - let resourceSourcePath = try RelativePath(validating: components.joined(separator: "/")) - - if sourcePath.parentDirectory.basename == buildSrcFolderName { - trace("skipping resource linking for buildSrc/") - } else if isCMakeProject { - trace("skipping resource linking for CMake project") - } else if sourcePath.extension == "xcstrings" { - try convertStrings(resourceSourceURL: resourceSourceURL, sourcePath: sourcePath) - //} else if sourcePath.extension == "xcassets" { - // TODO: convert various assets into Android res/ folder - } else { // non-processed resources are just linked directly from the package - // the Android "res" folder is special: it is intended to store Android-specific resources like values/strings.xml, and will be linked into the archive's res/ folder - let isAndroidRes = resourceComponents.first == "res" - let destinationPath = (isAndroidRes ? resOutputFolder : resourcesBasePath).appending(resourceSourcePath) - - // only create links for files that exist - if fs.isFile(sourcePath) { - info("\(destinationPath.relative(to: moduleBasePath).pathString) linking to \(sourcePath.pathString)", sourceFile: sourcePath.sourceFile) - try fs.createDirectory(destinationPath.parentDirectory, recursive: true) - try addLink(destinationPath, pointingAt: sourcePath, relative: false) - } + return try String(data: JSONSerialization.data(withJSONObject: string, options: writingOptions), encoding: .utf8) + } + + var stringsContent = "" + for (key, value) in locdict.sorted(by: { $0.key < $1.key }) { + if let keyString = try escape(key), let valueString = try escape(value) { + stringsContent += keyString + " = " + valueString + ";\n" } } + try fs.createDirectory(lprojFolder, recursive: true) + if localeId == defaultLanguage { + try addLink(resourcesBasePath.appending(component: "base.lproj"), pointingAt: lprojFolder, relative: true) + } + + let localizableStrings = try RelativePath(validating: locBase + ".strings") + let localizableStringsPath = lprojFolder.appending(localizableStrings) + info("create \(localizableStrings.pathString) from \(sourcePath.pathString)", sourceFile: localizableStringsPath.sourceFile) + try writeChanges(tag: localizableStrings.pathString, to: localizableStringsPath, contents: stringsContent.utf8Data, readOnly: false) } - func convertStrings(resourceSourceURL: URL, sourcePath: AbsolutePath) throws { - // process the .xcstrings in the same way that Xcode does: parse the JSON and use the localizations keys to synthesize a LANG.lproj/TABLENAME.strings file - let xcstrings = try JSONDecoder().decode(LocalizableStringsDictionary.self, from: Data(contentsOf: resourceSourceURL)) - let defaultLanguage = xcstrings.sourceLanguage - let locales = Set(xcstrings.strings.values.compactMap(\.localizations?.keys).joined()) - for localeId in locales { - let lprojFolder = resourcesBasePath.appending(component: localeId + ".lproj") - let locBase = sourcePath.basenameWithoutExt - - var locdict: [String: String] = [:] - var plurals: [String: [String : LocalizableStringsDictionary.StringUnit]] = [:] - - for (key, value) in xcstrings.strings { - guard let localized = value.localizations?[localeId] else { - continue - } - if let value = localized.stringUnit?.value { - locdict[key] = value - } - if let pluralDict = localized.variations?.plural { - plurals[key] = pluralDict.mapValues(\.stringUnit) - } - } + if !plurals.isEmpty { + let localizableStringsDict = try RelativePath(validating: locBase + ".stringsdict") - if !locdict.isEmpty { - func escape(_ string: String) throws -> String? { - // escape quotes and newlines; we just use a JSON string fragment for this - try String(data: JSONSerialization.data(withJSONObject: string, options: [.fragmentsAllowed, .withoutEscapingSlashes]), encoding: .utf8) - } + var pluralDictNodes: [Universal.XMLNode] = [] + for (key, value) in plurals.sorted(by: { $0.key < $1.key }) { + pluralDictNodes.append(Universal.XMLNode(elementName: "key", children: [.content(key)])) - var stringsContent = "" - for (key, value) in locdict.sorted(by: { $0.key < $1.key }) { - if let keyString = try escape(key), let valueString = try escape(value) { - stringsContent += keyString + " = " + valueString + ";\n" - } - } - try fs.createDirectory(lprojFolder, recursive: true) - if localeId == defaultLanguage { - // when there is a default language, set up a symbolic link so Android localization can know where to fall back in the case of a missing localization key - try addLink(resourcesBasePath.appending(component: "base.lproj"), pointingAt: lprojFolder, relative: true) - } + var pluralsDict = Universal.XMLNode(elementName: "dict") + pluralsDict.addPlist(key: "NSStringLocalizedFormatKey", stringValue: "%#@value@") + + pluralsDict.append(Universal.XMLNode(elementName: "key", children: [.content("value")])) + var pluralsSubDict = Universal.XMLNode(elementName: "dict") + + pluralsSubDict.addPlist(key: "NSStringFormatSpecTypeKey", stringValue: "NSStringPluralRuleType") + pluralsSubDict.addPlist(key: "NSStringFormatValueTypeKey", stringValue: "lld") - let localizableStrings = try RelativePath(validating: locBase + ".strings") // e.g., fr.lproj/Localizable.strings - let localizableStringsPath = lprojFolder.appending(localizableStrings) - info("create \(localizableStrings.pathString) from \(sourcePath.pathString)", sourceFile: localizableStringsPath.sourceFile) - try writeChanges(tag: localizableStrings.pathString, to: localizableStringsPath, contents: stringsContent.utf8Data, readOnly: false) + for (pluralType, stringUnit) in value.sorted(by: { $0.key < $1.key }) { + if let stringUnitValue = stringUnit.value { + pluralsSubDict.addPlist(key: pluralType, stringValue: stringUnitValue) + } } + pluralsDict.append(pluralsSubDict) + pluralDictNodes.append(pluralsDict) + } - if !plurals.isEmpty { - let localizableStringsDict = try RelativePath(validating: locBase + ".stringsdict") // e.g., fr.lproj/Localizable.stringsdict + let pluralDict = Universal.XMLNode(elementName: "dict", children: pluralDictNodes.map({ .element($0) })) - var pluralDictNodes: [Universal.XMLNode] = [] - for (key, value) in plurals.sorted(by: { $0.key < $1.key }) { - pluralDictNodes.append(Universal.XMLNode(elementName: "key", children: [.content(key)])) + let stringsDictPlist = Universal.XMLNode(elementName: "plist", attributes: ["version": "1.0"], children: [.element(pluralDict)]) + let stringsDictDocument = Universal.XMLNode(elementName: "", children: [.element(stringsDictPlist)]) - var pluralsDict = Universal.XMLNode(elementName: "dict") - pluralsDict.addPlist(key: "NSStringLocalizedFormatKey", stringValue: "%#@value@") + let localizableStringsDictPath = lprojFolder.appending(localizableStringsDict) + info("create \(localizableStringsDict.pathString) from \(sourcePath.pathString)", sourceFile: localizableStringsDictPath.sourceFile) + try writeChanges(tag: localizableStringsDict.pathString, to: localizableStringsDictPath, contents: stringsDictDocument.xmlString().utf8Data, readOnly: false) + } + } + } +} - pluralsDict.append(Universal.XMLNode(elementName: "key", children: [.content("value")])) - var pluralsSubDict = Universal.XMLNode(elementName: "dict") +extension SkipstoneSession { + // MARK: - Phase 6: Gradle Generation + + /// Generates all Gradle build files for the module. + /// + /// Creates the per-module `build.gradle.kts`, the project `settings.gradle.kts`, + /// `gradle.properties`, `proguard-rules.pro`, and the Gradle wrapper properties. + /// + /// - Parameters: + /// - sourceModules: The dependent source module names to include in settings. + /// - skipConfig: The merged skip.yml configuration. + /// - isApp: Whether this is an app module (affects Gradle plugin selection). + func generateGradle(for sourceModules: [String], with skipConfig: SkipConfig, isApp: Bool) throws { + let buildGradle = moduleRootPath.appending(component: "build.gradle.kts") + try generateGradleWrapperProperties() + try generateProguardFile(packageName) + try generatePerModuleGradle(config: skipConfig, buildGradle: buildGradle) + try generateGradleProperties(config: skipConfig) + try generateSettingsGradle(sourceModules: sourceModules, config: skipConfig) + } - pluralsSubDict.addPlist(key: "NSStringFormatSpecTypeKey", stringValue: "NSStringPluralRuleType") - pluralsSubDict.addPlist(key: "NSStringFormatValueTypeKey", stringValue: "lld") + /// Generates the per-module `build.gradle.kts` file from the merged config. + /// + /// - Parameters: + /// - config: The merged skip.yml configuration. + /// - buildGradle: The output path for build.gradle.kts. + private func generatePerModuleGradle(config: SkipConfig, buildGradle: AbsolutePath) throws { + let buildContents = (config.build ?? .init()).generate(context: .init(dsl: .kotlin)) - for (pluralType, stringUnit) in value.sorted(by: { $0.key < $1.key }) { - // pluralType is zero, one, two, few, many, other - if let stringUnitValue = stringUnit.value { - pluralsSubDict.addPlist(key: pluralType, stringValue: stringUnitValue) - } - } - pluralsDict.append(pluralsSubDict) - pluralDictNodes.append(pluralsDict) - } + trace("created gradle: \(buildContents.split(separator: "\n").map({ $0.trimmingCharacters(in: .whitespaces) }).joined(separator: "; "))") - let pluralDict = Universal.XMLNode(elementName: "dict", children: pluralDictNodes.map({ .element($0) })) + let contents = """ + // build.gradle.kts generated by Skip for \(primaryModuleName) + + """ + buildContents - let stringsDictPlist = Universal.XMLNode(elementName: "plist", attributes: ["version": "1.0"], children: [.element(pluralDict)]) - let stringsDictDocument = Universal.XMLNode(elementName: "", children: [.element(stringsDictPlist)]) + try writeChanges(tag: "gradle project", to: buildGradle, contents: contents.utf8Data, readOnly: true) + } - let localizableStringsDictPath = lprojFolder.appending(localizableStringsDict) - info("create \(localizableStringsDict.pathString) from \(sourcePath.pathString)", sourceFile: localizableStringsDictPath.sourceFile) - try writeChanges(tag: localizableStringsDict.pathString, to: localizableStringsDictPath, contents: stringsDictDocument.xmlString().utf8Data, readOnly: false) - } - } + /// Generates the project `settings.gradle.kts` with module includes. + /// + /// Includes the primary module and all dependent source modules. For native + /// (bridged) modules, adds them to the `bridgeModules` Gradle extra property. + /// + /// - Parameters: + /// - sourceModules: The dependent source module names. + /// - config: The merged skip.yml configuration. + private func generateSettingsGradle(sourceModules: [String], config: SkipConfig) throws { + let settingsPath = moduleRootPath.parentDirectory.appending(component: "settings.gradle.kts") + var settingsContents = (config.settings ?? .init()).generate(context: .init(dsl: .kotlin)) + + settingsContents += """ + + rootProject.name = "\(packageName ?? "")" + + """ + + var bridgedModules: [String] = [] + + func addIncludeModule(_ moduleName: String) { + settingsContents += """ + include(":\(moduleName)") + project(":\(moduleName)").projectDir = file("\(moduleName)") + + """ + + if Self.resolveModuleMode(moduleName: moduleName, configMap: configMap, baseConfig: baseSkipConfig, hasSkipFuse: hasSkipFuse, primaryModuleName: primaryModuleName) == .native { + bridgedModules.append(moduleName) } } - // NOTE: when linking between modules, SPM and Xcode will use different output paths: - // Xcode: ~/Library/Developer/Xcode/DerivedData/PROJECT-ID/SourcePackages/plugins/skiphub.output/SkipFoundationKotlinTests/skipstone/SkipFoundation - // SPM: .build/plugins/outputs/skiphub/ - func linkDependentModuleSources() throws -> [String] { - var dependentModules: [String] = [] - // transpilation was successful; now set up links to the other output packages (located in different plug-in folders) - let moduleBasePath = moduleRootPath.parentDirectory + if !sourceModules.contains(primaryModuleName) && !primaryModuleName.hasSuffix("Tests") { + addIncludeModule(primaryModuleName) + } + for sourceModule in sourceModules { + addIncludeModule(sourceModule) + } - // for each of the specified link/path pairs, create symbol links, either to the base folders, or the the sub-folders that share a common root - // this is the logic that allows us to merge two modules (like MyMod and MyModTests) into a single Kotlin module with the idiomatic src/main/kotlin/ and src/test/kotlin/ pair of folders - for (linkModuleName, relativeLinkPath) in linkNamePaths { - let linkModulePath = try moduleBasePath.appending(RelativePath(validating: linkModuleName)) - trace("relativeLinkPath: \(relativeLinkPath) moduleBasePath: \(moduleBasePath) linkModuleName: \(linkModuleName) -> linkModulePath: \(linkModulePath)") - try createMergedRelativeLinkTree(from: linkModulePath, to: relativeLinkPath, shallow: false) - dependentModules.append(linkModuleName) - } + if !bridgedModules.isEmpty { + settingsContents += """ + + gradle.extra["bridgeModules"] = listOf("\(bridgedModules.joined(separator: "\", \""))") + + """ + } + + try writeChanges(tag: "gradle settings", to: settingsPath, contents: settingsContents.utf8Data, readOnly: true) + } + + /// Generates the `proguard-rules.pro` file for release build optimization. + /// + /// - Parameter packageName: The Kotlin package name for keep rules. + private func generateProguardFile(_ packageName: String) throws { + try writeChanges(tag: "proguard", to: moduleRootPath.appending(component: "proguard-rules.pro"), contents: FrameworkProjectLayout.defaultProguardContents(packageName).utf8Data, readOnly: true) + } + + /// Generates the `gradle-wrapper.properties` file specifying the Gradle distribution version. + private func generateGradleWrapperProperties() throws { + let gradleWrapperFolder = moduleRootPath.parentDirectory.appending(components: "gradle", "wrapper") + try fs.createDirectory(gradleWrapperFolder, recursive: true) + let gradleWrapperPath = gradleWrapperFolder.appending(component: "gradle-wrapper.properties") + let gradeWrapperContents = FrameworkProjectLayout.defaultGradleWrapperProperties() + try writeChanges(tag: "gradle wrapper", to: gradleWrapperPath, contents: gradeWrapperContents.utf8Data, readOnly: true) + } - return dependentModules + /// Generates the `gradle.properties` file, merging defaults with custom properties from skip.yml. + /// + /// - Parameter config: The merged skip.yml configuration containing optional custom properties. + private func generateGradleProperties(config: SkipConfig) throws { + let gradlePropertiesPath = moduleRootPath.parentDirectory.appending(component: "gradle.properties") + let contents = Self.mergeGradleProperties( + defaults: FrameworkProjectLayout.defaultGradleProperties(), + custom: config.gradleProperties) + try writeChanges(tag: "gradle config", to: gradlePropertiesPath, contents: contents.utf8Data, readOnly: true) + } +} + +extension SkipstoneSession { + // MARK: - Phase 7: Cleanup + + /// Removes stale output files and writes the sourcehash completion marker. + /// + /// Called in a `defer` block to ensure cleanup happens even on error. + func finalizeSession() { + cleanupStaleOutputFiles() + do { + try saveSourcehashFile() + } catch { + warn("could not create build completion marker: \(error)") } + } - /// Attempts to make a link from the `fromPath` to the given relative path. - /// If `fromPath` already exists and is a directory, attempt to create links for each of the contents of the directory to the updated relative folder - func createMergedRelativeLinkTree(from fromPath: AbsolutePath, to relative: String, shallow: Bool) throws { - let destPath = try AbsolutePath(validating: relative, relativeTo: fromPath.parentDirectory) - if !fs.isDirectory(destPath) { - // skip over anything that is not a destination folder - // if it doesn't exist at all, then it is an error - if !fs.exists(destPath) { - warn("Expected destination path did not exist: \(destPath)") - } - return + /// Removes output files from previous runs that are no longer being produced. + /// + /// Compares the ``outputFilesSnapshot`` taken at session start against the + /// ``outputFiles`` accumulated during this run. Files present in the snapshot + /// but not in the current outputs are considered stale and removed. + /// `Package.resolved` is excluded since it's managed by the native build system. + func cleanupStaleOutputFiles() { + let staleFiles = Self.identifyStaleFiles(snapshot: outputFilesSnapshot, outputFiles: outputFiles) + for staleFile in staleFiles.sorted() { + let staleFileURL = URL(fileURLWithPath: staleFile, isDirectory: false) + if staleFileURL.lastPathComponent == "Package.resolved" { + continue } - trace("creating merged link tree from: \(fromPath) to: \(relative)") - if fs.isSymlink(fromPath) { - removePath(fromPath) // clear any pre-existing symlink + msg(.warning, "removing stale output file: \(staleFileURL.lastPathComponent)", sourceFile: try? staleFileURL.absolutePath.sourceFile) + + do { + try FileManager.default.trash(fileURL: staleFileURL, trash: false) + } catch { + msg(.warning, "error removing stale output file: \(staleFileURL.lastPathComponent): \(error)") } + } + } - // the folder is a directory; recurse into the destination paths in order to link to the local paths - if !shallow && fs.isDirectory(fromPath) { - for fsEntry in try fs.getDirectoryContents(destPath) { - let fromSubPath = fromPath.appending(try RelativePath(validating: fsEntry)) - // bump up all the relative links to account for the folder we just recursed into. - // e.g.: ../SomeSharedRoot/OtherModule/ - // becomes: ../../SomeSharedRoot/OtherModule/someFolder/ - try createMergedRelativeLinkTree(from: fromSubPath, to: "../" + relative + "/" + fsEntry, shallow: shallow) - } - } else { - try addLink(fromPath, pointingAt: destPath, relative: true) + /// Writes the sourcehash marker file with current source file hashes. + /// + /// The marker file signals to the build plugin host that the transpilation + /// is complete and records the source hashes for future change detection. + func saveSourcehashFile() throws { + if !fs.isDirectory(moduleBasePath) { + try fs.createDirectory(moduleBasePath, recursive: true) + } + + struct SourcehashContents : Encodable { + let skipstone: String = skipVersion + let sourcehashes: [String: String] + } + + let sourcePathHashes: [(String, String)] = sourcehashes.compactMap { url, sourcehash in + let absolutePath = url.path + if !absolutePath.hasPrefix(projectFolderPath.pathString) { + return .none } + + let relativePath = absolutePath.dropFirst(projectFolderPath.pathString.count).trimmingPrefix(while: { $0 == "/" }) + return (relativePath.description, sourcehash) } - /// Create a mirror hierarchy of the directory structure at `from` in the folder specified by `to`, and link each individual file in the hierarchy - func createMirroredLinkTree(_ destPath: AbsolutePath, pointingAt fromPath: AbsolutePath, shallow: Bool, excluding excludePaths: Set = [], contentHandler: ((_ destPath: AbsolutePath, _ fromPath: AbsolutePath) throws -> Bool)? = nil) throws { - trace("creating absolute merged link tree from: \(fromPath) to: \(destPath)") - // the folder is a directory; recurse into the destination paths in order to link to the local paths - if fs.isDirectory(fromPath) { - // we create output directories and link the contents, rather than just linking the folders themselves, since Gradle wants to be able to write to the output folders - try fs.createDirectory(destPath, recursive: true) - for fsEntry in try fs.getDirectoryContents(fromPath) { - if fsEntry.hasPrefix(".") || excludePaths.contains(fsEntry) { - continue - } - let rel = try RelativePath(validating: fsEntry) - let childDestPath = destPath.appending(rel) - let childFromPath = fromPath.appending(rel) - if shallow { - if try contentHandler?(childDestPath, childFromPath) != false { - try addLink(childDestPath, pointingAt: childFromPath, relative: false) - } - } else { - try createMirroredLinkTree(childDestPath, pointingAt: childFromPath, shallow: shallow, contentHandler: contentHandler) - } - } - } else if fs.isFile(fromPath) { - // check whether the contentHandler want to override linking the file - if try contentHandler?(destPath, fromPath) != false { - try addLink(destPath, pointingAt: fromPath, relative: false) - } else { - warn("unknown file type encountered when creating links: \(fromPath)") - } + let sourcehashOutputPath = try AbsolutePath(validating: skipstoneOptions.sourcehash) + let sourcehash = SourcehashContents(sourcehashes: Dictionary(sourcePathHashes, uniquingKeysWith: { $1 })) + try writeChanges(tag: "sourcehash", to: sourcehashOutputPath, contents: try encoder.encode(sourcehash), readOnly: false) + } +} + +extension SkipstoneSession { + // MARK: - File Operation Helpers + + /// Registers a path as an output file to prevent stale file cleanup. + /// + /// Every file written or linked during the session must be registered via this method + /// so it is not removed during ``cleanupStaleOutputFiles()``. + /// + /// - Parameter path: The output file path to register. + /// - Returns: The same path, for convenient chaining. + @discardableResult func addOutputFile(_ path: AbsolutePath) -> AbsolutePath { + outputFiles.append(path) + return path + } + + /// Registers a path as an input file for modification time tracking. + /// + /// - Parameter path: The input file path to register. + /// - Returns: The same path, for convenient chaining. + @discardableResult func addInputFile(_ path: AbsolutePath) -> AbsolutePath { + inputFiles.append(path) + return path + } + + /// Reads a source file's contents and registers it as an input file. + /// + /// - Parameter path: The file to read. + /// - Returns: The file contents as a `ByteString`. + func inputSource(_ path: AbsolutePath) throws -> ByteString { + try fs.readFileContents(addInputFile(path)) + } + + /// Writes content to an output file if it has changed, tracking the file as output. + /// + /// - Parameters: + /// - tag: A descriptive tag for logging (e.g., "gradle project", "codebase"). + /// - outputFilePath: The destination file path. + /// - contents: The content to write. + /// - readOnly: Whether to make the file read-only after writing. + func writeChanges(tag: String, to outputFilePath: AbsolutePath, contents: any DataProtocol, readOnly: Bool) throws { + let changed = try fs.writeChanges(path: addOutputFile(outputFilePath), makeReadOnly: readOnly, bytes: ByteString(contents)) + info("\(outputFilePath.relative(to: moduleBasePath).pathString) (\(contents.count.byteCount)) \(tag) \(!changed ? "unchanged" : "written")", sourceFile: outputFilePath.sourceFile) + } + + /// Creates a symbolic link (or copy for read-only files) from source to destination. + /// + /// For read-only files, a copy is made instead of a symlink to avoid Gradle write + /// permission failures on subsequent builds. The output link's modification time + /// is set to match the destination for accurate change detection. + /// + /// - Parameters: + /// - linkSource: The path where the link/copy will be created. + /// - destPath: The target path the link points to. + /// - relative: Whether to create a relative (vs absolute) symlink. + /// - replace: Whether to replace existing symlinks. Defaults to true. + /// - copyReadOnlyFiles: Whether to copy instead of link read-only files. Defaults to true. + func addLink(_ linkSource: AbsolutePath, pointingAt destPath: AbsolutePath, relative: Bool, replace: Bool = true, copyReadOnlyFiles: Bool = true) throws { + msg(.trace, "linking: \(linkSource) to: \(destPath)") + + if replace && fs.isSymlink(destPath) { + removePath(destPath) + } + + if let existingSymlinkDestination = try? FileManager.default.destinationOfSymbolicLink(atPath: linkSource.pathString) { + if existingSymlinkDestination == destPath.pathString { + msg(.trace, "retaining existing link from \(destPath.pathString) to \(existingSymlinkDestination)") + addOutputFile(linkSource) + return } } - @discardableResult - func removePath(_ path: AbsolutePath) -> Bool { - do { - if !fs.exists(path, followSymlink: false) { - return false - } - try fs.removeFileTree(path) - return true - } catch { - warn("unable to remove entry \(path): \(error)", sourceFile: path.sourceFile) + let destInfo = try fs.getFileInfo(destPath) + let modTime = destInfo.modTime + let perms = destInfo.posixPermissions + + let writablePermissions = perms | 0o200 + + let shouldCopy = copyReadOnlyFiles && !fs.isDirectory(linkSource) && (perms != writablePermissions) + + removePath(linkSource) + if shouldCopy { + msg(.trace, "copying \(destPath) to \(linkSource)") + try fs.copy(from: destPath, to: addOutputFile(linkSource)) + try FileManager.default.setAttributes([.posixPermissions: writablePermissions], ofItemAtPath: linkSource.pathString) + } else { + msg(.trace, "linking \(destPath) to \(linkSource)") + try fs.createSymbolicLink(addOutputFile(linkSource), pointingAt: destPath, relative: relative) + } + + try (linkSource.asURL as NSURL).setResourceValue(modTime, forKey: .contentModificationDateKey) + } + + /// Removes a file or directory, tolerating non-existent paths. + /// + /// - Parameter path: The path to remove. + /// - Returns: true if the path existed and was removed, false otherwise. + @discardableResult + func removePath(_ path: AbsolutePath) -> Bool { + do { + if !fs.exists(path, followSymlink: false) { return false } + try fs.removeFileTree(path) + return true + } catch { + warn("unable to remove entry \(path): \(error)", sourceFile: path.sourceFile) + return false } } - /// Generate transpiler transformers from the given skip config - func createTransformers(for config: SkipConfig, with moduleMap: [String: SkipConfig]) throws -> [KotlinTransformer] { - var transformers: [KotlinTransformer] = builtinKotlinTransformers() + // MARK: - Link Tree Operations + + /// Resolves the output path for a source file, accounting for file type and package structure. + /// + /// This is a convenience instance method that delegates to the static version + /// using the session's configured paths. + /// + /// - Parameters: + /// - baseSourceFileName: The source file's base name (e.g., "MyClass.kt"). + /// - basePath: Optional override base path. + /// - Returns: The resolved output path, or nil if the file should be skipped. + func sourceFileOutputPath(for baseSourceFileName: String, in basePath: AbsolutePath? = nil) throws -> AbsolutePath? { + try Self.resolveSourceFileOutputPath( + for: baseSourceFileName, + packageName: packageName, + kotlinFolder: try AbsolutePath(outputFolderPath, validating: "kotlin"), + javaFolder: try AbsolutePath(outputFolderPath, validating: "java"), + manifestName: androidManifestName, + basePath: basePath) + } - let configOptions = config.skip?.bridgingOptions() ?? [] - let transformerOptions = KotlinBridgeOptions.parse(configOptions) - transformers.append(KotlinBridgeTransformer(options: transformerOptions)) + /// Copies override .kt files from the Skip/ folder into the output, and links subdirectories. + /// + /// Any Kotlin file in the Skip/ folder takes precedence over the transpiled version. + /// Subdirectories are recursively linked to support custom Android resources and manifests. + /// + /// - Parameters: + /// - path: The Skip/ folder path to scan. + /// - outputFilePath: The destination output folder. + /// - topLevel: Whether this is the top-level Skip/ folder (affects path resolution). + /// - Returns: The set of output file paths that were overridden. + func linkSkipFolder(_ path: AbsolutePath, to outputFilePath: AbsolutePath, topLevel: Bool) throws -> Set { + if skipstoneOptions.skipBridgeOutput != nil { + return [] + } - if let root = config.skip?.dynamicroot { - transformers.append(KotlinDynamicObjectTransformer(root: root)) + var copiedFiles: Set = [] + for fileName in try fs.getDirectoryContents(path) { + if fileName.hasPrefix(".") { + continue + } + + if path.basename == buildSrcFolderName || fileName == buildSrcFolderName { + continue + } + + let sourcePath = try AbsolutePath(path, validating: fileName) + let outputPath = try AbsolutePath(outputFilePath, validating: fileName) + + if fs.isDirectory(sourcePath) { + let subPaths = try linkSkipFolder(sourcePath, to: outputPath, topLevel: false) + copiedFiles.formUnion(subPaths) + } else { + if let outputFilePath = try sourceFileOutputPath(for: sourcePath.basename, in: topLevel ? nil : outputFilePath) { + copiedFiles.insert(outputFilePath) + try fs.createDirectory(outputFilePath.parentDirectory, recursive: true) + try addLink(outputFilePath, pointingAt: sourcePath, relative: false) + info("\(outputFilePath.relative(to: moduleBasePath).pathString) override linked from project source \(sourcePath.pathString)", sourceFile: sourcePath.sourceFile) + } + } } + return copiedFiles + } - return transformers + /// Creates merged relative symbolic links from one module's output to another. + /// + /// If the destination is a directory and the source already exists as a directory, + /// recursively creates links for each child. Otherwise, creates a single relative link. + /// + /// - Parameters: + /// - fromPath: The path to create the link at. + /// - relative: The relative path to the link target. + /// - shallow: Whether to create shallow (non-recursive) links. + func createMergedRelativeLinkTree(from fromPath: AbsolutePath, to relative: String, shallow: Bool) throws { + let destPath = try AbsolutePath(validating: relative, relativeTo: fromPath.parentDirectory) + if !fs.isDirectory(destPath) { + if !fs.exists(destPath) { + warn("Expected destination path did not exist: \(destPath)") + } + return + } + trace("creating merged link tree from: \(fromPath) to: \(relative)") + if fs.isSymlink(fromPath) { + removePath(fromPath) + } + + if !shallow && fs.isDirectory(fromPath) { + for fsEntry in try fs.getDirectoryContents(destPath) { + let fromSubPath = fromPath.appending(try RelativePath(validating: fsEntry)) + try createMergedRelativeLinkTree(from: fromSubPath, to: "../" + relative + "/" + fsEntry, shallow: shallow) + } + } else { + try addLink(fromPath, pointingAt: destPath, relative: true) + } } - func loadSourceHashes(from allSourceURLs: [URL]) async throws -> [URL: String] { - // take a snapshot of all the source hashes for each of the URLs so we know when anything has changes - // TODO: this doesn't need to be a full SHA256 hash, it can be something faster (or maybe even just a snapshot of the file's size and last modified date…) - let sourcehashes = try await withThrowingTaskGroup(of: (URL, String).self) { group in - for url in allSourceURLs { - group.addTask { - let data = try Data(contentsOf: url, options: .mappedIfSafe) - return (url, data.SHA256Hash()) + /// Creates a mirror of a directory structure using symbolic links. + /// + /// Recursively traverses the source directory and creates corresponding links + /// in the destination. A content handler can intercept individual files to + /// provide custom handling (e.g., modifying Package.swift). + /// + /// - Parameters: + /// - destPath: The destination path to create the mirror at. + /// - fromPath: The source path to mirror. + /// - shallow: Whether to create shallow links (link children, don't recurse). + /// - excludePaths: Set of filenames to exclude from the mirror. + /// - contentHandler: Optional handler called for each file. Return false to skip linking. + func createMirroredLinkTree(_ destPath: AbsolutePath, pointingAt fromPath: AbsolutePath, shallow: Bool, excluding excludePaths: Set = [], contentHandler: ((_ destPath: AbsolutePath, _ fromPath: AbsolutePath) throws -> Bool)? = nil) throws { + trace("creating absolute merged link tree from: \(fromPath) to: \(destPath)") + if fs.isDirectory(fromPath) { + try fs.createDirectory(destPath, recursive: true) + for fsEntry in try fs.getDirectoryContents(fromPath) { + if fsEntry.hasPrefix(".") || excludePaths.contains(fsEntry) { + continue + } + let rel = try RelativePath(validating: fsEntry) + let childDestPath = destPath.appending(rel) + let childFromPath = fromPath.appending(rel) + if shallow { + if try contentHandler?(childDestPath, childFromPath) != false { + try addLink(childDestPath, pointingAt: childFromPath, relative: false) + } + } else { + try createMirroredLinkTree(childDestPath, pointingAt: childFromPath, shallow: shallow, contentHandler: contentHandler) } } + } else if fs.isFile(fromPath) { + if try contentHandler?(destPath, fromPath) != false { + try addLink(destPath, pointingAt: fromPath, relative: false) + } else { + warn("unknown file type encountered when creating links: \(fromPath)") + } + } + } - var results = [URL: String]() - results.reserveCapacity(allSourceURLs.count) + // MARK: - Utility Helpers - for try await (url, sha256) in group { - results[url] = sha256 - } + /// Returns the relative path for a module's codebase info JSON file. + /// + /// - Parameter moduleName: The module name. + /// - Returns: The relative path like "ModuleName.skipcode.json". + func moduleExportPath(forModule moduleName: String) throws -> RelativePath { + try RelativePath(validating: moduleName + skipcodeExtension) + } - return results + // MARK: - Static Pure Functions (Testable) + + /// Determines the output path for a source file based on its type and the package structure. + /// + /// - Kotlin (`.kt`) files are placed under `kotlinFolder/package/path/File.kt` + /// - Java (`.java`) files are placed under `javaFolder/package/path/File.java` + /// - `AndroidManifest.xml` is placed one level up from the type-specific folder + /// - `skip.yml` files return nil (excluded from output) + /// + /// - Parameters: + /// - fileName: The source file's base name. + /// - packageName: The Kotlin package name (e.g., "skip.foundation"). + /// - kotlinFolder: The base Kotlin output folder. + /// - javaFolder: The base Java output folder. + /// - manifestName: The Android manifest filename. + /// - basePath: Optional override base path; when set, the file is placed relative to it. + /// - Returns: The resolved output path, or nil if the file should be skipped. + static func resolveSourceFileOutputPath( + for fileName: String, + packageName: String, + kotlinFolder: AbsolutePath, + javaFolder: AbsolutePath, + manifestName: String, + basePath: AbsolutePath? + ) throws -> AbsolutePath? { + if fileName == "skip.yml" { + return nil } - return sourcehashes + let rawSourceDestination = fileName.hasSuffix(".kt") ? kotlinFolder : javaFolder + + let isManifest = fileName == manifestName + return try (basePath ?? rawSourceDestination + .appending(components: isManifest ? [".."] : packageName.split(separator: ".").map(\.description))) + .appending(RelativePath(validating: fileName)) } -} -struct SkipstoneCommandOptions: ParsableArguments { - @Option(name: [.customLong("project"), .long], help: ArgumentHelp("The project folder to transpile", valueName: "folder")) - var projectFolder: String // --project + /// Determines the module mode for a given module based on skip.yml configuration. + /// + /// The mode controls how the module is processed: + /// - `.native` — Swift is compiled natively on Android via the Swift toolchain. + /// - `.transpiled` — Swift is transpiled to Kotlin. + /// + /// When the mode is `"automatic"` (the default), the presence of SkipFuse in the + /// dependency graph causes the primary module to use native mode. + /// + /// - Parameters: + /// - moduleName: The module to check, or nil for the primary module. + /// - configMap: Map of module names to their skip.yml configs. + /// - baseConfig: The base skip.yml config for the current module. + /// - hasSkipFuse: Whether SkipFuse is in the dependency graph. + /// - primaryModuleName: The primary module name being processed. + /// - Returns: The resolved module mode. + static func resolveModuleMode( + moduleName: String?, + configMap: [String: SkipConfig], + baseConfig: SkipConfig, + hasSkipFuse: Bool, + primaryModuleName: String + ) -> ModuleMode { + let moduleMode: String? + + if let moduleName { + moduleMode = configMap[moduleName]?.skip?.mode + } else { + moduleMode = baseConfig.skip?.mode + } - @Option(name: [.long], help: ArgumentHelp("The path to the source hash file to output", valueName: "path")) - var sourcehash: String // --sourcehash + switch moduleMode { + case "native": return .native + case "transpiled": return .transpiled + case "automatic", .none: return hasSkipFuse && (moduleName == primaryModuleName || moduleName == nil) ? .native : .transpiled + default: + return .transpiled + } + } - @Option(name: [.customLong("module")], help: ArgumentHelp("ModuleName:SourcePath", valueName: "module")) - var moduleNames: [String] = [] // --module name:path + /// Determines whether a module is a test dependency (not the primary or its test peer). + /// + /// A module is NOT a test module if: + /// - It equals the primary module name, OR + /// - It equals the primary module name with "Tests" stripped (the test peer relationship) + /// + /// This is used to determine Gradle dependency types: test modules get + /// `testImplementation` while non-test modules get `api`. + /// + /// - Parameters: + /// - moduleName: The module name to check. + /// - primaryModuleName: The primary module name. + /// - Returns: true if the module is a test dependency. + static func isTestModule(_ moduleName: String, primaryModuleName: String) -> Bool { + primaryModuleName != moduleName && primaryModuleName != moduleName + "Tests" + } - @Option(name: [.customLong("link")], help: ArgumentHelp("ModuleName:LinkPath", valueName: "module")) - var linkPaths: [String] = [] // --link name:path + /// Identifies output files from a previous run that are no longer being produced. + /// + /// Takes the set difference between the snapshot file paths and the current output + /// file paths. `Package.resolved` exclusion is handled by the caller. + /// + /// - Parameters: + /// - snapshot: URLs of files that existed before the current run. + /// - outputFiles: Paths of files produced during the current run. + /// - Returns: Set of stale file path strings that should be cleaned up. + static func identifyStaleFiles(snapshot: [URL], outputFiles: [AbsolutePath]) -> Set { + Set(snapshot.map(\.path)) + .subtracting(outputFiles.map(\.pathString)) + } - @Option(help: ArgumentHelp("Path to the folder that contains skip.yml and overrides", valueName: "path")) - var skipFolder: String? = nil // --skip-folder + /// Partitions source file URLs into transpilation targets and native bridge files. + /// + /// In native mode, all files become bridge files (compiled natively on Android). + /// In transpiled mode, all files become transpilation targets. + /// + /// - Parameters: + /// - sourceURLs: The source file URLs to categorize. + /// - isNative: Whether the module uses native mode. + /// - Returns: Tuple of (transpileFiles, swiftFiles) as sorted path strings. + static func categorizeSourceFiles(sourceURLs: [URL], isNative: Bool) -> (transpile: [String], swift: [String]) { + var transpileFiles: [String] = [] + var swiftFiles: [String] = [] + for sourceFile in sourceURLs.map(\.path).sorted() { + if isNative { + swiftFiles.append(sourceFile) + } else { + transpileFiles.append(sourceFile) + } + } + return (transpile: transpileFiles, swift: swiftFiles) + } - @Option(help: ArgumentHelp("Path to the output module root folder", valueName: "path")) - var moduleRoot: String? = nil // --module-root + /// Merges default Gradle properties with custom overrides from skip.yml. + /// + /// Parses the default properties string into key-value pairs, overlays custom + /// properties, and produces a sorted output string. Comments and blank lines + /// from the defaults are discarded. + /// + /// - Parameters: + /// - defaults: The default Gradle properties as a multi-line string. + /// - custom: Optional custom properties that override or extend defaults. + /// - Returns: The merged properties as a newline-terminated string. + static func mergeGradleProperties(defaults: String, custom: [String: String]?) -> String { + var properties: [String: String] = [:] + + for line in defaults.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { + continue + } + let parts = trimmed.split(separator: "=", maxSplits: 1) + if parts.count == 2 { + let key = String(parts[0]).trimmingCharacters(in: .whitespaces) + let value = String(parts[1]).trimmingCharacters(in: .whitespaces) + properties[key] = value + } + } - @Option(name: [.customShort("D", allowingJoined: true)], help: ArgumentHelp("Set preprocessor variable for transpilation", valueName: "value")) - var preprocessorVariables: [String] = [] + if let custom { + for (key, value) in custom { + properties[key] = value + } + } - @Option(name: [.long], help: ArgumentHelp("Output directory", valueName: "dir")) - var outputFolder: String? = nil + var result = "" + for (key, value) in properties.sorted(by: { $0.key < $1.key }) { + result += "\(key)=\(value)\n" + } + result += "\n" + return result + } - @Option(name: [.customLong("dependency")], help: ArgumentHelp("id:path", valueName: "dependency")) - var dependencies: [String] = [] // --dependency id:path + /// Builds structured resource entries from skip.yml configuration. + /// + /// If the skip.yml declares resource paths with modes, each path is enumerated + /// and paired with its declared mode. Otherwise, falls back to the default + /// `Resources/` folder contents with process mode. + /// + /// - Parameters: + /// - config: The base skip.yml config. + /// - resourceURLs: Default resource URLs from the Resources/ folder. + /// - projectBaseURL: The project folder URL for resolving relative resource paths. + /// - Returns: Array of resource entries with their files and processing modes. + static func buildResourceEntries(config: SkipConfig, resourceURLs: [URL], projectBaseURL: URL) throws -> [ResourceEntry] { + if let resourceConfigs = config.skip?.resources { + return try resourceConfigs.map { resourceConfig in + let resourceDirURL = projectBaseURL.appendingPathComponent(resourceConfig.path, isDirectory: true) + let urls: [URL] = try FileManager.default.enumeratedURLs(of: resourceDirURL) + return ResourceEntry(path: resourceConfig.path, urls: urls, isCopyMode: resourceConfig.isCopyMode) + } + } else if !resourceURLs.isEmpty { + return [ResourceEntry(path: "Resources", urls: resourceURLs, isCopyMode: false)] + } else { + return [] + } + } - @Option(name: [.long], help: ArgumentHelp("Folder for SkipBridge generated Swift files", valueName: "suffix")) - var skipBridgeOutput: String? = nil + /// Filters YAML content by removing blocks marked with `export: false`. + /// + /// When a module's skip.yml is loaded for use by a dependent module, blocks + /// explicitly marked as non-exported are stripped. This allows modules to have + /// configuration that only applies locally. + /// + /// - Parameter yaml: The YAML content to filter. + /// - Returns: The filtered YAML, or nil if the entire block should be removed. + static func filterExportYAML(_ yaml: YAML) -> YAML? { + guard var obj = yaml.object else { + if let array = yaml.array { + return .array(array.compactMap(filterExportYAML(_:))) + } else { + return yaml + } + } + for (key, value) in obj { + if key == "export" { + if value.boolean == false { + return nil + } + } else { + obj[key] = filterExportYAML(value) + } + } + return .object(obj) + } } - extension Universal.XMLNode { mutating func addPlist(key: String, stringValue: String) { append(Universal.XMLNode(elementName: "key", children: [.content(key)])) diff --git a/Tests/SkipBuildTests/SkipstoneCommandTests.swift b/Tests/SkipBuildTests/SkipstoneCommandTests.swift new file mode 100644 index 00000000..1e6fb9e3 --- /dev/null +++ b/Tests/SkipBuildTests/SkipstoneCommandTests.swift @@ -0,0 +1,435 @@ +// Copyright (c) 2023 - 2026 Skip +// Licensed under the GNU Affero General Public License v3.0 +// SPDX-License-Identifier: AGPL-3.0-only + +import XCTest +@testable import SkipBuild +import TSCBasic +import Universal + +final class SkipstoneCommandTests: XCTestCase { + + // MARK: - resolveSourceFileOutputPath Tests + + /// Kotlin files should be placed under the kotlin output folder with package-derived subdirectories. + func testSourceFileOutputPath_KotlinFile() throws { + let kotlinFolder = try AbsolutePath(validating: "/output/kotlin") + let javaFolder = try AbsolutePath(validating: "/output/java") + + let result = try SkipstoneSession.resolveSourceFileOutputPath( + for: "MyClass.kt", + packageName: "skip.foundation", + kotlinFolder: kotlinFolder, + javaFolder: javaFolder, + manifestName: "AndroidManifest.xml", + basePath: nil) + + XCTAssertEqual(result?.pathString, "/output/kotlin/skip/foundation/MyClass.kt") + } + + /// Java files should be placed under the java output folder with package-derived subdirectories. + func testSourceFileOutputPath_JavaFile() throws { + let kotlinFolder = try AbsolutePath(validating: "/output/kotlin") + let javaFolder = try AbsolutePath(validating: "/output/java") + + let result = try SkipstoneSession.resolveSourceFileOutputPath( + for: "Helper.java", + packageName: "skip.model", + kotlinFolder: kotlinFolder, + javaFolder: javaFolder, + manifestName: "AndroidManifest.xml", + basePath: nil) + + XCTAssertEqual(result?.pathString, "/output/java/skip/model/Helper.java") + } + + /// skip.yml files should be excluded from output (returns nil). + func testSourceFileOutputPath_SkipYml() throws { + let kotlinFolder = try AbsolutePath(validating: "/output/kotlin") + let javaFolder = try AbsolutePath(validating: "/output/java") + + let result = try SkipstoneSession.resolveSourceFileOutputPath( + for: "skip.yml", + packageName: "skip.ui", + kotlinFolder: kotlinFolder, + javaFolder: javaFolder, + manifestName: "AndroidManifest.xml", + basePath: nil) + + XCTAssertNil(result) + } + + /// AndroidManifest.xml should be placed one level up from the type-specific folder. + func testSourceFileOutputPath_AndroidManifest() throws { + let kotlinFolder = try AbsolutePath(validating: "/output/kotlin") + let javaFolder = try AbsolutePath(validating: "/output/java") + + let result = try SkipstoneSession.resolveSourceFileOutputPath( + for: "AndroidManifest.xml", + packageName: "skip.ui", + kotlinFolder: kotlinFolder, + javaFolder: javaFolder, + manifestName: "AndroidManifest.xml", + basePath: nil) + + // AndroidManifest goes up one level from java folder (since it's not .kt) + XCTAssertEqual(result?.pathString, "/output/AndroidManifest.xml") + } + + /// When basePath is provided, files should be placed relative to it. + func testSourceFileOutputPath_WithBasePath() throws { + let kotlinFolder = try AbsolutePath(validating: "/output/kotlin") + let javaFolder = try AbsolutePath(validating: "/output/java") + let basePath = try AbsolutePath(validating: "/custom/path") + + let result = try SkipstoneSession.resolveSourceFileOutputPath( + for: "Override.kt", + packageName: "skip.ui", + kotlinFolder: kotlinFolder, + javaFolder: javaFolder, + manifestName: "AndroidManifest.xml", + basePath: basePath) + + XCTAssertEqual(result?.pathString, "/custom/path/Override.kt") + } + + /// Deep package names should create nested subdirectories. + func testSourceFileOutputPath_DeepPackage() throws { + let kotlinFolder = try AbsolutePath(validating: "/output/kotlin") + let javaFolder = try AbsolutePath(validating: "/output/java") + + let result = try SkipstoneSession.resolveSourceFileOutputPath( + for: "File.kt", + packageName: "com.example.deep.package", + kotlinFolder: kotlinFolder, + javaFolder: javaFolder, + manifestName: "AndroidManifest.xml", + basePath: nil) + + XCTAssertEqual(result?.pathString, "/output/kotlin/com/example/deep/package/File.kt") + } + + // MARK: - resolveModuleMode Tests + + /// Explicit "native" mode in config should return .native. + func testModuleMode_Native() throws { + let config = try makeSkipConfig(mode: "native") + let configMap: [String: SkipConfig] = ["TestModule": config] + + let result = SkipstoneSession.resolveModuleMode( + moduleName: nil, configMap: configMap, + baseConfig: config, hasSkipFuse: false, + primaryModuleName: "TestModule") + + XCTAssertEqual(result, .native) + } + + /// Explicit "transpiled" mode should return .transpiled. + func testModuleMode_Transpiled() throws { + let config = try makeSkipConfig(mode: "transpiled") + let configMap: [String: SkipConfig] = ["TestModule": config] + + let result = SkipstoneSession.resolveModuleMode( + moduleName: nil, configMap: configMap, + baseConfig: config, hasSkipFuse: false, + primaryModuleName: "TestModule") + + XCTAssertEqual(result, .transpiled) + } + + /// Automatic mode with SkipFuse present should return .native for the primary module. + func testModuleMode_AutomaticWithFuse() throws { + let config = try makeSkipConfig(mode: nil) + let configMap: [String: SkipConfig] = ["TestModule": config, "SkipFuse": config] + + let result = SkipstoneSession.resolveModuleMode( + moduleName: nil, configMap: configMap, + baseConfig: config, hasSkipFuse: true, + primaryModuleName: "TestModule") + + XCTAssertEqual(result, .native) + } + + /// Automatic mode without SkipFuse should return .transpiled. + func testModuleMode_AutomaticWithoutFuse() throws { + let config = try makeSkipConfig(mode: nil) + let configMap: [String: SkipConfig] = ["TestModule": config] + + let result = SkipstoneSession.resolveModuleMode( + moduleName: nil, configMap: configMap, + baseConfig: config, hasSkipFuse: false, + primaryModuleName: "TestModule") + + XCTAssertEqual(result, .transpiled) + } + + /// Automatic mode with SkipFuse for a non-primary module should return .transpiled. + func testModuleMode_AutomaticWithFuseNonPrimary() throws { + let config = try makeSkipConfig(mode: nil) + let configMap: [String: SkipConfig] = ["OtherModule": config, "SkipFuse": config] + + let result = SkipstoneSession.resolveModuleMode( + moduleName: "OtherModule", configMap: configMap, + baseConfig: config, hasSkipFuse: true, + primaryModuleName: "TestModule") + + XCTAssertEqual(result, .transpiled) + } + + // MARK: - isTestModule Tests + + /// The primary module itself should not be considered a test module. + func testIsTestModule_SameModule() { + XCTAssertFalse(SkipstoneSession.isTestModule("MyModule", primaryModuleName: "MyModule")) + } + + /// The test peer (primary name without "Tests" suffix) should not be a test module. + func testIsTestModule_TestPeer() { + XCTAssertFalse(SkipstoneSession.isTestModule("MyModule", primaryModuleName: "MyModuleTests")) + } + + /// A different module should be considered a test module. + func testIsTestModule_DifferentModule() { + XCTAssertTrue(SkipstoneSession.isTestModule("SkipFoundation", primaryModuleName: "MyModule")) + } + + /// SkipUnit should be considered a test module for any primary module. + func testIsTestModule_SkipUnit() { + XCTAssertTrue(SkipstoneSession.isTestModule("SkipUnit", primaryModuleName: "MyModule")) + } + + // MARK: - identifyStaleFiles Tests + + /// When all snapshot files are still in output, there should be no stale files. + func testIdentifyStaleFiles_NoStale() { + let snapshot = [ + URL(fileURLWithPath: "/output/File1.kt"), + URL(fileURLWithPath: "/output/File2.kt"), + ] + let outputFiles = [ + try! AbsolutePath(validating: "/output/File1.kt"), + try! AbsolutePath(validating: "/output/File2.kt"), + ] + + let stale = SkipstoneSession.identifyStaleFiles(snapshot: snapshot, outputFiles: outputFiles) + XCTAssertTrue(stale.isEmpty) + } + + /// Files in snapshot but not in output should be identified as stale. + func testIdentifyStaleFiles_WithStale() { + let snapshot = [ + URL(fileURLWithPath: "/output/File1.kt"), + URL(fileURLWithPath: "/output/OldFile.kt"), + URL(fileURLWithPath: "/output/File2.kt"), + ] + let outputFiles = [ + try! AbsolutePath(validating: "/output/File1.kt"), + try! AbsolutePath(validating: "/output/File2.kt"), + ] + + let stale = SkipstoneSession.identifyStaleFiles(snapshot: snapshot, outputFiles: outputFiles) + XCTAssertEqual(stale, Set(["/output/OldFile.kt"])) + } + + /// An empty snapshot should produce no stale files. + func testIdentifyStaleFiles_EmptySnapshot() { + let stale = SkipstoneSession.identifyStaleFiles(snapshot: [], outputFiles: []) + XCTAssertTrue(stale.isEmpty) + } + + // MARK: - categorizeSourceFiles Tests + + /// In transpiled mode, all files should be in the transpile list. + func testCategorizeSourceFiles_Transpiled() { + let urls = [ + URL(fileURLWithPath: "/src/A.swift"), + URL(fileURLWithPath: "/src/B.swift"), + ] + + let (transpile, swift) = SkipstoneSession.categorizeSourceFiles(sourceURLs: urls, isNative: false) + + XCTAssertEqual(transpile.count, 2) + XCTAssertTrue(swift.isEmpty) + // Should be sorted + XCTAssertEqual(transpile, transpile.sorted()) + } + + /// In native mode, all files should be in the swift (bridge) list. + func testCategorizeSourceFiles_Native() { + let urls = [ + URL(fileURLWithPath: "/src/A.swift"), + URL(fileURLWithPath: "/src/B.swift"), + ] + + let (transpile, swift) = SkipstoneSession.categorizeSourceFiles(sourceURLs: urls, isNative: true) + + XCTAssertTrue(transpile.isEmpty) + XCTAssertEqual(swift.count, 2) + XCTAssertEqual(swift, swift.sorted()) + } + + /// Empty source list should produce empty results. + func testCategorizeSourceFiles_Empty() { + let (transpile, swift) = SkipstoneSession.categorizeSourceFiles(sourceURLs: [], isNative: false) + XCTAssertTrue(transpile.isEmpty) + XCTAssertTrue(swift.isEmpty) + } + + // MARK: - mergeGradleProperties Tests + + /// Default properties should be parsed and output sorted. + func testMergeGradleProperties_DefaultsOnly() { + let defaults = """ + org.gradle.jvmargs=-Xmx4g + android.useAndroidX=true + kotlin.code.style=official + """ + + let result = SkipstoneSession.mergeGradleProperties(defaults: defaults, custom: nil) + + XCTAssertTrue(result.contains("android.useAndroidX=true")) + XCTAssertTrue(result.contains("kotlin.code.style=official")) + XCTAssertTrue(result.contains("org.gradle.jvmargs=-Xmx4g")) + } + + /// Custom properties should override defaults. + func testMergeGradleProperties_WithOverrides() { + let defaults = """ + org.gradle.jvmargs=-Xmx4g + android.useAndroidX=true + """ + + let result = SkipstoneSession.mergeGradleProperties( + defaults: defaults, + custom: ["org.gradle.jvmargs": "-Xmx8g"]) + + XCTAssertTrue(result.contains("org.gradle.jvmargs=-Xmx8g")) + XCTAssertFalse(result.contains("org.gradle.jvmargs=-Xmx4g")) + } + + /// Custom properties should add new entries. + func testMergeGradleProperties_CustomAdded() { + let defaults = """ + org.gradle.jvmargs=-Xmx4g + """ + + let result = SkipstoneSession.mergeGradleProperties( + defaults: defaults, + custom: ["custom.prop": "value"]) + + XCTAssertTrue(result.contains("custom.prop=value")) + XCTAssertTrue(result.contains("org.gradle.jvmargs=-Xmx4g")) + } + + /// Comments and blank lines in defaults should be ignored. + func testMergeGradleProperties_IgnoresComments() { + let defaults = """ + # This is a comment + key1=value1 + + key2=value2 + """ + + let result = SkipstoneSession.mergeGradleProperties(defaults: defaults, custom: nil) + + XCTAssertTrue(result.contains("key1=value1")) + XCTAssertTrue(result.contains("key2=value2")) + XCTAssertFalse(result.contains("#")) + } + + /// Output should be sorted by key. + func testMergeGradleProperties_Sorted() { + let defaults = """ + zebra=last + alpha=first + middle=mid + """ + + let result = SkipstoneSession.mergeGradleProperties(defaults: defaults, custom: nil) + + let lines = result.components(separatedBy: "\n").filter { !$0.isEmpty } + XCTAssertEqual(lines, ["alpha=first", "middle=mid", "zebra=last"]) + } + + // MARK: - buildResourceEntries Tests + + /// When no config resources and no resource URLs, should return empty. + func testBuildResourceEntries_EmptyResources() throws { + let config = try makeSkipConfig(mode: nil) + let result = try SkipstoneSession.buildResourceEntries( + config: config, resourceURLs: [], projectBaseURL: URL(fileURLWithPath: "/project")) + + XCTAssertTrue(result.isEmpty) + } + + /// When resource URLs exist but no config, should fall back to default Resources/ entry. + func testBuildResourceEntries_FallbackToResources() throws { + let config = try makeSkipConfig(mode: nil) + let resourceURLs = [URL(fileURLWithPath: "/project/Resources/file.txt")] + + let result = try SkipstoneSession.buildResourceEntries( + config: config, resourceURLs: resourceURLs, projectBaseURL: URL(fileURLWithPath: "/project")) + + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result.first?.path, "Resources") + XCTAssertFalse(result.first?.isCopyMode ?? true) + } + + // MARK: - filterExportYAML Tests + + /// Blocks with export:false should be removed. + func testFilterExportYAML_RemovesExportFalse() throws { + let yaml: YAML = .object([ + "key1": .string("value1"), + "export": .boolean(false), + ]) + + let result = SkipstoneSession.filterExportYAML(yaml) + XCTAssertNil(result) + } + + /// Blocks without export:false should be preserved. + func testFilterExportYAML_PreservesNonExportBlocks() throws { + let yaml: YAML = .object([ + "key1": .string("value1"), + "key2": .string("value2"), + ]) + + let result = SkipstoneSession.filterExportYAML(yaml) + XCTAssertNotNil(result) + XCTAssertEqual(result?.object?["key1"]?.string, "value1") + } + + /// Nested blocks with export:false should be removed from arrays. + func testFilterExportYAML_FiltersNestedArrayItems() throws { + let yaml: YAML = .array([ + .object(["key1": .string("keep")]), + .object(["export": .boolean(false), "key2": .string("remove")]), + .object(["key3": .string("also keep")]), + ]) + + let result = SkipstoneSession.filterExportYAML(yaml) + XCTAssertNotNil(result) + let array = result?.array + XCTAssertEqual(array?.count, 2) + } + + /// Scalar values should pass through unchanged. + func testFilterExportYAML_ScalarPassthrough() throws { + let yaml: YAML = .string("hello") + let result = SkipstoneSession.filterExportYAML(yaml) + XCTAssertEqual(result?.string, "hello") + } + + // MARK: - Helpers + + /// Creates a minimal SkipConfig with an optional mode for testing. + private func makeSkipConfig(mode: String?) throws -> SkipConfig { + if let mode { + let json: Universal.JSON = .object(["skip": .object(["mode": .string(mode)])]) + return try json.decode() + } else { + return try Universal.JSON.object([:]).decode() + } + } +} From 2e0ceda674d8a7baa9e3135732fde23a49b9774f Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Tue, 10 Mar 2026 18:21:30 -0400 Subject: [PATCH 3/4] Revert "Refactor SkipstoneCommand and add test cases" This reverts commit 75915c3f975c57d5c6babd6bde92fdfb2925c28b. --- .github/workflows/ci.yml | 5 +- Package.swift | 8 +- .../SkipBuild/Commands/SkipstoneCommand.swift | 2598 +++++++---------- .../SkipstoneCommandTests.swift | 435 --- 4 files changed, 1028 insertions(+), 2018 deletions(-) delete mode 100644 Tests/SkipBuildTests/SkipstoneCommandTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b69f7c52..2b92e2a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,10 +134,7 @@ jobs: - run: skip doctor - run: skip checkup --native - # there's a bug runing checkup with a local skipstone comand build: - # [✗] error: Dependencies could not be resolved because root depends on 'skip' 1.7.5..<2.0.0. - if: false - #if: runner.os != 'Linux' + if: runner.os != 'Linux' - name: "Prepare Android emulator environment" if: runner.os == 'Linux' diff --git a/Package.swift b/Package.swift index 242ec772..a2011722 100644 --- a/Package.swift +++ b/Package.swift @@ -4,13 +4,7 @@ import PackageDescription let package = Package( name: "skipstone", defaultLocalization: "en", - platforms: [ - .iOS(.v16), - .macOS(.v14), - .tvOS(.v16), - .watchOS(.v9), - .macCatalyst(.v16), - ], + platforms: [.macOS(.v13)], products: [ .library(name: "SkipSyntax", targets: ["SkipSyntax"]), .library(name: "SkipBuild", targets: ["SkipBuild"]), diff --git a/Sources/SkipBuild/Commands/SkipstoneCommand.swift b/Sources/SkipBuild/Commands/SkipstoneCommand.swift index 144d8eee..dea870aa 100644 --- a/Sources/SkipBuild/Commands/SkipstoneCommand.swift +++ b/Sources/SkipBuild/Commands/SkipstoneCommand.swift @@ -104,18 +104,12 @@ struct SkipstoneCommand: BuildPluginOptionsCommand, StreamingCommand { info("Skip \(v): skipstone plugin to: \(skipstoneOptions.outputFolder ?? "nowhere") at \(dateFormatter.string(from: .now))") + try await self.skipstone(root: baseOutputPath, project: projectFolderPath, module: moduleRootPath, skip: skipFolderPath, output: outputFolderPath, fs: fs, with: out) + } - // Delegates to a `SkipstoneSession` which encapsulates all the mutable state and operational logic - let session = SkipstoneSession( - command: self, - rootPath: baseOutputPath, - projectFolderPath: projectFolderPath, - moduleRootPath: moduleRootPath, - skipFolderPath: skipFolderPath, - outputFolderPath: outputFolderPath, - fs: fs) + private func skipstone(root rootPath: AbsolutePath, project projectFolderPath: AbsolutePath, module moduleRootPath: AbsolutePath, skip skipFolderPath: AbsolutePath, output outputFolderPath: AbsolutePath, fs: FileSystem, with out: MessageQueue) async throws { do { - try await session.run(with: out) + try await skipstoneThrows(root: rootPath, project: projectFolderPath, module: moduleRootPath, skip: skipFolderPath, output: outputFolderPath, fs: fs, with: out) } catch { // ensure that the error is logged in some way before failing self.error("Skip \(skipVersion) error: \(error.localizedDescription)") @@ -123,1830 +117,1290 @@ struct SkipstoneCommand: BuildPluginOptionsCommand, StreamingCommand { } } - /// Generate transpiler transformers from the given skip config - func createTransformers(for config: SkipConfig, with moduleMap: [String: SkipConfig]) throws -> [KotlinTransformer] { - var transformers: [KotlinTransformer] = builtinKotlinTransformers() - - let configOptions = config.skip?.bridgingOptions() ?? [] - let transformerOptions = KotlinBridgeOptions.parse(configOptions) - transformers.append(KotlinBridgeTransformer(options: transformerOptions)) - - if let root = config.skip?.dynamicroot { - transformers.append(KotlinDynamicObjectTransformer(root: root)) - } - - return transformers - } + private func skipstoneThrows(root rootPath: AbsolutePath, project projectFolderPath: AbsolutePath, module moduleRootPath: AbsolutePath, skip skipFolderPath: AbsolutePath, output outputFolderPath: AbsolutePath, fs: FileSystem, with out: MessageQueue) async throws { + trace("skipstoneThrows: rootPath=\(rootPath), projectFolderPath=\(projectFolderPath), moduleRootPath=\(moduleRootPath), skipFolderPath=\(skipFolderPath), outputFolderPath=\(outputFolderPath)") - func loadSourceHashes(from allSourceURLs: [URL]) async throws -> [URL: String] { - // take a snapshot of all the source hashes for each of the URLs so we know when anything has changes - // TODO: this doesn't need to be a full SHA256 hash, it can be something faster (or maybe even just a snapshot of the file's size and last modified date…) - let sourcehashes = try await withThrowingTaskGroup(of: (URL, String).self) { group in - for url in allSourceURLs { - group.addTask { - let data = try Data(contentsOf: url, options: .mappedIfSafe) - return (url, data.SHA256Hash()) - } - } + // the path that will contain the `skip.yml` - var results = [URL: String]() - results.reserveCapacity(allSourceURLs.count) + // the module will be treated differently if it is an app versus a library (it will use the "com.android.application" plugin instead of "com.android.library") + let AndroidManifestName = "AndroidManifest.xml" - for try await (url, sha256) in group { - results[url] = sha256 - } + // folders that can contain gradle plugins and scripts + let buildSrcFolderName = "buildSrc" - return results + let cmakeLists = projectFolderPath.appending(component: "CMakeLists.txt") + let isCMakeProject = fs.exists(cmakeLists) + if !isCMakeProject && !fs.isDirectory(skipFolderPath) { + throw error("In order for Skip to process the module, a Skip/ folder must exist and contain a skip.yml file at: \(skipFolderPath)") } - return sourcehashes - } -} - -struct SkipstoneCommandOptions: ParsableArguments { - @Option(name: [.customLong("project"), .long], help: ArgumentHelp("The project folder to transpile", valueName: "folder")) - var projectFolder: String // --project - - @Option(name: [.long], help: ArgumentHelp("The path to the source hash file to output", valueName: "path")) - var sourcehash: String // --sourcehash - - @Option(name: [.customLong("module")], help: ArgumentHelp("ModuleName:SourcePath", valueName: "module")) - var moduleNames: [String] = [] // --module name:path - - @Option(name: [.customLong("link")], help: ArgumentHelp("ModuleName:LinkPath", valueName: "module")) - var linkPaths: [String] = [] // --link name:path - - @Option(help: ArgumentHelp("Path to the folder that contains skip.yml and overrides", valueName: "path")) - var skipFolder: String? = nil // --skip-folder - - @Option(help: ArgumentHelp("Path to the output module root folder", valueName: "path")) - var moduleRoot: String? = nil // --module-root - - @Option(name: [.customShort("D", allowingJoined: true)], help: ArgumentHelp("Set preprocessor variable for transpilation", valueName: "value")) - var preprocessorVariables: [String] = [] - - @Option(name: [.long], help: ArgumentHelp("Output directory", valueName: "dir")) - var outputFolder: String? = nil - - @Option(name: [.customLong("dependency")], help: ArgumentHelp("id:path", valueName: "dependency")) - var dependencies: [String] = [] // --dependency id:path - - @Option(name: [.long], help: ArgumentHelp("Folder for SkipBridge generated Swift files", valueName: "suffix")) - var skipBridgeOutput: String? = nil -} - - -/// A collected resource entry with its file URLs and processing mode. -/// -/// Resource entries are built from `skip.yml` configuration and track whether resources -/// should be processed (flattened with localization conversion) or copied (preserving -/// the source directory hierarchy, matching Darwin's `.copy()` behavior). -struct ResourceEntry { - /// The relative path to the resource directory from the project folder. - let path: String - /// The file URLs contained within the resource directory. - let urls: [URL] - /// Whether this entry uses copy mode (preserving hierarchy) vs process mode (flattening). - let isCopyMode: Bool -} - -/// Manages the mutable state and execution phases of a single skipstone transpilation invocation. -/// -/// `SkipstoneSession` encapsulates all the mutable state and operational logic that was previously -/// contained within `skipstoneThrows` as nested closures. By organizing this logic into a class -/// with distinct methods, each phase of the skipstone pipeline becomes independently understandable -/// and testable. -/// -/// ## Execution Phases -/// -/// The session executes via ``run(with:)`` in sequential phases: -/// -/// 1. **Validation & Setup** — Validates paths, enumerates source/resource files, snapshots -/// existing output files for stale detection, loads and merges `skip.yml` configs. -/// -/// 2. **Transpilation** — Loads dependent module codebase info, creates the transpiler with -/// appropriate transformers, runs transpilation, and writes Kotlin output files. -/// -/// 3. **Output & Linking** — Saves codebase info for downstream modules, generates bridge code, -/// links dependent module sources, links resources (process or copy mode), and generates -/// Gradle build files. -/// -/// 4. **Cleanup** — Removes stale output files from previous runs and writes the sourcehash -/// marker file to signal completion to the build plugin host. -class SkipstoneSession { + // when renaming SomeClassA.swift to SomeClassB.swift, the stale SomeClassA.kt file from previous runs will be left behind, and will then cause a "Redeclaration:" error from the Kotlin compiler if they declare the same types + // so keep a snapshot of the output folder files that existed at the start of the skipstone operation, so we can then clean up any output files that are no longer being produced + let outputFilesSnapshot: [URL] = try FileManager.default.enumeratedURLs(of: outputFolderPath.asURL) + //msg(.warning, "transpiling to \(outputFolderPath.pathString) with existing files: \(outputFilesSnapshot.map(\.lastPathComponent).sorted().joined(separator: ", "))") - // MARK: - Command & Environment + var outputFiles: [AbsolutePath] = [] - /// The command that created this session, used for logging and accessing parsed options. - private let command: SkipstoneCommand + var skipBridgeTranspilations: [Transpilation] = [] - /// The filesystem abstraction for all file operations. - let fs: FileSystem + func cleanupStaleOutputFiles() { + let staleFiles = Set(outputFilesSnapshot.map(\.path)) + .subtracting(outputFiles.map(\.pathString)) + for staleFile in staleFiles.sorted() { + let staleFileURL = URL(fileURLWithPath: staleFile, isDirectory: false) + if staleFileURL.lastPathComponent == "Package.resolved" { + // Package.resolved is special, because it is output from the native build and removing it would cause an unnecessary rebuild + continue + } + msg(.warning, "removing stale output file: \(staleFileURL.lastPathComponent)", sourceFile: try? staleFileURL.absolutePath.sourceFile) - // MARK: - Immutable Paths + do { + // don't actually trash it, since the output files often have read-only permissions set, and that prevents trash from working + try FileManager.default.trash(fileURL: staleFileURL, trash: false) + } catch { + msg(.warning, "error removing stale output file: \(staleFileURL.lastPathComponent): \(error)") + } + } + } - /// The working directory root path. - let rootPath: AbsolutePath + /// track every output file written using `addOutputFile` to prevent the file from being cleaned up at the end + @discardableResult func addOutputFile(_ path: AbsolutePath) -> AbsolutePath { + outputFiles.append(path) + return path + } - /// Path to the Swift source project folder (e.g., Sources/ModuleName). - let projectFolderPath: AbsolutePath + var inputFiles: [AbsolutePath] = [] + // add the given file to the list of input files for consideration of mod time + func addInputFile(_ path: AbsolutePath) -> AbsolutePath { + inputFiles.append(path) + return path + } - /// Path to the Gradle module root output directory. - let moduleRootPath: AbsolutePath + /// Load the given source file, tracking its last modified date for the timestamp on the `.sourcehash` marker file + func inputSource(_ path: AbsolutePath) throws -> ByteString { + try fs.readFileContents(addInputFile(path)) + } - /// Path to the Skip/ configuration folder containing skip.yml and overrides. - let skipFolderPath: AbsolutePath - /// Path to the Kotlin/Java output folder (e.g., src/main/kotlin or src/test/kotlin). - let outputFolderPath: AbsolutePath + if !fs.isDirectory(moduleRootPath) { + try fs.createDirectory(moduleRootPath, recursive: true) + } - // MARK: - Constants + if !fs.isDirectory(moduleRootPath) { + throw error("Module root path did not exist at: \(moduleRootPath.pathString)") + } - /// The filename for the Android manifest, which requires special output path handling. - let androidManifestName = "AndroidManifest.xml" + guard let (primaryModuleName, primaryModulePath) = moduleNamePaths.first else { + throw error("Must specify at least one --module") + } - /// The folder name for Gradle build scripts and plugins. - let buildSrcFolderName = "buildSrc" + func isTestModule(_ moduleName: String) -> Bool { + primaryModuleName != moduleName && primaryModuleName != moduleName + "Tests" + } - // MARK: - Accumulated Output State + // check for the existence of PrimaryModuleName.xcconfig, and if it exists, this is an app module + let configModuleName = primaryModuleName.hasSuffix("Tests") ? String(primaryModuleName.dropLast("Tests".count)) : primaryModuleName + let moduleXCConfig = rootPath.appending(component: configModuleName + ".xcconfig") + let isAppModule = fs.isFile(moduleXCConfig) - /// All output files written during this session. Used for stale file detection: - /// any file in the output folder not in this list after the session is considered stale. - var outputFiles: [AbsolutePath] = [] + let _ = primaryModulePath - /// All input files read during this session, tracked for modification timestamps. - var inputFiles: [AbsolutePath] = [] + /// A collected resource entry with its URLs and mode + struct ResourceEntry { + let path: String + let urls: [URL] + let isCopyMode: Bool + } - /// Bridge transpilation outputs accumulated during the transpilation phase, - /// consumed later when saving bridge code. - var skipBridgeTranspilations: [Transpilation] = [] + func buildSourceList() throws -> (sources: [URL], resources: [URL]) { + let projectBaseURL = projectFolderPath.asURL + let allProjectFiles: [URL] = try FileManager.default.enumeratedURLs(of: projectBaseURL) - // MARK: - Phase-Derived State (set during run) + let swiftPathExtensions: Set = ["swift"] + let sourceURLs: [URL] = allProjectFiles.filter({ swiftPathExtensions.contains($0.pathExtension) }) - /// Snapshot of output file URLs taken at session start, for stale file comparison. - private var outputFilesSnapshot: [URL] = [] + let projectResourcesURL = projectBaseURL.appendingPathComponent("Resources", isDirectory: true) + let resourceURLs: [URL] = try FileManager.default.enumeratedURLs(of: projectResourcesURL) - /// Source .swift file URLs enumerated from the project folder. - private var sourceURLs: [URL] = [] + return (sources: sourceURLs, resources: resourceURLs) + } - /// Resource file URLs from the default Resources/ folder. - private var resourceURLs: [URL] = [] + let (sourceURLs, resourceURLs) = try buildSourceList() - /// The base (unmerged) skip.yml config for this module. - private var baseSkipConfig: SkipConfig! + let moduleBasePath = moduleRootPath.parentDirectory - /// The merged skip.yml config combining all dependent module configs. - private var mergedSkipConfig: SkipConfig! + // always touch the sourcehash file with the most recent source hashes in order to update the output file time + /// Create a link from the source to the destination; this is used for resources and custom Kotlin files in order to permit edits to target file and have them reflected in the original source + func addLink(_ linkSource: AbsolutePath, pointingAt destPath: AbsolutePath, relative: Bool, replace: Bool = true, copyReadOnlyFiles: Bool = true) throws { + msg(.trace, "linking: \(linkSource) to: \(destPath)") - /// Map of module name to its parsed skip.yml config. - private var configMap: [String: SkipConfig]! + if replace && fs.isSymlink(destPath) { + removePath(destPath) // clear any pre-existing symlink + } - /// Whether SkipFuse is present in the dependency graph. - private var hasSkipFuse: Bool = false + if let existingSymlinkDestination = try? FileManager.default.destinationOfSymbolicLink(atPath: linkSource.pathString) { + if existingSymlinkDestination == destPath.pathString { + msg(.trace, "retaining existing link from \(destPath.pathString) to \(existingSymlinkDestination)") + addOutputFile(linkSource) // remember that we are using the linkSource file + return + } + } - /// Whether this module operates in native (non-transpiled) mode. - private var isNativeModule: Bool = false + let destInfo = try fs.getFileInfo(destPath) + let modTime = destInfo.modTime + let perms = destInfo.posixPermissions - /// The Kotlin package name for this module (e.g., "skip.foundation"). - private var packageName: String! + // 0o200 adds owner write permission (write = 2, owner = 2) + let writablePermissions = perms | 0o200 - /// Structured resource entries built from skip.yml configuration. - private var resourceEntries: [ResourceEntry] = [] + // when the source file is not writable, we copy the file insead of linking it, because otherwise Gradle may fail to overwrite the desination the second time it tries to copy it + // https://github.com/skiptools/skip/issues/296 + let shouldCopy = copyReadOnlyFiles && !fs.isDirectory(linkSource) && (perms != writablePermissions) - /// SHA256 hashes of all source files, used for change detection. - private var sourcehashes: [URL: String] = [:] + removePath(linkSource) // remove any existing link in order to re-create it + if shouldCopy { + msg(.trace, "copying \(destPath) to \(linkSource)") + try fs.copy(from: destPath, to: addOutputFile(linkSource)) + //try fs.chmod(.userWritable, path: destPath) + try FileManager.default.setAttributes([.posixPermissions: writablePermissions], ofItemAtPath: linkSource.pathString) + } else { + msg(.trace, "linking \(destPath) to \(linkSource)") + try fs.createSymbolicLink(addOutputFile(linkSource), pointingAt: destPath, relative: relative) + } - /// Codebase info loaded from dependent modules, populated during transpilation. - private var codebaseInfo: CodebaseInfo! + // set the output link mod time to match the source link mod time - /// Kotlin filenames that have manual overrides from the Skip/ folder. - private var overriddenKotlinFiles: [String] = [] + // this will try to set the mod time of the *destination* file, which is incorrect (and also not allowed, since the dest is likely outside of our sandboxed write folder list) + //try FileManager.default.setAttributes([.modificationDate: modTime], ofItemAtPath: linkSource.pathString) - // MARK: - Computed Properties + // using setResourceValue instead does apply it to the link + // https://stackoverflow.com/questions/10608724/set-modification-date-on-symbolic-link-in-cocoa + try (linkSource.asURL as NSURL).setResourceValue(modTime, forKey: .contentModificationDateKey) + } - /// The parent directory of moduleRootPath, used as the base for relative output paths. - var moduleBasePath: AbsolutePath { moduleRootPath.parentDirectory } + // the shared JSON encoder for serializing .skipcode.json codebase and .sourcehash marker contents + let encoder = JSONEncoder() + encoder.outputFormatting = [ + .sortedKeys, // needed for deterministic output + .withoutEscapingSlashes, + //.prettyPrinted, // compacting JSON significantly reduces the size of the codebase files + ] - /// Module name/path pairs from --module arguments, forwarded from the command. - var moduleNamePaths: [(module: String, path: String)] { command.moduleNamePaths } + let sourcehashOutputPath = try AbsolutePath(validating: skipstoneOptions.sourcehash) + // We no longer remove the path because the plugin doesn't seem to require it to know to run in dependency order + //removePath(sourcehashOutputPath) // delete the build completion marker to force its re-creation (removeFileTree doesn't throw when the file doesn't exist) + + // load and merge each of the skip.yml files for the dependent modules + let (baseSkipConfig, mergedSkipConfig, configMap) = try loadSkipConfig(merge: true) + let hasSkipFuse = configMap.keys.contains("SkipFuse") + + // Build resource entries from skip.yml configuration, falling back to the default Resources/ folder + let resourceEntries: [ResourceEntry] = try { + let projectBaseURL = projectFolderPath.asURL + if let resourceConfigs = baseSkipConfig.skip?.resources { + return try resourceConfigs.map { config in + let resourceDirURL = projectBaseURL.appendingPathComponent(config.path, isDirectory: true) + let urls: [URL] = try FileManager.default.enumeratedURLs(of: resourceDirURL) + return ResourceEntry(path: config.path, urls: urls, isCopyMode: config.isCopyMode) + } + } else if !resourceURLs.isEmpty { + return [ResourceEntry(path: "Resources", urls: resourceURLs, isCopyMode: false)] + } else { + return [] + } + }() - /// Module name/link pairs from --link arguments, forwarded from the command. - var linkNamePaths: [(module: String, link: String)] { command.linkNamePaths } + func moduleMode(for moduleName: String?) -> ModuleMode { + let moduleMode: String? - /// Dependency id/path triples from --dependency arguments, forwarded from the command. - var dependencyIdPaths: [(targetName: String, packageID: String, packagePath: String)] { command.dependencyIdPaths } + if let moduleName { + moduleMode = configMap[moduleName]?.skip?.mode + } else { + moduleMode = baseSkipConfig.skip?.mode + } - /// The skipstone-specific command options. - var skipstoneOptions: SkipstoneCommandOptions { command.skipstoneOptions } + switch moduleMode { + case "native": return .native + case "transpiled": return .transpiled + case "automatic", .none: return hasSkipFuse && (moduleName == primaryModuleName || moduleName == nil) ? .native : .transpiled + default: + error("Unknown skip mode for module \(moduleName ?? primaryModuleName): \(moduleMode ?? "none")") + return .transpiled + } + } - // MARK: - JSON Encoder + let isNativeModule = moduleMode(for: nil) == .native - /// Shared JSON encoder configured for deterministic output, used for - /// serializing `.skipcode.json` codebase and `.sourcehash` marker contents. - let encoder: JSONEncoder = { - let e = JSONEncoder() - e.outputFormatting = [ - .sortedKeys, // needed for deterministic output - .withoutEscapingSlashes, - ] - return e - }() - - // MARK: - Initialization - - /// Creates a new session with the given command and pre-validated paths. - /// - /// The initializer stores references but performs no I/O. All work is deferred - /// to ``run(with:)`` and its phase methods. - /// - /// - Parameters: - /// - command: The parsed skipstone command, providing options and logging. - /// - rootPath: The filesystem root / working directory. - /// - projectFolderPath: Path to the Swift project source folder. - /// - moduleRootPath: Path to the Gradle module root output. - /// - skipFolderPath: Path to the Skip/ configuration folder. - /// - outputFolderPath: Path to the Kotlin/Java output folder. - /// - fs: The filesystem abstraction for file operations. - init(command: SkipstoneCommand, rootPath: AbsolutePath, projectFolderPath: AbsolutePath, moduleRootPath: AbsolutePath, skipFolderPath: AbsolutePath, outputFolderPath: AbsolutePath, fs: FileSystem) { - self.command = command - self.rootPath = rootPath - self.projectFolderPath = projectFolderPath - self.moduleRootPath = moduleRootPath - self.skipFolderPath = skipFolderPath - self.outputFolderPath = outputFolderPath - self.fs = fs - } + // also add any files in the skipFolderFile to the list of sources (including the skip.yml and other metadata files) + let skipFolderPathContents = try FileManager.default.enumeratedURLs(of: skipFolderPath.asURL) + .filter({ (try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true }) - // MARK: - Logging (forwarded to command) + let sourcehashes = try await loadSourceHashes(from: sourceURLs + skipFolderPathContents) - func trace(_ message: @autoclosure () -> String) { - command.trace(message()) - } + // touch the build marker with the most recent file time from the complete build list + // if we were to touch it afresh every time, the plugin would be re-executed every time + defer { + // finally, remove any "stale" files from the output folder that probably indicate a deleted or renamed file once all the known outputs have been written + cleanupStaleOutputFiles() - func info(_ message: @autoclosure () -> String, sourceFile: Source.FilePath? = nil) { - command.info(message(), sourceFile: sourceFile) - } + do { + // touch the source hash file with a new timestamp to signal to the plugin host that our output file has been written + try saveSourcehashFile() + } catch { + msg(.warning, "could not create build completion marker: \(error)") + } + } - func warn(_ message: @autoclosure () -> String, sourceFile: Source.FilePath? = nil) { - command.warn(message(), sourceFile: sourceFile) - } + let buildGradle = moduleRootPath.appending(component: "build.gradle.kts") - @discardableResult func error(_ message: @autoclosure () -> String, sourceFile: Source.FilePath? = nil) -> ValidationError { - command.error(message(), sourceFile: sourceFile) - } + let codebaseInfo = try await loadCodebaseInfo() // initialize the codebaseinfo and load DependentModuleName.skipcode.json - func msg(_ kind: Message.Kind, _ message: @autoclosure () -> String, sourceFile: Source.FilePath? = nil) { - command.msg(kind, message(), sourceFile: sourceFile) - } + let autoBridge: AutoBridge = primaryModuleName == "SkipSwiftUI" ? .none : baseSkipConfig.skip?.isAutoBridgingEnabled() == true ? .public : .default + let dynamicRoot = baseSkipConfig.skip?.dynamicroot - // MARK: - Main Execution + // projects with a CMakeLists.txt file are built as a native Android library + // these are only used for purely native code libraries, and so we short-circuit the build generation + if isCMakeProject { + // Link ext/ to the relative cmake target + let extLink = moduleRootPath.appending(component: "ext") + try addLink(extLink, pointingAt: projectFolderPath, relative: false) + } - /// Executes all phases of the skipstone pipeline. - /// - /// This method orchestrates the full skipstone invocation by calling phase methods - /// in sequence. A `defer` block ensures that stale file cleanup and the sourcehash - /// marker are always written, even if an error occurs. - /// - /// - Parameter out: The message queue for yielding transpilation results to the build plugin host. - func run(with out: MessageQueue) async throws { - let primaryModuleName = try requirePrimaryModule().module + // the standard base name for Gradle Kotlin and Java source files + let kotlinOutputFolder = try AbsolutePath(outputFolderPath, validating: "kotlin") + let javaOutputFolder = try AbsolutePath(outputFolderPath, validating: "java") - trace("skipstoneThrows: rootPath=\(rootPath), projectFolderPath=\(projectFolderPath), moduleRootPath=\(moduleRootPath), skipFolderPath=\(skipFolderPath), outputFolderPath=\(outputFolderPath)") + // the standard base name for resources, which will be linked from a path like: src/main/resources/package/name/resname.ext + //let resourcesOutputFolder = try AbsolutePath(outputFolderPath, validating: "resources") // traditional Java resources folder + let resourcesOutputFolder = try AbsolutePath(outputFolderPath, validating: "assets") // Android AssetManager folder - try validateSkipFolder() - try snapshotExistingOutputFiles() - try ensureModuleRootExists() + // Android-specific resources like res/values/strings.xml + let resOutputFolder = try AbsolutePath(outputFolderPath, validating: "res") - let (sources, resources) = try buildSourceList() - self.sourceURLs = sources - self.resourceURLs = resources + if !fs.isDirectory(kotlinOutputFolder) { + // e.g.: ~Library/Developer/Xcode/DerivedData/PACKAGE-ID/SourcePackages/plugins/skiphub.output/SkipFoundationKotlinTests/skipstone/SkipFoundation/src/test/kotlin + //throw error("Folder specified by --output-folder did not exist: \(outputFolder)") + try fs.createDirectory(kotlinOutputFolder, recursive: true) + } - try loadAndMergeConfiguration() - try await computeSourceHashes() + // now make a link from src/androidTest/kotlin to src/test/kotlin so the same tests will run against an Android emulator/device with the ANDROID_SERIAL environment + if primaryModuleName.hasSuffix("Tests") { + let androidTestOutputFolder = try AbsolutePath(outputFolderPath, validating: "../androidTest") + removePath(androidTestOutputFolder) // remove any existing link in order to re-create it + try fs.createSymbolicLink(addOutputFile(androidTestOutputFolder), pointingAt: outputFolderPath, relative: true) + } - defer { finalizeSession() } + let packageName = baseSkipConfig.skip?.package ?? KotlinTranslator.packageName(forModule: primaryModuleName) - self.codebaseInfo = try await loadCodebaseInfo() + let transformers: [KotlinTransformer] = try createTransformers(for: baseSkipConfig, with: configMap) - let autoBridge: AutoBridge = primaryModuleName == "SkipSwiftUI" ? .none : baseSkipConfig.skip?.isAutoBridgingEnabled() == true ? .public : .default - let dynamicRoot = baseSkipConfig.skip?.dynamicroot + let overridden = try linkSkipFolder(skipFolderPath, to: kotlinOutputFolder, topLevel: true) + let overriddenKotlinFiles = overridden.map({ $0.basename }) - if isCMakeProject { - try linkCMakeProject() + // the contents of a folder named "buildSrc" are linked at the top level to contain scripts and plugins + let buildSrcFolder = skipFolderPath.appending(component: buildSrcFolderName) + if fs.isDirectory(buildSrcFolder) { + try addLink(moduleBasePath.appending(component: buildSrcFolderName), pointingAt: buildSrcFolder, relative: false) } - let kotlinOutputFolder = try setupOutputFolders() - try setupTransformersAndOverrides(kotlinOutputFolder: kotlinOutputFolder) - - try await runTranspiler(autoBridge: autoBridge, dynamicRoot: dynamicRoot, kotlinOutputFolder: kotlinOutputFolder, with: out) + // feed skipstone the files to transpile and any compiled files to potentially bridge + var transpileFiles: [String] = [] + var swiftFiles: [String] = [] + for sourceFile in sourceURLs.map(\.path).sorted() { + if isNativeModule { + swiftFiles.append(sourceFile) + } else { + transpileFiles.append(sourceFile) + } + } + let transpiler = Transpiler(packageName: packageName, transpileFiles: transpileFiles.map(Source.FilePath.init(path:)), bridgeFiles: swiftFiles.map(Source.FilePath.init(path:)), autoBridge: autoBridge, isBridgeGatherEnabled: dynamicRoot != nil, codebaseInfo: codebaseInfo, preprocessorSymbols: Set(inputOptions.symbols), transformers: transformers) - try saveCodebaseInfo() + try await transpiler.transpile(handler: handleTranspilation) + try saveCodebaseInfo() // save out the ModuleName.skipcode.json try saveSkipBridgeCode() let sourceModules = try linkDependentModuleSources() try linkResources() try generateGradle(for: sourceModules, with: mergedSkipConfig, isApp: isAppModule) - } - // MARK: - Phase 1: Validation & Setup + return // done - /// Returns the primary module name and path from the command's --module arguments. - /// - /// - Throws: `ValidationError` if no --module argument was provided. - /// - Returns: The first module name/path tuple. - func requirePrimaryModule() throws -> (module: String, path: String) { - guard let primary = moduleNamePaths.first else { - throw error("Must specify at least one --module") - } - return primary - } + // MARK: Transpilation helper functions - /// The primary module name, extracted from the first --module argument. - var primaryModuleName: String { - moduleNamePaths.first?.module ?? "" - } + /// The relative path for cached codebase info JSON + func moduleExportPath(forModule moduleName: String) throws -> RelativePath { + try RelativePath(validating: moduleName + skipcodeExtension) + } - /// Whether this module is an app (vs a library), determined by the presence of an `.xcconfig` file. - var isAppModule: Bool { - let configModuleName = primaryModuleName.hasSuffix("Tests") ? String(primaryModuleName.dropLast("Tests".count)) : primaryModuleName - let moduleXCConfig = rootPath.appending(component: configModuleName + ".xcconfig") - return fs.isFile(moduleXCConfig) - } + func loadCodebaseInfo() async throws -> CodebaseInfo { + let decoder = JSONDecoder() + var dependentModuleExports: [CodebaseInfo.ModuleExport] = [] - /// The path to the module's xcconfig file (used for app module manifest configuration). - var moduleXCConfig: AbsolutePath { - let configModuleName = primaryModuleName.hasSuffix("Tests") ? String(primaryModuleName.dropLast("Tests".count)) : primaryModuleName - return rootPath.appending(component: configModuleName + ".xcconfig") - } + // go through the '--link modulename:../../some/path' arguments and try to load the modulename.skipcode.json symbols from the previous module's transpilation output + for (linkModuleName, relativeLinkPath) in linkNamePaths { + let linkModuleRoot = moduleRootPath + .parentDirectory + .appending(try RelativePath(validating: relativeLinkPath)) - /// Whether the project uses CMake (has a CMakeLists.txt in the project folder). - var isCMakeProject: Bool { - let cmakeLists = projectFolderPath.appending(component: "CMakeLists.txt") - return fs.exists(cmakeLists) - } + let dependencyModuleExport = linkModuleRoot + .parentDirectory + .appending(try moduleExportPath(forModule: linkModuleName)) + + do { + let exportLoadStart = Date().timeIntervalSinceReferenceDate + trace("dependencyModuleExport \(dependencyModuleExport): exists \(fs.exists(dependencyModuleExport))") + let exportData = try inputSource(dependencyModuleExport).withData { Data($0) } + let export = try decoder.decode(CodebaseInfo.ModuleExport.self, from: exportData) + dependentModuleExports.append(export) + let exportLoadEnd = Date().timeIntervalSinceReferenceDate + info("\(dependencyModuleExport.basename) codebase (\(exportData.count.byteCount)) loaded (\(Int64((exportLoadEnd - exportLoadStart) * 1000)) ms) for \(linkModuleName)", sourceFile: dependencyModuleExport.sourceFile) + } catch let e { + throw error("Skip: error loading codebase for \(linkModuleName): \(e.localizedDescription)", sourceFile: dependencyModuleExport.sourceFile) + } + } - /// Validates that the Skip/ folder exists (unless this is a CMake project). - /// - /// - Throws: `ValidationError` if the Skip/ folder is missing and this isn't a CMake project. - func validateSkipFolder() throws { - if !isCMakeProject && !fs.isDirectory(skipFolderPath) { - throw error("In order for Skip to process the module, a Skip/ folder must exist and contain a skip.yml file at: \(skipFolderPath)") + let codebaseInfo = CodebaseInfo(moduleName: primaryModuleName) + codebaseInfo.dependentModules = dependentModuleExports + return codebaseInfo } - } - - /// Takes a snapshot of all files currently in the output folder. - /// - /// This snapshot is compared against ``outputFiles`` at session end to identify - /// stale files from previous runs that should be cleaned up. - func snapshotExistingOutputFiles() throws { - self.outputFilesSnapshot = try FileManager.default.enumeratedURLs(of: outputFolderPath.asURL) - } - /// Ensures the module root directory exists, creating it if needed. - /// - /// - Throws: `ValidationError` if the directory cannot be created. - func ensureModuleRootExists() throws { - if !fs.isDirectory(moduleRootPath) { - try fs.createDirectory(moduleRootPath, recursive: true) - } - if !fs.isDirectory(moduleRootPath) { - throw error("Module root path did not exist at: \(moduleRootPath.pathString)") + func writeChanges(tag: String, to outputFilePath: AbsolutePath, contents: any DataProtocol, readOnly: Bool) throws { + let changed = try fs.writeChanges(path: addOutputFile(outputFilePath), makeReadOnly: readOnly, bytes: ByteString(contents)) + info("\(outputFilePath.relative(to: moduleBasePath).pathString) (\(contents.count.byteCount)) \(tag) \(!changed ? "unchanged" : "written")", sourceFile: outputFilePath.sourceFile) } - } - /// Enumerates the project folder to find Swift source files and resource files. - /// - /// Source files are any `.swift` files in the project folder. Resource files - /// are files under the `Resources/` subdirectory. - /// - /// - Returns: A tuple of (sourceURLs, resourceURLs). - func buildSourceList() throws -> (sources: [URL], resources: [URL]) { - let projectBaseURL = projectFolderPath.asURL - let allProjectFiles: [URL] = try FileManager.default.enumeratedURLs(of: projectBaseURL) - - let swiftPathExtensions: Set = ["swift"] - let sourceURLs: [URL] = allProjectFiles.filter({ swiftPathExtensions.contains($0.pathExtension) }) + func saveSourcehashFile() throws { + if !fs.isDirectory(moduleBasePath) { + try fs.createDirectory(moduleBasePath, recursive: true) + } - let projectResourcesURL = projectBaseURL.appendingPathComponent("Resources", isDirectory: true) - let resourceURLs: [URL] = try FileManager.default.enumeratedURLs(of: projectResourcesURL) + struct SourcehashContents : Encodable { + /// The version of Skip that generates this marker file + let skipstone: String = skipVersion - return (sources: sourceURLs, resources: resourceURLs) - } + /// The relative input paths and hashes for source files, in order to identify when input contents or file lists have changed + let sourcehashes: [String: String] + } - // MARK: - Phase 2: Configuration Loading + // create relative source paths so we do not encode full paths in the output + let sourcePathHashes: [(String, String)] = sourcehashes.compactMap { url, sourcehash in + let absolutePath = url.path + if !absolutePath.hasPrefix(projectFolderPath.pathString) { + return .none + } - /// Loads and merges skip.yml configs from this module and all its dependencies. - /// - /// After this method completes, ``baseSkipConfig``, ``mergedSkipConfig``, - /// ``configMap``, ``hasSkipFuse``, ``isNativeModule``, ``packageName``, - /// and ``resourceEntries`` are all populated. - func loadAndMergeConfiguration() throws { - let (base, merged, map) = try loadSkipConfig(merge: true) - self.baseSkipConfig = base - self.mergedSkipConfig = merged - self.configMap = map - self.hasSkipFuse = map.keys.contains("SkipFuse") + let relativePath = absolutePath.dropFirst(projectFolderPath.pathString.count).trimmingPrefix(while: { $0 == "/" }) + return (relativePath.description, sourcehash) + } - self.resourceEntries = try Self.buildResourceEntries( - config: base, resourceURLs: resourceURLs, projectBaseURL: projectFolderPath.asURL) + let sourcehash = SourcehashContents(sourcehashes: Dictionary(sourcePathHashes, uniquingKeysWith: { $1 })) + try writeChanges(tag: "sourcehash", to: sourcehashOutputPath, contents: try encoder.encode(sourcehash), readOnly: false) + } - self.isNativeModule = Self.resolveModuleMode( - moduleName: nil, configMap: map, baseConfig: base, - hasSkipFuse: hasSkipFuse, primaryModuleName: primaryModuleName) == .native + func saveCodebaseInfo() throws { + let outputFilePath = try moduleBasePath.appending(moduleExportPath(forModule: primaryModuleName)) + let moduleExport = CodebaseInfo.ModuleExport(of: codebaseInfo) + try writeChanges(tag: "codebase", to: outputFilePath, contents: encoder.encode(moduleExport), readOnly: true) + } - self.packageName = base.skip?.package ?? KotlinTranslator.packageName(forModule: primaryModuleName) - } + func saveSkipBridgeCode() throws { + // create the generated bridge files when the SKIP_BRIDGE environment is set and the plugin passed the --skip-bridge-output flag to the tool + if let skipBridgeOutput = skipstoneOptions.skipBridgeOutput { + let skipBridgeOutputFolder = try AbsolutePath(validating: skipBridgeOutput) - /// Loads a single skip.yml file, optionally filtering `export: false` blocks. - /// - /// - Parameters: - /// - path: Absolute path to the skip.yml file. - /// - forExport: When true, blocks marked `export: false` are stripped. - /// - Returns: The parsed `SkipConfig`. - func loadSkipYAML(path: AbsolutePath, forExport: Bool) throws -> SkipConfig { - do { - var yaml = try inputSource(path).withData(YAML.parse(_:)) - if yaml.object == nil { - yaml = .object([:]) - } + let swiftBridgeFileNameTranspilationMap = skipBridgeTranspilations.reduce(into: Dictionary()) { result, transpilation in + result[transpilation.output.file.name] = transpilation + } - if forExport { - yaml = Self.filterExportYAML(yaml) ?? yaml - } - return try yaml.json().decode() - } catch let e { - throw error("The skip.yml file at \(path) could not be loaded: \(e)", sourceFile: path.sourceFile) - } - } + for swiftSourceFile in sourceURLs.filter({ $0.pathExtension == "swift"}) { + let swiftFileBase = swiftSourceFile.deletingPathExtension().lastPathComponent + let swiftBridgeFileName = swiftFileBase.appending(Source.FilePath.bridgeFileSuffix) + let swiftBridgeOutputPath = skipBridgeOutputFolder.appending(components: [swiftBridgeFileName]) - /// Loads the skip.yml config, optionally merged with dependent module configs. - /// - /// When `merge` is true, iterates through all --module dependencies, loads each - /// module's skip.yml, and produces an aggregate config. The aggregate includes - /// auto-generated Gradle dependency blocks and, for app modules, manifest - /// placeholder configuration from the .xcconfig file. - /// - /// - Parameters: - /// - merge: Whether to merge with dependent module configs. Defaults to true. - /// - configFileName: The config filename. Defaults to "skip.yml". - /// - Returns: A tuple of (base config, merged config, per-module config map). - func loadSkipConfig(merge: Bool = true, configFileName: String = "skip.yml") throws -> (base: SkipConfig, merged: SkipConfig, configMap: [String: SkipConfig]) { - let configStart = Date().timeIntervalSinceReferenceDate - let skipConfigPath = skipFolderPath.appending(component: configFileName) - let currentModuleConfig = try loadSkipYAML(path: skipConfigPath, forExport: false) - - var configMap: [String: SkipConfig] = [:] - configMap[primaryModuleName] = currentModuleConfig - - let currentModuleJSON = try currentModuleConfig.json() - info("loading skip.yml from \(skipConfigPath)", sourceFile: skipConfigPath.sourceFile) - - if !merge { - return (currentModuleConfig, currentModuleConfig, configMap) - } + // FIXME: this doesn't handle the case where there are multiple files with the same name in a project (e.g., Folder1/Utils.swift and Folder2/Utils.swift). We would need to handle un-flattened project hierarchies to get past this + let bridgeContents: String + if let bridgeTranspilation = swiftBridgeFileNameTranspilationMap[swiftBridgeFileName] { + bridgeContents = bridgeTranspilation.output.content + } else { + bridgeContents = "" + } + try writeChanges(tag: "skipbridge", to: swiftBridgeOutputPath, contents: bridgeContents.utf8Data, readOnly: true) + } - var aggregateJSON: Universal.JSON = [:] + // write support files + for supportFileName in [KotlinDynamicObjectTransformer.supportFileName, KotlinBundleTransformer.supportFileName, KotlinFoundationBridgeTransformer.supportFileName] { + let supportContents: String + if let supportTranspilation = swiftBridgeFileNameTranspilationMap[supportFileName] { + supportContents = supportTranspilation.output.content + } else { + supportContents = "" + } + let supportOutputPath = skipBridgeOutputFolder.appending(components: [supportFileName]) + try writeChanges(tag: "skipbridge", to: supportOutputPath, contents: supportContents.utf8Data, readOnly: true) + } - for (moduleName, modulePath) in moduleNamePaths { - trace("moduleName: \(moduleName) modulePath: \(modulePath) primaryModuleName: \(primaryModuleName)") - if moduleName == primaryModuleName { - continue + return } - let moduleSkipBasePath = try AbsolutePath(validating: modulePath, relativeTo: moduleRootPath.parentDirectory) - .appending(components: ["Skip"]) - - let moduleSkipConfigPath = moduleSkipBasePath.appending(component: configFileName) - - if fs.isFile(moduleSkipConfigPath) { - let skipConfigLoadStart = Date().timeIntervalSinceReferenceDate - let isTestPeer = primaryModuleName == moduleName + "Tests" - trace("primaryModuleName: \(primaryModuleName) moduleName: \(moduleName) isTestPeer=\(isTestPeer)") - let isForExport = !isTestPeer - let moduleConfig = try loadSkipYAML(path: moduleSkipConfigPath, forExport: isForExport) - configMap[moduleName] = moduleConfig - let skipConfigLoadEnd = Date().timeIntervalSinceReferenceDate - info("\(moduleName) skip.yml config loaded (\(Int64((skipConfigLoadEnd - skipConfigLoadStart) * 1000)) ms)", sourceFile: moduleSkipConfigPath.sourceFile) - aggregateJSON = try aggregateJSON.merged(with: moduleConfig.json()) + // if the package is to be bridged, then create a src/main/swift folder that links to the source package + guard isNativeModule || !skipBridgeTranspilations.isEmpty else { + return } - } - - aggregateJSON = try aggregateJSON.merged(with: currentModuleJSON) - // Merge auto-generated module dependency and app config blocks - do { - var moduleDependencyBlocks: [GradleBlock.BlockOrCommand] = [] - - for (moduleName, _) in moduleNamePaths { - if Self.isTestModule(moduleName, primaryModuleName: primaryModuleName) { - if moduleName == "SkipUnit" { - moduleDependencyBlocks += [ - .init("testImplementation(project(\":\(moduleName)\"))"), - .init("androidTestImplementation(project(\":\(moduleName)\"))") - ] + // Link src/main/swift/ to the absolute Swift project folder + let swiftLinkFolder = try AbsolutePath(outputFolderPath, validating: "swift") + try fs.createDirectory(swiftLinkFolder, recursive: true) + + // create Packages/swift-package-name links for all the project's package dependencies so we use the local versions in our swift build rather than downloading the remote dependencies + // this will sync with Xcode's workspace, which will enable local package development of dependencies to work the same with this derived package as it does in Xcode + let packagesLinkFolder = try AbsolutePath(swiftLinkFolder, validating: "Packages") + try fs.createDirectory(packagesLinkFolder, recursive: true) + + // to use the package, we could do the equivalent of `swift package edit --path /path/to/local/package-id package-id, + // but this would involve writing to the .build/workspace-state.json file with the "edited" property, which is + // not a stable or well-documented format, and would require a lot of other metadata about the package; + // so instead we tack on some code to the Package.swift file that we output + // + // We pass dependencies as an inout parameter to bypass Swift 6+ requiring that it be @MainActor. + var packageAddendum = """ + + /// Convert remote dependencies into their locally-cached versions. + /// This allows us to re-use dependencies from the parent + /// Xcode/SwiftPM process without redundently cloning them. + func useLocalPackage(named packageName: String, id packageID: String, dependencies: inout [Package.Dependency]) { + func localDependency(name: String?, location: String) -> Package.Dependency? { + if name == packageID || location.hasSuffix("/" + packageID) || location.hasSuffix("/" + packageID + ".git") { + return Package.Dependency.package(path: "Packages/" + packageID) } else { - moduleDependencyBlocks += [ - .init("api(project(\":\(moduleName)\"))"), - ] + return nil + } + } + dependencies = dependencies.map { dep in + switch dep.kind { + case let .sourceControl(name: name, location: location, requirement: _): + return localDependency(name: name, location: location) ?? dep + case let .fileSystem(name: name, path: location): + return localDependency(name: name, location: location) ?? dep + default: + return dep } } } + + """ - var localConfig = GradleBlock(contents: [.init(GradleBlock(block: "dependencies", contents: moduleDependencyBlocks))]) + var createdIds: Set = [] - if isAppModule { - var manifestConfigLines: [String] = [] + let moduleLinkPaths = Dictionary(self.linkNamePaths, uniquingKeysWith: { $1 }) - let moduleXCConfigContents = try String(contentsOf: moduleXCConfig.asURL, encoding: .utf8) - for (key, value) in parseXCConfig(contents: moduleXCConfigContents) { - manifestConfigLines += [""" - manifestPlaceholders["\(key)"] = System.getenv("\(key)") ?: "\(value)" - """] - } + for (targetName, packageName, var packagePath) in self.dependencyIdPaths { + // the package name in the Package.swift typically the last part of the repository name (e.g., "swift-algorithms" in https://github.com/apple/swift-algorithms.git ), but for other packages it isn't (e.g., "Lottie" for https://github.com/airbnb/lottie-ios.git ); we need to use the repository name + let packageID = packagePath.split(separator: "/").last?.description ?? packagePath - manifestConfigLines += [""" - applicationId = manifestPlaceholders["PRODUCT_BUNDLE_IDENTIFIER"]?.toString().replace("-", "_") - """] + if !createdIds.insert(packageID).inserted { + // only create the link once, even if specified multiple times + continue + } - manifestConfigLines += [""" - versionCode = (manifestPlaceholders["CURRENT_PROJECT_VERSION"]?.toString())?.toInt() - """] + // check whether the linked target is another linked Skip folder, and if so, check whether it has a derived src/main/swift folder (which indicates that it is a bridging package in which case we need the package to reference the *derived* sources rather than the *original* sources) + if let relativeLinkPath = moduleLinkPaths[targetName] { + let linkModuleRoot = moduleRootPath + .parentDirectory + .appending(try RelativePath(validating: relativeLinkPath)) + let linkModuleSrcMainSwift = linkModuleRoot.appending(components: "src", "main", "swift") + if fs.exists(linkModuleSrcMainSwift) { + info("override link path for \(targetName) from \(packagePath) to \(linkModuleSrcMainSwift.pathString)") + packagePath = linkModuleSrcMainSwift.pathString + } + } - manifestConfigLines += [""" - versionName = manifestPlaceholders["MARKETING_VERSION"]?.toString() - """] + let dependencyPackageLink = try AbsolutePath(packagesLinkFolder, validating: packageID) + let destinationPath = try AbsolutePath(validating: packagePath) + try addLink(dependencyPackageLink, pointingAt: destinationPath, relative: false) - localConfig.contents?.append(.init(GradleBlock(block: "android", contents: [ - .init(GradleBlock(block: "defaultConfig", contents: manifestConfigLines.map({ .a($0) }))) - ]))) + packageAddendum += """ + useLocalPackage(named: "\(packageName)", id: "\(packageID)", dependencies: &package.dependencies) + + """ } - aggregateJSON = try aggregateJSON.merged(with: JSON.object(["build": localConfig.json()])) + // The source of the link tree needs to be the root project for the module in question, which we don't have access to (it can't be the `rootPath`, since that will be the topmost package that resulted in the transpiler invocation, which may not be the module in question). + // So we need to guess from the projectFolderPath, which will be something like `/path/to/project-name/Sources/TargetName` by tacking `../..` to the end of the path. + // WARNING: this is delicate, because there is nothing guaranteeing that the project follows the convention of `Sources/TargetName` for their modules! + //let mirrorSource = rootPath + let mirrorSource = projectFolderPath.appending(components: "..", "..") + + //warn("creating absolute merged link tree from: swiftLinkFolder=\(swiftLinkFolder) to mirrorSource=\(mirrorSource) (rootPath=\(rootPath)) with dependencyIdPaths=\(dependencyIdPaths)") + try createMirroredLinkTree(swiftLinkFolder, pointingAt: mirrorSource, shallow: true, excluding: ["Packages", "Package.resolved", ".build", ".swiftpm", "skip-export", "build"]) { destPath, path in + trace("createMirroredLinkTree for \(path.pathString)->\(destPath)") + + // manually add the packageAddendum the Package.swift + if path.basename == "Package.swift" && !self.dependencyIdPaths.isEmpty { + let packageContents = try fs.readFileContents(path).withData { $0 + packageAddendum.utf8Data } + try writeChanges(tag: "skippackage", to: destPath, contents: packageContents, readOnly: true) + return false // override the linking of the file + } else { + return true + } + } } - var aggregateSkipConfig: SkipConfig = try aggregateJSON.decode() - aggregateSkipConfig.build?.removeContent(withExports: true) - aggregateSkipConfig.settings?.removeContent(withExports: true) + func generateGradle(for sourceModules: [String], with skipConfig: SkipConfig, isApp: Bool) throws { + try generateGradleWrapperProperties() + try generateProguardFile(packageName) + try generatePerModuleGradle() + try generateGradleProperties() + try generateSettingsGradle() - let configEnd = Date().timeIntervalSinceReferenceDate - info("skip.yml aggregate created (\(Int64((configEnd - configStart) * 1000)) ms) for modules: \(moduleNamePaths.map(\.module))") - return (currentModuleConfig, aggregateSkipConfig, configMap) - } + func generatePerModuleGradle() throws { + let buildContents = (skipConfig.build ?? .init()).generate(context: .init(dsl: .kotlin)) - // MARK: - Phase 3: Source Hash Computation + // we output as a joined string because there is a weird stdout bug with the tool or plugin executor somewhere that causes multi-line strings to be output in the wrong order + trace("created gradle: \(buildContents.split(separator: "\n").map({ $0.trimmingCharacters(in: .whitespaces) }).joined(separator: "; "))") - /// Computes SHA256 hashes for all source files and Skip/ folder contents. - /// - /// These hashes are written to the sourcehash marker file to enable the build - /// plugin to detect when source content has changed. - func computeSourceHashes() async throws { - let skipFolderPathContents = try FileManager.default.enumeratedURLs(of: skipFolderPath.asURL) - .filter({ (try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true }) + let contents = """ + // build.gradle.kts generated by Skip for \(primaryModuleName) - let sourceHashStart = Date().timeIntervalSinceReferenceDate - self.sourcehashes = try await command.loadSourceHashes(from: sourceURLs + skipFolderPathContents) - let sourceHashEnd = Date().timeIntervalSinceReferenceDate - info("source hashes calculated \(self.sourcehashes) files for modules: \(moduleNamePaths.map(\.module)) (\(Int64((sourceHashEnd - sourceHashStart) * 1000)) ms)") - } + """ + buildContents - // MARK: - Phase 4: Transpilation + try writeChanges(tag: "gradle project", to: buildGradle, contents: contents.utf8Data, readOnly: true) + } - /// Loads codebase info from previously transpiled dependent modules. - /// - /// Iterates through --link arguments to find `ModuleName.skipcode.json` files - /// from prior transpilation runs. These provide type and function information - /// that the transpiler needs for cross-module references. - /// - /// - Returns: A populated `CodebaseInfo` with dependent module exports. - func loadCodebaseInfo() async throws -> CodebaseInfo { - let decoder = JSONDecoder() - var dependentModuleExports: [CodebaseInfo.ModuleExport] = [] + func generateSettingsGradle() throws { + let settingsPath = moduleRootPath.parentDirectory.appending(component: "settings.gradle.kts") + var settingsContents = (skipConfig.settings ?? .init()).generate(context: .init(dsl: .kotlin)) - for (linkModuleName, relativeLinkPath) in linkNamePaths { - let linkModuleRoot = moduleRootPath - .parentDirectory - .appending(try RelativePath(validating: relativeLinkPath)) + settingsContents += """ - let dependencyModuleExport = linkModuleRoot - .parentDirectory - .appending(try moduleExportPath(forModule: linkModuleName)) + rootProject.name = "\(packageName)" - do { - let exportLoadStart = Date().timeIntervalSinceReferenceDate - trace("dependencyModuleExport \(dependencyModuleExport): exists \(fs.exists(dependencyModuleExport))") - let exportData = try inputSource(dependencyModuleExport).withData { Data($0) } - let export = try decoder.decode(CodebaseInfo.ModuleExport.self, from: exportData) - dependentModuleExports.append(export) - let exportLoadEnd = Date().timeIntervalSinceReferenceDate - info("\(dependencyModuleExport.basename) codebase (\(exportData.count.byteCount)) loaded (\(Int64((exportLoadEnd - exportLoadStart) * 1000)) ms) for \(linkModuleName)", sourceFile: dependencyModuleExport.sourceFile) - } catch let e { - throw error("Skip: error loading codebase for \(linkModuleName): \(e.localizedDescription)", sourceFile: dependencyModuleExport.sourceFile) - } - } + """ - let codebaseInfo = CodebaseInfo(moduleName: primaryModuleName) - codebaseInfo.dependentModules = dependentModuleExports - return codebaseInfo - } + var bridgedModules: [String] = [] - /// Links the CMake project's ext/ directory to the project folder. - func linkCMakeProject() throws { - let extLink = moduleRootPath.appending(component: "ext") - try addLink(extLink, pointingAt: projectFolderPath, relative: false) - } + func addIncludeModule(_ moduleName: String) { + settingsContents += """ + include(":\(moduleName)") + project(":\(moduleName)").projectDir = file("\(moduleName)") - /// Creates output folders and sets up the androidTest symlink for test modules. - /// - /// - Returns: The path to the Kotlin output folder. - func setupOutputFolders() throws -> AbsolutePath { - let kotlinOutputFolder = try AbsolutePath(outputFolderPath, validating: "kotlin") + """ - if !fs.isDirectory(kotlinOutputFolder) { - try fs.createDirectory(kotlinOutputFolder, recursive: true) - } + if moduleMode(for: moduleName) == .native { + bridgedModules.append(moduleName) + } + } - // Link src/androidTest/kotlin → src/test/kotlin for test modules - if primaryModuleName.hasSuffix("Tests") { - let androidTestOutputFolder = try AbsolutePath(outputFolderPath, validating: "../androidTest") - removePath(androidTestOutputFolder) - try fs.createSymbolicLink(addOutputFile(androidTestOutputFolder), pointingAt: outputFolderPath, relative: true) - } + // always add the primary module include + if !sourceModules.contains(primaryModuleName) && !primaryModuleName.hasSuffix("Tests") { + addIncludeModule(primaryModuleName) + } - return kotlinOutputFolder - } + for sourceModule in sourceModules { + addIncludeModule(sourceModule) + } - /// Creates transpiler transformers and links override files from the Skip/ folder. - /// - /// Override `.kt` files in the Skip/ folder take precedence over transpiled output. - /// The `buildSrc` folder, if present, is also linked for Gradle build scripts. - /// - /// - Parameter kotlinOutputFolder: The Kotlin output folder to link overrides into. - func setupTransformersAndOverrides(kotlinOutputFolder: AbsolutePath) throws { - let transformers = try command.createTransformers(for: baseSkipConfig, with: configMap) - let overridden = try linkSkipFolder(skipFolderPath, to: kotlinOutputFolder, topLevel: true) - self.overriddenKotlinFiles = overridden.map({ $0.basename }) + if !bridgedModules.isEmpty { + settingsContents += """ - let buildSrcFolder = skipFolderPath.appending(component: buildSrcFolderName) - if fs.isDirectory(buildSrcFolder) { - try addLink(moduleBasePath.appending(component: buildSrcFolderName), pointingAt: buildSrcFolder, relative: false) - } - - // Store transformers for use by the transpiler - self._transformers = transformers - } - - /// Stored transformers, set by ``setupTransformersAndOverrides(kotlinOutputFolder:)``. - private var _transformers: [KotlinTransformer] = [] - - /// Creates and runs the transpiler, handling each transpilation result. - /// - /// Source files are categorized into transpilation targets (transpiled mode) or - /// native bridge files (native mode). The transpiler processes all files and - /// calls ``handleTranspilation(transpilation:kotlinOutputFolder:with:)`` for each result. - /// - /// - Parameters: - /// - autoBridge: The auto-bridge mode for the transpiler. - /// - dynamicRoot: The dynamic root class name, if any. - /// - kotlinOutputFolder: The Kotlin output folder path. - /// - out: The message queue for yielding results. - func runTranspiler(autoBridge: AutoBridge, dynamicRoot: String?, kotlinOutputFolder: AbsolutePath, with out: MessageQueue) async throws { - let (transpileFiles, swiftFiles) = Self.categorizeSourceFiles(sourceURLs: sourceURLs, isNative: isNativeModule) - - let transpiler = Transpiler( - packageName: packageName, - transpileFiles: transpileFiles.map(Source.FilePath.init(path:)), - bridgeFiles: swiftFiles.map(Source.FilePath.init(path:)), - autoBridge: autoBridge, - isBridgeGatherEnabled: dynamicRoot != nil, - codebaseInfo: codebaseInfo, - preprocessorSymbols: Set(command.inputOptions.symbols), - transformers: _transformers) - - try await transpiler.transpile(handler: { transpilation in - try await self.handleTranspilation(transpilation: transpilation, kotlinOutputFolder: kotlinOutputFolder, with: out) - }) - } - - /// Handles a single transpilation result by writing output files and forwarding messages. - /// - /// Bridge transpilations are accumulated for later processing. Regular transpilations - /// are written to the Kotlin output folder with source mapping files. - /// - /// - Parameters: - /// - transpilation: The transpilation result to process. - /// - kotlinOutputFolder: The Kotlin output folder path. - /// - out: The message queue for yielding results. - func handleTranspilation(transpilation: Transpilation, kotlinOutputFolder: AbsolutePath, with out: MessageQueue) async throws { - for message in transpilation.messages { - await out.yield(message) - } + gradle.extra["bridgeModules"] = listOf("\(bridgedModules.joined(separator: "\", \""))") - switch transpilation.outputType { - case .bridgeToSwift, .bridgeFromSwift: - skipBridgeTranspilations.append(transpilation) - return - case .default: - break - } + """ + } - if skipstoneOptions.skipBridgeOutput != nil { - return - } + try writeChanges(tag: "gradle settings", to: settingsPath, contents: settingsContents.utf8Data, readOnly: true) + } - let sourcePath = try AbsolutePath(validating: transpilation.input.file.path) + /// Create the proguard-rules.pro file, which configures the optimization settings for release buils + func generateProguardFile(_ packageName: String) throws { + try writeChanges(tag: "proguard", to: moduleRootPath.appending(component: "proguard-rules.pro"), contents: FrameworkProjectLayout.defaultProguardContents(packageName).utf8Data, readOnly: true) + } - let (outputFile, changed, overridden) = try saveTranspilation(transpilation: transpilation, kotlinOutputFolder: kotlinOutputFolder) - info("\(outputFile.relative(to: moduleBasePath).pathString) (\(transpilation.output.content.lengthOfBytes(using: .utf8).byteCount)) transpilation \(overridden ? "overridden" : !changed ? "unchanged" : "saved") from \(sourcePath.basename) (\(transpilation.input.content.lengthOfBytes(using: .utf8).byteCount)) in \(Int64(transpilation.duration * 1000)) ms", sourceFile: overridden ? transpilation.input.file : outputFile.sourceFile) + /// Create the gradle-wrapper.properties file, which will dictate which version of Gradle that Android Studio should use to build the project. + func generateGradleWrapperProperties() throws { + let gradleWrapperFolder = moduleRootPath.parentDirectory.appending(components: "gradle", "wrapper") + try fs.createDirectory(gradleWrapperFolder, recursive: true) + let gradleWrapperPath = gradleWrapperFolder.appending(component: "gradle-wrapper.properties") + let gradeWrapperContents = FrameworkProjectLayout.defaultGradleWrapperProperties() + try writeChanges(tag: "gradle wrapper", to: gradleWrapperPath, contents: gradeWrapperContents.utf8Data, readOnly: true) + } - for message in transpilation.messages { - if message.kind == .error { - await out.finish(throwing: message) - return + func generateGradleProperties() throws { + let gradlePropertiesPath = moduleRootPath.parentDirectory.appending(component: "gradle.properties") + + let defaultPropertiesString = FrameworkProjectLayout.defaultGradleProperties() + var properties: [String: String] = [:] + + for line in defaultPropertiesString.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { + continue + } + let parts = trimmed.split(separator: "=", maxSplits: 1) + if parts.count == 2 { + let key = String(parts[0]).trimmingCharacters(in: .whitespaces) + let value = String(parts[1]).trimmingCharacters(in: .whitespaces) + properties[key] = value + } + } + + // Merge with custom properties from skip.yml (custom properties override defaults) + if let customProperties = skipConfig.gradleProperties { + for (key, value) in customProperties { + properties[key] = value + } + } + + var gradePropertiesContents = "" + for (key, value) in properties.sorted(by: { $0.key < $1.key }) { + gradePropertiesContents += "\(key)=\(value)\n" + } + gradePropertiesContents += "\n" + + try writeChanges(tag: "gradle config", to: gradlePropertiesPath, contents: gradePropertiesContents.utf8Data, readOnly: true) } } - let output = SkipstoneCommand.Output(transpilation: transpilation) - await out.yield(output) - } + func loadSkipYAML(path: AbsolutePath, forExport: Bool) throws -> SkipConfig { + do { + var yaml = try inputSource(path).withData(YAML.parse(_:)) + if yaml.object == nil { // an empty file will appear as nil, so just convert to an empty dictionary + yaml = .object([:]) + } - /// Writes a single transpilation's Kotlin output and source map to disk. - /// - /// If the output filename has been overridden by a file in the Skip/ folder, - /// the transpiled output is skipped. - /// - /// - Parameters: - /// - transpilation: The transpilation result to save. - /// - kotlinOutputFolder: The Kotlin output folder path. - /// - Returns: A tuple of (output path, whether the file changed, whether it was overridden). - func saveTranspilation(transpilation: Transpilation, kotlinOutputFolder: AbsolutePath) throws -> (output: AbsolutePath, changed: Bool, overridden: Bool) { - trace("path: \(kotlinOutputFolder)") - - let kotlinName = transpilation.kotlinFileName - guard let outputFilePath = try Self.resolveSourceFileOutputPath( - for: kotlinName, packageName: packageName, - kotlinFolder: kotlinOutputFolder, - javaFolder: try AbsolutePath(outputFolderPath, validating: "java"), - manifestName: androidManifestName, basePath: nil) else { - throw error("No output path for \(kotlinName)") - } + // go through all the top-level "export: false" blocks and remove them when the config is being imported elsewhere + if forExport { + func filterExport(from yaml: YAML) -> YAML? { + guard var obj = yaml.object else { + if let array = yaml.array { + return .array(array.compactMap(filterExport(from:))) + } else { + return yaml + } + } + for (key, value) in obj { + if key == "export" { + if value.boolean == false { + // skip over the whole dict + return nil + } + } else { + obj[key] = filterExport(from: value) + } + } + return .object(obj) + } - if overriddenKotlinFiles.contains(kotlinName) { - return (output: outputFilePath, changed: false, overridden: true) + yaml = filterExport(from: yaml) ?? yaml + } + return try yaml.json().decode() + } catch let e { + throw error("The skip.yml file at \(path) could not be loaded: \(e)", sourceFile: path.sourceFile) + } } - let kotlinBytes = ByteString(encodingAsUTF8: transpilation.output.content) - let fileWritten = try fs.writeChanges(path: addOutputFile(outputFilePath), checkSize: true, makeReadOnly: true, bytes: kotlinBytes) + /// Loads the `skip.yml` config, optionally merged with the `skip.yml` of all the module dependencies + func loadSkipConfig(merge: Bool = true, configFileName: String = "skip.yml") throws -> (base: SkipConfig, merged: SkipConfig, configMap: [String: SkipConfig]) { + let configStart = Date().timeIntervalSinceReferenceDate + let skipConfigPath = skipFolderPath.appending(component: configFileName) + let currentModuleConfig = try loadSkipYAML(path: skipConfigPath, forExport: false) - trace("wrote to: \(outputFilePath)\(!fileWritten ? " (unchanged)" : "")") + var configMap: [String: SkipConfig] = [:] + configMap[primaryModuleName] = currentModuleConfig - // Save the source map file - let sourceMappingPath = outputFilePath.parentDirectory.appending(component: "." + outputFilePath.basenameWithoutExt + ".sourcemap") - let sourceMapData = try self.encoder.encode(transpilation.outputMap) - try fs.writeChanges(path: addOutputFile(sourceMappingPath), makeReadOnly: true, bytes: ByteString(sourceMapData)) - - return (output: outputFilePath, changed: fileWritten, overridden: false) - } - - // MARK: - Phase 5: Output Saving & Linking - - /// Saves the codebase info JSON for consumption by downstream module transpilations. - func saveCodebaseInfo() throws { - let outputFilePath = try moduleBasePath.appending(moduleExportPath(forModule: primaryModuleName)) - let moduleExport = CodebaseInfo.ModuleExport(of: codebaseInfo) - try writeChanges(tag: "codebase", to: outputFilePath, contents: encoder.encode(moduleExport), readOnly: true) - } + let currentModuleJSON = try currentModuleConfig.json() + info("loading skip.yml from \(skipConfigPath)", sourceFile: skipConfigPath.sourceFile) - /// Saves bridge code files or sets up the native Swift link tree. - /// - /// When `--skip-bridge-output` is set, writes generated bridge Swift files - /// for each source file. Otherwise, if the module is native or has bridge - /// transpilations, creates a mirrored link tree for native Swift compilation - /// on Android. - func saveSkipBridgeCode() throws { - if let skipBridgeOutput = skipstoneOptions.skipBridgeOutput { - let skipBridgeOutputFolder = try AbsolutePath(validating: skipBridgeOutput) - - let swiftBridgeFileNameTranspilationMap = skipBridgeTranspilations.reduce(into: Dictionary()) { result, transpilation in - result[transpilation.output.file.name] = transpilation + if !merge { + return (currentModuleConfig, currentModuleConfig, configMap) // just the unmerged base YAML } - for swiftSourceFile in sourceURLs.filter({ $0.pathExtension == "swift"}) { - let swiftFileBase = swiftSourceFile.deletingPathExtension().lastPathComponent - let swiftBridgeFileName = swiftFileBase.appending(Source.FilePath.bridgeFileSuffix) - let swiftBridgeOutputPath = skipBridgeOutputFolder.appending(components: [swiftBridgeFileName]) + // build up a merged YAML from the base dependencies to the current module + var aggregateJSON: Universal.JSON = [:] - let bridgeContents: String - if let bridgeTranspilation = swiftBridgeFileNameTranspilationMap[swiftBridgeFileName] { - bridgeContents = bridgeTranspilation.output.content - } else { - bridgeContents = "" + for (moduleName, modulePath) in moduleNamePaths { + trace("moduleName: \(moduleName) modulePath: \(modulePath) primaryModuleName: \(primaryModuleName)") + if moduleName == primaryModuleName { + // don't merge the primary module name with itself + continue } - try writeChanges(tag: "skipbridge", to: swiftBridgeOutputPath, contents: bridgeContents.utf8Data, readOnly: true) - } - for supportFileName in [KotlinDynamicObjectTransformer.supportFileName, KotlinBundleTransformer.supportFileName, KotlinFoundationBridgeTransformer.supportFileName] { - let supportContents: String - if let supportTranspilation = swiftBridgeFileNameTranspilationMap[supportFileName] { - supportContents = supportTranspilation.output.content - } else { - supportContents = "" + let moduleSkipBasePath = try AbsolutePath(validating: modulePath, relativeTo: moduleRootPath.parentDirectory) + .appending(components: ["Skip"]) + + let moduleSkipConfigPath = moduleSkipBasePath.appending(component: configFileName) + + if fs.isFile(moduleSkipConfigPath) { + let skipConfigLoadStart = Date().timeIntervalSinceReferenceDate + let isTestPeer = primaryModuleName == moduleName + "Tests" // test peers have the same module name + trace("primaryModuleName: \(primaryModuleName) moduleName: \(moduleName) isTestPeer=\(isTestPeer)") // SkipLibTests moduleName: SkipLib + let isForExport = !isTestPeer + let moduleConfig = try loadSkipYAML(path: moduleSkipConfigPath, forExport: isForExport) + configMap[moduleName] = moduleConfig // remember the raw config for use in configuring transpiler plug-ins + let skipConfigLoadEnd = Date().timeIntervalSinceReferenceDate + info("\(moduleName) skip.yml config loaded (\(Int64((skipConfigLoadEnd - skipConfigLoadStart) * 1000)) ms)", sourceFile: moduleSkipConfigPath.sourceFile) + aggregateJSON = try aggregateJSON.merged(with: moduleConfig.json()) } - let supportOutputPath = skipBridgeOutputFolder.appending(components: [supportFileName]) - try writeChanges(tag: "skipbridge", to: supportOutputPath, contents: supportContents.utf8Data, readOnly: true) } - return - } - - guard isNativeModule || !skipBridgeTranspilations.isEmpty else { - return - } + aggregateJSON = try aggregateJSON.merged(with: currentModuleJSON) - // Link src/main/swift/ to the Swift project folder for native compilation - let swiftLinkFolder = try AbsolutePath(outputFolderPath, validating: "swift") - try fs.createDirectory(swiftLinkFolder, recursive: true) - - let packagesLinkFolder = try AbsolutePath(swiftLinkFolder, validating: "Packages") - try fs.createDirectory(packagesLinkFolder, recursive: true) - - var packageAddendum = """ - - /// Convert remote dependencies into their locally-cached versions. - /// This allows us to re-use dependencies from the parent - /// Xcode/SwiftPM process without redundently cloning them. - func useLocalPackage(named packageName: String, id packageID: String, dependencies: inout [Package.Dependency]) { - func localDependency(name: String?, location: String) -> Package.Dependency? { - if name == packageID || location.hasSuffix("/" + packageID) || location.hasSuffix("/" + packageID + ".git") { - return Package.Dependency.package(path: "Packages/" + packageID) - } else { - return nil - } - } - dependencies = dependencies.map { dep in - switch dep.kind { - case let .sourceControl(name: name, location: location, requirement: _): - return localDependency(name: name, location: location) ?? dep - case let .fileSystem(name: name, path: location): - return localDependency(name: name, location: location) ?? dep - default: - return dep + // finally, merge with a manually constructed SkipConfig that contains references to the modules this module depends on + do { + var moduleDependencyBlocks: [GradleBlock.BlockOrCommand] = [] + + for (moduleName, _) in moduleNamePaths { + // manually exclude our own module and tests names + if isTestModule(moduleName) { + if moduleName == "SkipUnit" { + moduleDependencyBlocks += [ + .init("testImplementation(project(\":\(moduleName)\"))"), + .init("androidTestImplementation(project(\":\(moduleName)\"))") + ] + } else { + moduleDependencyBlocks += [ + .init("api(project(\":\(moduleName)\"))"), + ] + } + } } - } - } - - """ - var createdIds: Set = [] - let moduleLinkPaths = Dictionary(self.linkNamePaths, uniquingKeysWith: { $1 }) + var localConfig = GradleBlock(contents: [.init(GradleBlock(block: "dependencies", contents: moduleDependencyBlocks))]) - for (targetName, packageName, var packagePath) in self.dependencyIdPaths { - let packageID = packagePath.split(separator: "/").last?.description ?? packagePath + // for app modules, import its settings into the manifestPlaceholders dictionary in the `android { defaultConfig { } }` block + if isAppModule { + var manifestConfigLines: [String] = [] - if !createdIds.insert(packageID).inserted { - continue - } + let moduleXCConfigContents = try String(contentsOf: moduleXCConfig.asURL, encoding: .utf8) + for (key, value) in parseXCConfig(contents: moduleXCConfigContents) { + manifestConfigLines += [""" + manifestPlaceholders["\(key)"] = System.getenv("\(key)") ?: "\(value)" + """] + } - if let relativeLinkPath = moduleLinkPaths[targetName] { - let linkModuleRoot = moduleRootPath - .parentDirectory - .appending(try RelativePath(validating: relativeLinkPath)) - let linkModuleSrcMainSwift = linkModuleRoot.appending(components: "src", "main", "swift") - if fs.exists(linkModuleSrcMainSwift) { - info("override link path for \(targetName) from \(packagePath) to \(linkModuleSrcMainSwift.pathString)") - packagePath = linkModuleSrcMainSwift.pathString - } - } - let dependencyPackageLink = try AbsolutePath(packagesLinkFolder, validating: packageID) - let destinationPath = try AbsolutePath(validating: packagePath) - try addLink(dependencyPackageLink, pointingAt: destinationPath, relative: false) + // now do some manual configuration of the android properties + manifestConfigLines += [""" + applicationId = manifestPlaceholders["PRODUCT_BUNDLE_IDENTIFIER"]?.toString().replace("-", "_") + """] - packageAddendum += """ - useLocalPackage(named: "\(packageName)", id: "\(packageID)", dependencies: &package.dependencies) - - """ - } + manifestConfigLines += [""" + versionCode = (manifestPlaceholders["CURRENT_PROJECT_VERSION"]?.toString())?.toInt() + """] - let mirrorSource = projectFolderPath.appending(components: "..", "..") + manifestConfigLines += [""" + versionName = manifestPlaceholders["MARKETING_VERSION"]?.toString() + """] - try createMirroredLinkTree(swiftLinkFolder, pointingAt: mirrorSource, shallow: true, excluding: ["Packages", "Package.resolved", ".build", ".swiftpm", "skip-export", "build"]) { destPath, path in - self.trace("createMirroredLinkTree for \(path.pathString)->\(destPath)") + localConfig.contents?.append(.init(GradleBlock(block: "android", contents: [ + .init(GradleBlock(block: "defaultConfig", contents: manifestConfigLines.map({ .a($0) }))) + ]))) + } - if path.basename == "Package.swift" && !self.dependencyIdPaths.isEmpty { - let packageContents = try self.fs.readFileContents(path).withData { $0 + packageAddendum.utf8Data } - try self.writeChanges(tag: "skippackage", to: destPath, contents: packageContents, readOnly: true) - return false - } else { - return true + aggregateJSON = try aggregateJSON.merged(with: JSON.object(["build": localConfig.json()])) } - } - } - - /// Links dependent module output directories into the current module's output tree. - /// - /// Creates symbolic links from the dependent module build outputs to the current - /// module, allowing Gradle to see all modules in a unified project structure. - /// - /// - Returns: The list of dependent module names that were linked. - func linkDependentModuleSources() throws -> [String] { - var dependentModules: [String] = [] - let moduleBasePath = moduleRootPath.parentDirectory - - for (linkModuleName, relativeLinkPath) in linkNamePaths { - let linkModulePath = try moduleBasePath.appending(RelativePath(validating: linkModuleName)) - trace("relativeLinkPath: \(relativeLinkPath) moduleBasePath: \(moduleBasePath) linkModuleName: \(linkModuleName) -> linkModulePath: \(linkModulePath)") - try createMergedRelativeLinkTree(from: linkModulePath, to: relativeLinkPath, shallow: false) - dependentModules.append(linkModuleName) - } - return dependentModules - } -} + var aggregateSkipConfig: SkipConfig = try aggregateJSON.decode() + // clear exports and perform final item removal + aggregateSkipConfig.build?.removeContent(withExports: true) + aggregateSkipConfig.settings?.removeContent(withExports: true) -extension SkipstoneSession { - /// Links resource files from the project to the output assets folder. - /// - /// Iterates through ``resourceEntries`` and dispatches each entry to either - /// ``linkCopyResources(entry:resourcesBasePath:)`` or - /// ``linkProcessResources(entry:resourcesBasePath:)`` based on its mode. - func linkResources() throws { - let resourcesOutputFolder = try AbsolutePath(outputFolderPath, validating: "assets") - let resourcesBasePath = resourcesOutputFolder - .appending(components: packageName.split(separator: ".").map(\.description)) - .appending(component: "Resources") - - for entry in resourceEntries { - if entry.isCopyMode { - try linkCopyResources(entry: entry, resourcesBasePath: resourcesBasePath) - } else { - try linkProcessResources(entry: entry, resourcesBasePath: resourcesBasePath) - } + let configEnd = Date().timeIntervalSinceReferenceDate + info("skip.yml aggregate created (\(Int64((configEnd - configStart) * 1000)) ms) for modules: \(moduleNamePaths.map(\.module))") + return (currentModuleConfig, aggregateSkipConfig, configMap) } - } - /// Links resources in "copy" mode, preserving the full directory hierarchy. - /// - /// In copy mode, the resource folder name is preserved as a subdirectory prefix, - /// matching Darwin's `.copy()` behavior. For example, a file at - /// `ResourcesCopy/subdir/file.txt` is linked to `Resources/ResourcesCopy/subdir/file.txt`. - /// - /// - Parameters: - /// - entry: The resource entry to link. - /// - resourcesBasePath: The base output path for resources. - private func linkCopyResources(entry: ResourceEntry, resourcesBasePath: AbsolutePath) throws { - for resourceFile in entry.urls.map(\.path).sorted() { - let resourceFileCanonical = (resourceFile as NSString).standardizingPath - guard let resourceSourceURL = moduleNamePaths.compactMap({ (_, folder) -> URL? in - let folderCanonical = (folder as NSString).standardizingPath - guard resourceFileCanonical.hasPrefix(folderCanonical) else { return nil } - let relativePath = String(resourceFileCanonical.dropFirst(folderCanonical.count)).trimmingCharacters(in: CharacterSet(charactersIn: "/")) - return URL(fileURLWithPath: relativePath, relativeTo: URL(fileURLWithPath: folderCanonical, isDirectory: true)) - }).first else { - msg(.trace, "no module root parent for \(resourceFile)") - continue + func sourceFileOutputPath(for baseSourceFileName: String, in basePath: AbsolutePath? = nil) throws -> AbsolutePath? { + if baseSourceFileName == "skip.yml" { + // skip metadata files are excluded from copy + return nil } - let sourcePath = try AbsolutePath(validating: resourceSourceURL.path) - let resourceComponents = try RelativePath(validating: resourceSourceURL.relativePath).components - - let resourceSourcePath = try RelativePath(validating: resourceComponents.joined(separator: "/")) - let destinationPath = resourcesBasePath.appending(resourceSourcePath) - - if sourcePath.parentDirectory.basename == buildSrcFolderName { - trace("skipping resource linking for buildSrc/") - } else if isCMakeProject { - trace("skipping resource linking for CMake project") - } else if fs.isFile(sourcePath) { - info("\(destinationPath.relative(to: moduleBasePath).pathString) copying to \(sourcePath.pathString)", sourceFile: sourcePath.sourceFile) - try fs.createDirectory(destinationPath.parentDirectory, recursive: true) - try addLink(destinationPath, pointingAt: sourcePath, relative: false) - } - } - } - - /// Links resources in "process" mode, flattening the hierarchy. - /// - /// In process mode, the resource directory prefix is stripped and files are placed - /// directly in the Resources/ output folder. Special handling is applied for: - /// - `.xcstrings` files, which are converted to `.strings` and `.stringsdict` localizations - /// - `res/` prefixed resources, which are placed in the Android res/ folder - /// - /// - Parameters: - /// - entry: The resource entry to link. - /// - resourcesBasePath: The base output path for resources. - private func linkProcessResources(entry: ResourceEntry, resourcesBasePath: AbsolutePath) throws { - let resOutputFolder = try AbsolutePath(outputFolderPath, validating: "res") - - for resourceFile in entry.urls.map(\.path).sorted() { - let resourceFileCanonical = (resourceFile as NSString).standardizingPath - guard let resourceSourceURL = moduleNamePaths.compactMap({ (_, folder) -> URL? in - let folderCanonical = (folder as NSString).standardizingPath - guard resourceFileCanonical.hasPrefix(folderCanonical) else { return nil } - let relativePath = String(resourceFileCanonical.dropFirst(folderCanonical.count)).trimmingCharacters(in: CharacterSet(charactersIn: "/")) - return URL(fileURLWithPath: relativePath, relativeTo: URL(fileURLWithPath: folderCanonical, isDirectory: true)) - }).first else { - msg(.trace, "no module root parent for \(resourceFile)") - continue + // Kotlin (.kt) files go to src/main/kotlin/package/name/File.kt, and Java (.java) files go to src/main/java/package/name/File.kt + let rawSourceDestination = baseSourceFileName.hasSuffix(".kt") ? kotlinOutputFolder : javaOutputFolder + + // the "AndroidManifest.xml" file is special: it needs to go in the root src/main/ folder + let isManifest = baseSourceFileName == AndroidManifestName + // if an empty basePath, treat as a source file and place in package-derived folders + return try (basePath ?? rawSourceDestination + .appending(components: isManifest ? [".."] : packageName.split(separator: ".").map(\.description))) + .appending(RelativePath(validating: baseSourceFileName)) + } + + /// Copies over the overridden .kt files from `ModuleNameKotlin/Skip/*.kt` into the destination folder, + /// and makes links to any subdirectories, which enables the handling of `src/main/AndroidManifest.xml` + /// and other custom resources. + /// + /// Any Kotlin files that are overridden will not be transpiled. + func linkSkipFolder(_ path: AbsolutePath, to outputFilePath: AbsolutePath, topLevel: Bool) throws -> Set { + // when we are running with SKIP_BRIDGE, don't link over any files from the skip folder + // failure to do this will result in (harmless) .kt files being copied over, but since no subsequent transpilation + // will mark those as expected output file, they will raise warnings: "removing stale output file: …" + if skipstoneOptions.skipBridgeOutput != nil { + return [] } - let sourcePath = try AbsolutePath(validating: resourceSourceURL.path) + var copiedFiles: Set = [] + for fileName in try fs.getDirectoryContents(path) { + if fileName.hasPrefix(".") { + continue // skip hidden files + } - let resourceComponents = try RelativePath(validating: resourceSourceURL.relativePath).components - let components = resourceComponents.dropFirst(1) - let resourceSourcePath = try RelativePath(validating: components.joined(separator: "/")) + if path.basename == buildSrcFolderName || fileName == buildSrcFolderName { + continue // don't copy buildSrc into resources + } - if sourcePath.parentDirectory.basename == buildSrcFolderName { - trace("skipping resource linking for buildSrc/") - } else if isCMakeProject { - trace("skipping resource linking for CMake project") - } else if sourcePath.extension == "xcstrings" { - try convertStrings(resourceSourceURL: resourceSourceURL, sourcePath: sourcePath, resourcesBasePath: resourcesBasePath) - } else { - let isAndroidRes = resourceComponents.first == "res" - let destinationPath = (isAndroidRes ? resOutputFolder : resourcesBasePath).appending(resourceSourcePath) + let sourcePath = try AbsolutePath(path, validating: fileName) + let outputPath = try AbsolutePath(outputFilePath, validating: fileName) - if fs.isFile(sourcePath) { - info("\(destinationPath.relative(to: moduleBasePath).pathString) linking to \(sourcePath.pathString)", sourceFile: sourcePath.sourceFile) - try fs.createDirectory(destinationPath.parentDirectory, recursive: true) - try addLink(destinationPath, pointingAt: sourcePath, relative: false) + if fs.isDirectory(sourcePath) { + // make recursive folders for sub-linked resources + let subPaths = try linkSkipFolder(sourcePath, to: outputPath, topLevel: false) + copiedFiles.formUnion(subPaths) + } else { + if let outputFilePath = try sourceFileOutputPath(for: sourcePath.basename, in: topLevel ? nil : outputFilePath) { + copiedFiles.insert(outputFilePath) + try fs.createDirectory(outputFilePath.parentDirectory, recursive: true) // ensure parent exists + // we make links instead of copying so the file can be edited from the gradle project structure without needing to be manually synchronized + try addLink(outputFilePath, pointingAt: sourcePath, relative: false) + info("\(outputFilePath.relative(to: moduleBasePath).pathString) override linked from project source \(sourcePath.pathString)", sourceFile: sourcePath.sourceFile) + } } } + return copiedFiles } - } - /// Converts `.xcstrings` files to `.strings` and `.stringsdict` localization files. - /// - /// Parses the Xcode string catalog JSON and generates per-locale `.strings` files - /// (for simple translations) and `.stringsdict` plist files (for plural rules), - /// mirroring the conversion Xcode performs for iOS builds. - /// - /// - Parameters: - /// - resourceSourceURL: The URL of the `.xcstrings` file. - /// - sourcePath: The absolute path to the `.xcstrings` file. - /// - resourcesBasePath: The base output path for localization folders. - private func convertStrings(resourceSourceURL: URL, sourcePath: AbsolutePath, resourcesBasePath: AbsolutePath) throws { - let xcstrings = try JSONDecoder().decode(LocalizableStringsDictionary.self, from: Data(contentsOf: resourceSourceURL)) - let defaultLanguage = xcstrings.sourceLanguage - let locales = Set(xcstrings.strings.values.compactMap(\.localizations?.keys).joined()) - for localeId in locales { - let lprojFolder = resourcesBasePath.appending(component: localeId + ".lproj") - let locBase = sourcePath.basenameWithoutExt - - var locdict: [String: String] = [:] - var plurals: [String: [String : LocalizableStringsDictionary.StringUnit]] = [:] - - for (key, value) in xcstrings.strings { - guard let localized = value.localizations?[localeId] else { - continue - } - if let value = localized.stringUnit?.value { - locdict[key] = value - } - if let pluralDict = localized.variations?.plural { - plurals[key] = pluralDict.mapValues(\.stringUnit) - } + func handleTranspilation(transpilation: Transpilation) async throws { + for message in transpilation.messages { + await out.yield(message) } - if !locdict.isEmpty { - func escape(_ string: String) throws -> String? { - let writingOptions: JSONSerialization.WritingOptions - if #available(iOS 13.0, macOS 15.0, *) { - writingOptions = [ - .sortedKeys, // needed for deterministic output - .withoutEscapingSlashes, - ] - } else { - writingOptions = [ - .sortedKeys, - ] - } - - return try String(data: JSONSerialization.data(withJSONObject: string, options: writingOptions), encoding: .utf8) - } - - var stringsContent = "" - for (key, value) in locdict.sorted(by: { $0.key < $1.key }) { - if let keyString = try escape(key), let valueString = try escape(value) { - stringsContent += keyString + " = " + valueString + ";\n" - } - } - try fs.createDirectory(lprojFolder, recursive: true) - if localeId == defaultLanguage { - try addLink(resourcesBasePath.appending(component: "base.lproj"), pointingAt: lprojFolder, relative: true) - } - - let localizableStrings = try RelativePath(validating: locBase + ".strings") - let localizableStringsPath = lprojFolder.appending(localizableStrings) - info("create \(localizableStrings.pathString) from \(sourcePath.pathString)", sourceFile: localizableStringsPath.sourceFile) - try writeChanges(tag: localizableStrings.pathString, to: localizableStringsPath, contents: stringsContent.utf8Data, readOnly: false) + switch transpilation.outputType { + case .bridgeToSwift, .bridgeFromSwift: + skipBridgeTranspilations.append(transpilation) + return + case .default: + break } - if !plurals.isEmpty { - let localizableStringsDict = try RelativePath(validating: locBase + ".stringsdict") + // when we are running with SKIP_BRIDGE, we don't need to write out the Kotlin (which has already been generated in the first pass of the plugin) + if skipstoneOptions.skipBridgeOutput != nil { + //warn("suppressing transpiled Kotlin due to skipstoneOptions.skipBridgeOutput") + return + } - var pluralDictNodes: [Universal.XMLNode] = [] - for (key, value) in plurals.sorted(by: { $0.key < $1.key }) { - pluralDictNodes.append(Universal.XMLNode(elementName: "key", children: [.content(key)])) + let sourcePath = try AbsolutePath(validating: transpilation.input.file.path) - var pluralsDict = Universal.XMLNode(elementName: "dict") - pluralsDict.addPlist(key: "NSStringLocalizedFormatKey", stringValue: "%#@value@") + let (outputFile, changed, overridden) = try saveTranspilation() - pluralsDict.append(Universal.XMLNode(elementName: "key", children: [.content("value")])) - var pluralsSubDict = Universal.XMLNode(elementName: "dict") + // 2 separate log messages, one linking to the source swift and the second linking to the kotlin + // this makes the log rather noisy, and isn't very useful + //if !transpilation.isSourceFileSynthetic { + // info("\(sourcePath.basename) (\(byteCount(for: .init(sourceSize)))) transpiling to \(outputFile.basename)", sourceFile: transpilation.sourceFile) + //} - pluralsSubDict.addPlist(key: "NSStringFormatSpecTypeKey", stringValue: "NSStringPluralRuleType") - pluralsSubDict.addPlist(key: "NSStringFormatValueTypeKey", stringValue: "lld") + info("\(outputFile.relative(to: moduleBasePath).pathString) (\(transpilation.output.content.lengthOfBytes(using: .utf8).byteCount)) transpilation \(overridden ? "overridden" : !changed ? "unchanged" : "saved") from \(sourcePath.basename) (\(transpilation.input.content.lengthOfBytes(using: .utf8).byteCount)) in \(Int64(transpilation.duration * 1000)) ms", sourceFile: overridden ? transpilation.input.file : outputFile.sourceFile) - for (pluralType, stringUnit) in value.sorted(by: { $0.key < $1.key }) { - if let stringUnitValue = stringUnit.value { - pluralsSubDict.addPlist(key: pluralType, stringValue: stringUnitValue) - } - } - pluralsDict.append(pluralsSubDict) - pluralDictNodes.append(pluralsDict) + for message in transpilation.messages { + //writeMessage(message) + if message.kind == .error { + // throw the first error we see + await out.finish(throwing: message) + return } - - let pluralDict = Universal.XMLNode(elementName: "dict", children: pluralDictNodes.map({ .element($0) })) - - let stringsDictPlist = Universal.XMLNode(elementName: "plist", attributes: ["version": "1.0"], children: [.element(pluralDict)]) - let stringsDictDocument = Universal.XMLNode(elementName: "", children: [.element(stringsDictPlist)]) - - let localizableStringsDictPath = lprojFolder.appending(localizableStringsDict) - info("create \(localizableStringsDict.pathString) from \(sourcePath.pathString)", sourceFile: localizableStringsDictPath.sourceFile) - try writeChanges(tag: localizableStringsDict.pathString, to: localizableStringsDictPath, contents: stringsDictDocument.xmlString().utf8Data, readOnly: false) } - } - } -} -extension SkipstoneSession { - // MARK: - Phase 6: Gradle Generation - - /// Generates all Gradle build files for the module. - /// - /// Creates the per-module `build.gradle.kts`, the project `settings.gradle.kts`, - /// `gradle.properties`, `proguard-rules.pro`, and the Gradle wrapper properties. - /// - /// - Parameters: - /// - sourceModules: The dependent source module names to include in settings. - /// - skipConfig: The merged skip.yml configuration. - /// - isApp: Whether this is an app module (affects Gradle plugin selection). - func generateGradle(for sourceModules: [String], with skipConfig: SkipConfig, isApp: Bool) throws { - let buildGradle = moduleRootPath.appending(component: "build.gradle.kts") - try generateGradleWrapperProperties() - try generateProguardFile(packageName) - try generatePerModuleGradle(config: skipConfig, buildGradle: buildGradle) - try generateGradleProperties(config: skipConfig) - try generateSettingsGradle(sourceModules: sourceModules, config: skipConfig) - } - - /// Generates the per-module `build.gradle.kts` file from the merged config. - /// - /// - Parameters: - /// - config: The merged skip.yml configuration. - /// - buildGradle: The output path for build.gradle.kts. - private func generatePerModuleGradle(config: SkipConfig, buildGradle: AbsolutePath) throws { - let buildContents = (config.build ?? .init()).generate(context: .init(dsl: .kotlin)) + let output = Output(transpilation: transpilation) + await out.yield(output) - trace("created gradle: \(buildContents.split(separator: "\n").map({ $0.trimmingCharacters(in: .whitespaces) }).joined(separator: "; "))") + func saveTranspilation() throws -> (output: AbsolutePath, changed: Bool, overridden: Bool) { + // the build plug-in's output folder base will be something like ~/Library/Developer/Xcode/DerivedData/Mod-ID/SourcePackages/plugins/module-name.output/ModuleNameKotlin/skipstone/ModuleName/src/test/kotlin + trace("path: \(kotlinOutputFolder)") - let contents = """ - // build.gradle.kts generated by Skip for \(primaryModuleName) - - """ + buildContents - - try writeChanges(tag: "gradle project", to: buildGradle, contents: contents.utf8Data, readOnly: true) - } + let kotlinName = transpilation.kotlinFileName + guard let outputFilePath = try sourceFileOutputPath(for: kotlinName) else { + throw error("No output path for \(kotlinName)") + } - /// Generates the project `settings.gradle.kts` with module includes. - /// - /// Includes the primary module and all dependent source modules. For native - /// (bridged) modules, adds them to the `bridgeModules` Gradle extra property. - /// - /// - Parameters: - /// - sourceModules: The dependent source module names. - /// - config: The merged skip.yml configuration. - private func generateSettingsGradle(sourceModules: [String], config: SkipConfig) throws { - let settingsPath = moduleRootPath.parentDirectory.appending(component: "settings.gradle.kts") - var settingsContents = (config.settings ?? .init()).generate(context: .init(dsl: .kotlin)) - - settingsContents += """ - - rootProject.name = "\(packageName ?? "")" - - """ - - var bridgedModules: [String] = [] - - func addIncludeModule(_ moduleName: String) { - settingsContents += """ - include(":\(moduleName)") - project(":\(moduleName)").projectDir = file("\(moduleName)") - - """ + if overriddenKotlinFiles.contains(kotlinName) { + return (output: outputFilePath, changed: false, overridden: true) + } - if Self.resolveModuleMode(moduleName: moduleName, configMap: configMap, baseConfig: baseSkipConfig, hasSkipFuse: hasSkipFuse, primaryModuleName: primaryModuleName) == .native { - bridgedModules.append(moduleName) - } - } + let kotlinBytes = ByteString(encodingAsUTF8: transpilation.output.content) + let fileWritten = try fs.writeChanges(path: addOutputFile(outputFilePath), checkSize: true, makeReadOnly: true, bytes: kotlinBytes) - if !sourceModules.contains(primaryModuleName) && !primaryModuleName.hasSuffix("Tests") { - addIncludeModule(primaryModuleName) - } + trace("wrote to: \(outputFilePath)\(!fileWritten ? " (unchanged)" : "")", sourceFile: outputFilePath.sourceFile) - for sourceModule in sourceModules { - addIncludeModule(sourceModule) - } + // also save the output line mapping file: SomeFile.kt -> .SomeFile.sourcemap + let sourceMappingPath = outputFilePath.parentDirectory.appending(component: "." + outputFilePath.basenameWithoutExt + ".sourcemap") + let encoder = JSONEncoder() + encoder.outputFormatting = [ + .sortedKeys, // needed for deterministic output + .withoutEscapingSlashes, + //.prettyPrinted, + ] + let sourceMapData = try encoder.encode(transpilation.outputMap) + try fs.writeChanges(path: addOutputFile(sourceMappingPath), makeReadOnly: true, bytes: ByteString(sourceMapData)) - if !bridgedModules.isEmpty { - settingsContents += """ - - gradle.extra["bridgeModules"] = listOf("\(bridgedModules.joined(separator: "\", \""))") - - """ + return (output: outputFilePath, changed: fileWritten, overridden: false) + } } - try writeChanges(tag: "gradle settings", to: settingsPath, contents: settingsContents.utf8Data, readOnly: true) - } - - /// Generates the `proguard-rules.pro` file for release build optimization. - /// - /// - Parameter packageName: The Kotlin package name for keep rules. - private func generateProguardFile(_ packageName: String) throws { - try writeChanges(tag: "proguard", to: moduleRootPath.appending(component: "proguard-rules.pro"), contents: FrameworkProjectLayout.defaultProguardContents(packageName).utf8Data, readOnly: true) - } - - /// Generates the `gradle-wrapper.properties` file specifying the Gradle distribution version. - private func generateGradleWrapperProperties() throws { - let gradleWrapperFolder = moduleRootPath.parentDirectory.appending(components: "gradle", "wrapper") - try fs.createDirectory(gradleWrapperFolder, recursive: true) - let gradleWrapperPath = gradleWrapperFolder.appending(component: "gradle-wrapper.properties") - let gradeWrapperContents = FrameworkProjectLayout.defaultGradleWrapperProperties() - try writeChanges(tag: "gradle wrapper", to: gradleWrapperPath, contents: gradeWrapperContents.utf8Data, readOnly: true) - } - - /// Generates the `gradle.properties` file, merging defaults with custom properties from skip.yml. - /// - /// - Parameter config: The merged skip.yml configuration containing optional custom properties. - private func generateGradleProperties(config: SkipConfig) throws { - let gradlePropertiesPath = moduleRootPath.parentDirectory.appending(component: "gradle.properties") - let contents = Self.mergeGradleProperties( - defaults: FrameworkProjectLayout.defaultGradleProperties(), - custom: config.gradleProperties) - try writeChanges(tag: "gradle config", to: gradlePropertiesPath, contents: contents.utf8Data, readOnly: true) - } -} - -extension SkipstoneSession { - // MARK: - Phase 7: Cleanup - - /// Removes stale output files and writes the sourcehash completion marker. - /// - /// Called in a `defer` block to ensure cleanup happens even on error. - func finalizeSession() { - cleanupStaleOutputFiles() - do { - try saveSourcehashFile() - } catch { - warn("could not create build completion marker: \(error)") - } - } + /// Links each of the resource files passed to the transpiler to the underlying source files. + /// - Returns: the list of root resource folder(s) that contain the link(s) for the resources + func linkResources() throws { + let resourcesBasePath = resourcesOutputFolder + .appending(components: packageName.split(separator: ".").map(\.description)) + .appending(component: "Resources") - /// Removes output files from previous runs that are no longer being produced. - /// - /// Compares the ``outputFilesSnapshot`` taken at session start against the - /// ``outputFiles`` accumulated during this run. Files present in the snapshot - /// but not in the current outputs are considered stale and removed. - /// `Package.resolved` is excluded since it's managed by the native build system. - func cleanupStaleOutputFiles() { - let staleFiles = Self.identifyStaleFiles(snapshot: outputFilesSnapshot, outputFiles: outputFiles) - for staleFile in staleFiles.sorted() { - let staleFileURL = URL(fileURLWithPath: staleFile, isDirectory: false) - if staleFileURL.lastPathComponent == "Package.resolved" { - continue + for entry in resourceEntries { + if entry.isCopyMode { + try linkCopyResources(entry: entry, resourcesBasePath: resourcesBasePath) + } else { + try linkProcessResources(entry: entry, resourcesBasePath: resourcesBasePath) + } } - msg(.warning, "removing stale output file: \(staleFileURL.lastPathComponent)", sourceFile: try? staleFileURL.absolutePath.sourceFile) - do { - try FileManager.default.trash(fileURL: staleFileURL, trash: false) - } catch { - msg(.warning, "error removing stale output file: \(staleFileURL.lastPathComponent): \(error)") - } - } - } + /// Links resources in "copy" mode, preserving the directory hierarchy relative to the resource folder + func linkCopyResources(entry: ResourceEntry, resourcesBasePath: AbsolutePath) throws { + for resourceFile in entry.urls.map(\.path).sorted() { + let resourceFileCanonical = (resourceFile as NSString).standardizingPath + guard let resourceSourceURL = moduleNamePaths.compactMap({ (_, folder) -> URL? in + let folderCanonical = (folder as NSString).standardizingPath + guard resourceFileCanonical.hasPrefix(folderCanonical) else { return nil } + let relativePath = String(resourceFileCanonical.dropFirst(folderCanonical.count)).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return URL(fileURLWithPath: relativePath, relativeTo: URL(fileURLWithPath: folderCanonical, isDirectory: true)) + }).first else { + msg(.trace, "no module root parent for \(resourceFile)") + continue + } - /// Writes the sourcehash marker file with current source file hashes. - /// - /// The marker file signals to the build plugin host that the transpilation - /// is complete and records the source hashes for future change detection. - func saveSourcehashFile() throws { - if !fs.isDirectory(moduleBasePath) { - try fs.createDirectory(moduleBasePath, recursive: true) - } + let sourcePath = try AbsolutePath(validating: resourceSourceURL.path) + let resourceComponents = try RelativePath(validating: resourceSourceURL.relativePath).components + + // In copy mode, preserve the full directory hierarchy including the resource folder name + // (e.g., "ResourcesCopy/subdir/file.txt"), matching Darwin's .copy() behavior where + // the folder name becomes a subdirectory in the bundle. + let resourceSourcePath = try RelativePath(validating: resourceComponents.joined(separator: "/")) + let destinationPath = resourcesBasePath.appending(resourceSourcePath) + + if sourcePath.parentDirectory.basename == buildSrcFolderName { + trace("skipping resource linking for buildSrc/") + } else if isCMakeProject { + trace("skipping resource linking for CMake project") + } else if fs.isFile(sourcePath) { + info("\(destinationPath.relative(to: moduleBasePath).pathString) copying to \(sourcePath.pathString)", sourceFile: sourcePath.sourceFile) + try fs.createDirectory(destinationPath.parentDirectory, recursive: true) + try addLink(destinationPath, pointingAt: sourcePath, relative: false) + } + } + } - struct SourcehashContents : Encodable { - let skipstone: String = skipVersion - let sourcehashes: [String: String] - } + /// Links resources in "process" mode, flattening the hierarchy and performing special processing for .xcstrings and other files + func linkProcessResources(entry: ResourceEntry, resourcesBasePath: AbsolutePath) throws { + for resourceFile in entry.urls.map(\.path).sorted() { + let resourceFileCanonical = (resourceFile as NSString).standardizingPath + guard let resourceSourceURL = moduleNamePaths.compactMap({ (_, folder) -> URL? in + let folderCanonical = (folder as NSString).standardizingPath + guard resourceFileCanonical.hasPrefix(folderCanonical) else { return nil } + let relativePath = String(resourceFileCanonical.dropFirst(folderCanonical.count)).trimmingCharacters(in: CharacterSet(charactersIn: "/")) + return URL(fileURLWithPath: relativePath, relativeTo: URL(fileURLWithPath: folderCanonical, isDirectory: true)) + }).first else { + // skip over resources that are not contained within the resource folder + msg(.trace, "no module root parent for \(resourceFile)") + continue + } - let sourcePathHashes: [(String, String)] = sourcehashes.compactMap { url, sourcehash in - let absolutePath = url.path - if !absolutePath.hasPrefix(projectFolderPath.pathString) { - return .none + let sourcePath = try AbsolutePath(validating: resourceSourceURL.path) + + let resourceComponents = try RelativePath(validating: resourceSourceURL.relativePath).components + // all resources get put into a single "Resources/" folder in the jar, so drop the first item and replace it with "Resources/" + let components = resourceComponents.dropFirst(1) + let resourceSourcePath = try RelativePath(validating: components.joined(separator: "/")) + + if sourcePath.parentDirectory.basename == buildSrcFolderName { + trace("skipping resource linking for buildSrc/") + } else if isCMakeProject { + trace("skipping resource linking for CMake project") + } else if sourcePath.extension == "xcstrings" { + try convertStrings(resourceSourceURL: resourceSourceURL, sourcePath: sourcePath) + //} else if sourcePath.extension == "xcassets" { + // TODO: convert various assets into Android res/ folder + } else { // non-processed resources are just linked directly from the package + // the Android "res" folder is special: it is intended to store Android-specific resources like values/strings.xml, and will be linked into the archive's res/ folder + let isAndroidRes = resourceComponents.first == "res" + let destinationPath = (isAndroidRes ? resOutputFolder : resourcesBasePath).appending(resourceSourcePath) + + // only create links for files that exist + if fs.isFile(sourcePath) { + info("\(destinationPath.relative(to: moduleBasePath).pathString) linking to \(sourcePath.pathString)", sourceFile: sourcePath.sourceFile) + try fs.createDirectory(destinationPath.parentDirectory, recursive: true) + try addLink(destinationPath, pointingAt: sourcePath, relative: false) + } + } + } } - let relativePath = absolutePath.dropFirst(projectFolderPath.pathString.count).trimmingPrefix(while: { $0 == "/" }) - return (relativePath.description, sourcehash) - } - - let sourcehashOutputPath = try AbsolutePath(validating: skipstoneOptions.sourcehash) - let sourcehash = SourcehashContents(sourcehashes: Dictionary(sourcePathHashes, uniquingKeysWith: { $1 })) - try writeChanges(tag: "sourcehash", to: sourcehashOutputPath, contents: try encoder.encode(sourcehash), readOnly: false) - } -} + func convertStrings(resourceSourceURL: URL, sourcePath: AbsolutePath) throws { + // process the .xcstrings in the same way that Xcode does: parse the JSON and use the localizations keys to synthesize a LANG.lproj/TABLENAME.strings file + let xcstrings = try JSONDecoder().decode(LocalizableStringsDictionary.self, from: Data(contentsOf: resourceSourceURL)) + let defaultLanguage = xcstrings.sourceLanguage + let locales = Set(xcstrings.strings.values.compactMap(\.localizations?.keys).joined()) + for localeId in locales { + let lprojFolder = resourcesBasePath.appending(component: localeId + ".lproj") + let locBase = sourcePath.basenameWithoutExt + + var locdict: [String: String] = [:] + var plurals: [String: [String : LocalizableStringsDictionary.StringUnit]] = [:] + + for (key, value) in xcstrings.strings { + guard let localized = value.localizations?[localeId] else { + continue + } + if let value = localized.stringUnit?.value { + locdict[key] = value + } + if let pluralDict = localized.variations?.plural { + plurals[key] = pluralDict.mapValues(\.stringUnit) + } + } -extension SkipstoneSession { - // MARK: - File Operation Helpers - - /// Registers a path as an output file to prevent stale file cleanup. - /// - /// Every file written or linked during the session must be registered via this method - /// so it is not removed during ``cleanupStaleOutputFiles()``. - /// - /// - Parameter path: The output file path to register. - /// - Returns: The same path, for convenient chaining. - @discardableResult func addOutputFile(_ path: AbsolutePath) -> AbsolutePath { - outputFiles.append(path) - return path - } + if !locdict.isEmpty { + func escape(_ string: String) throws -> String? { + // escape quotes and newlines; we just use a JSON string fragment for this + try String(data: JSONSerialization.data(withJSONObject: string, options: [.fragmentsAllowed, .withoutEscapingSlashes]), encoding: .utf8) + } - /// Registers a path as an input file for modification time tracking. - /// - /// - Parameter path: The input file path to register. - /// - Returns: The same path, for convenient chaining. - @discardableResult func addInputFile(_ path: AbsolutePath) -> AbsolutePath { - inputFiles.append(path) - return path - } + var stringsContent = "" + for (key, value) in locdict.sorted(by: { $0.key < $1.key }) { + if let keyString = try escape(key), let valueString = try escape(value) { + stringsContent += keyString + " = " + valueString + ";\n" + } + } + try fs.createDirectory(lprojFolder, recursive: true) + if localeId == defaultLanguage { + // when there is a default language, set up a symbolic link so Android localization can know where to fall back in the case of a missing localization key + try addLink(resourcesBasePath.appending(component: "base.lproj"), pointingAt: lprojFolder, relative: true) + } - /// Reads a source file's contents and registers it as an input file. - /// - /// - Parameter path: The file to read. - /// - Returns: The file contents as a `ByteString`. - func inputSource(_ path: AbsolutePath) throws -> ByteString { - try fs.readFileContents(addInputFile(path)) - } + let localizableStrings = try RelativePath(validating: locBase + ".strings") // e.g., fr.lproj/Localizable.strings + let localizableStringsPath = lprojFolder.appending(localizableStrings) + info("create \(localizableStrings.pathString) from \(sourcePath.pathString)", sourceFile: localizableStringsPath.sourceFile) + try writeChanges(tag: localizableStrings.pathString, to: localizableStringsPath, contents: stringsContent.utf8Data, readOnly: false) + } - /// Writes content to an output file if it has changed, tracking the file as output. - /// - /// - Parameters: - /// - tag: A descriptive tag for logging (e.g., "gradle project", "codebase"). - /// - outputFilePath: The destination file path. - /// - contents: The content to write. - /// - readOnly: Whether to make the file read-only after writing. - func writeChanges(tag: String, to outputFilePath: AbsolutePath, contents: any DataProtocol, readOnly: Bool) throws { - let changed = try fs.writeChanges(path: addOutputFile(outputFilePath), makeReadOnly: readOnly, bytes: ByteString(contents)) - info("\(outputFilePath.relative(to: moduleBasePath).pathString) (\(contents.count.byteCount)) \(tag) \(!changed ? "unchanged" : "written")", sourceFile: outputFilePath.sourceFile) - } + if !plurals.isEmpty { + let localizableStringsDict = try RelativePath(validating: locBase + ".stringsdict") // e.g., fr.lproj/Localizable.stringsdict - /// Creates a symbolic link (or copy for read-only files) from source to destination. - /// - /// For read-only files, a copy is made instead of a symlink to avoid Gradle write - /// permission failures on subsequent builds. The output link's modification time - /// is set to match the destination for accurate change detection. - /// - /// - Parameters: - /// - linkSource: The path where the link/copy will be created. - /// - destPath: The target path the link points to. - /// - relative: Whether to create a relative (vs absolute) symlink. - /// - replace: Whether to replace existing symlinks. Defaults to true. - /// - copyReadOnlyFiles: Whether to copy instead of link read-only files. Defaults to true. - func addLink(_ linkSource: AbsolutePath, pointingAt destPath: AbsolutePath, relative: Bool, replace: Bool = true, copyReadOnlyFiles: Bool = true) throws { - msg(.trace, "linking: \(linkSource) to: \(destPath)") - - if replace && fs.isSymlink(destPath) { - removePath(destPath) - } + var pluralDictNodes: [Universal.XMLNode] = [] + for (key, value) in plurals.sorted(by: { $0.key < $1.key }) { + pluralDictNodes.append(Universal.XMLNode(elementName: "key", children: [.content(key)])) - if let existingSymlinkDestination = try? FileManager.default.destinationOfSymbolicLink(atPath: linkSource.pathString) { - if existingSymlinkDestination == destPath.pathString { - msg(.trace, "retaining existing link from \(destPath.pathString) to \(existingSymlinkDestination)") - addOutputFile(linkSource) - return - } - } + var pluralsDict = Universal.XMLNode(elementName: "dict") + pluralsDict.addPlist(key: "NSStringLocalizedFormatKey", stringValue: "%#@value@") - let destInfo = try fs.getFileInfo(destPath) - let modTime = destInfo.modTime - let perms = destInfo.posixPermissions + pluralsDict.append(Universal.XMLNode(elementName: "key", children: [.content("value")])) + var pluralsSubDict = Universal.XMLNode(elementName: "dict") - let writablePermissions = perms | 0o200 + pluralsSubDict.addPlist(key: "NSStringFormatSpecTypeKey", stringValue: "NSStringPluralRuleType") + pluralsSubDict.addPlist(key: "NSStringFormatValueTypeKey", stringValue: "lld") - let shouldCopy = copyReadOnlyFiles && !fs.isDirectory(linkSource) && (perms != writablePermissions) + for (pluralType, stringUnit) in value.sorted(by: { $0.key < $1.key }) { + // pluralType is zero, one, two, few, many, other + if let stringUnitValue = stringUnit.value { + pluralsSubDict.addPlist(key: pluralType, stringValue: stringUnitValue) + } + } + pluralsDict.append(pluralsSubDict) + pluralDictNodes.append(pluralsDict) + } - removePath(linkSource) - if shouldCopy { - msg(.trace, "copying \(destPath) to \(linkSource)") - try fs.copy(from: destPath, to: addOutputFile(linkSource)) - try FileManager.default.setAttributes([.posixPermissions: writablePermissions], ofItemAtPath: linkSource.pathString) - } else { - msg(.trace, "linking \(destPath) to \(linkSource)") - try fs.createSymbolicLink(addOutputFile(linkSource), pointingAt: destPath, relative: relative) - } + let pluralDict = Universal.XMLNode(elementName: "dict", children: pluralDictNodes.map({ .element($0) })) - try (linkSource.asURL as NSURL).setResourceValue(modTime, forKey: .contentModificationDateKey) - } + let stringsDictPlist = Universal.XMLNode(elementName: "plist", attributes: ["version": "1.0"], children: [.element(pluralDict)]) + let stringsDictDocument = Universal.XMLNode(elementName: "", children: [.element(stringsDictPlist)]) - /// Removes a file or directory, tolerating non-existent paths. - /// - /// - Parameter path: The path to remove. - /// - Returns: true if the path existed and was removed, false otherwise. - @discardableResult - func removePath(_ path: AbsolutePath) -> Bool { - do { - if !fs.exists(path, followSymlink: false) { - return false + let localizableStringsDictPath = lprojFolder.appending(localizableStringsDict) + info("create \(localizableStringsDict.pathString) from \(sourcePath.pathString)", sourceFile: localizableStringsDictPath.sourceFile) + try writeChanges(tag: localizableStringsDict.pathString, to: localizableStringsDictPath, contents: stringsDictDocument.xmlString().utf8Data, readOnly: false) + } + } } - try fs.removeFileTree(path) - return true - } catch { - warn("unable to remove entry \(path): \(error)", sourceFile: path.sourceFile) - return false } - } - // MARK: - Link Tree Operations - - /// Resolves the output path for a source file, accounting for file type and package structure. - /// - /// This is a convenience instance method that delegates to the static version - /// using the session's configured paths. - /// - /// - Parameters: - /// - baseSourceFileName: The source file's base name (e.g., "MyClass.kt"). - /// - basePath: Optional override base path. - /// - Returns: The resolved output path, or nil if the file should be skipped. - func sourceFileOutputPath(for baseSourceFileName: String, in basePath: AbsolutePath? = nil) throws -> AbsolutePath? { - try Self.resolveSourceFileOutputPath( - for: baseSourceFileName, - packageName: packageName, - kotlinFolder: try AbsolutePath(outputFolderPath, validating: "kotlin"), - javaFolder: try AbsolutePath(outputFolderPath, validating: "java"), - manifestName: androidManifestName, - basePath: basePath) - } - - /// Copies override .kt files from the Skip/ folder into the output, and links subdirectories. - /// - /// Any Kotlin file in the Skip/ folder takes precedence over the transpiled version. - /// Subdirectories are recursively linked to support custom Android resources and manifests. - /// - /// - Parameters: - /// - path: The Skip/ folder path to scan. - /// - outputFilePath: The destination output folder. - /// - topLevel: Whether this is the top-level Skip/ folder (affects path resolution). - /// - Returns: The set of output file paths that were overridden. - func linkSkipFolder(_ path: AbsolutePath, to outputFilePath: AbsolutePath, topLevel: Bool) throws -> Set { - if skipstoneOptions.skipBridgeOutput != nil { - return [] - } + // NOTE: when linking between modules, SPM and Xcode will use different output paths: + // Xcode: ~/Library/Developer/Xcode/DerivedData/PROJECT-ID/SourcePackages/plugins/skiphub.output/SkipFoundationKotlinTests/skipstone/SkipFoundation + // SPM: .build/plugins/outputs/skiphub/ + func linkDependentModuleSources() throws -> [String] { + var dependentModules: [String] = [] + // transpilation was successful; now set up links to the other output packages (located in different plug-in folders) + let moduleBasePath = moduleRootPath.parentDirectory - var copiedFiles: Set = [] - for fileName in try fs.getDirectoryContents(path) { - if fileName.hasPrefix(".") { - continue - } - if path.basename == buildSrcFolderName || fileName == buildSrcFolderName { - continue + // for each of the specified link/path pairs, create symbol links, either to the base folders, or the the sub-folders that share a common root + // this is the logic that allows us to merge two modules (like MyMod and MyModTests) into a single Kotlin module with the idiomatic src/main/kotlin/ and src/test/kotlin/ pair of folders + for (linkModuleName, relativeLinkPath) in linkNamePaths { + let linkModulePath = try moduleBasePath.appending(RelativePath(validating: linkModuleName)) + trace("relativeLinkPath: \(relativeLinkPath) moduleBasePath: \(moduleBasePath) linkModuleName: \(linkModuleName) -> linkModulePath: \(linkModulePath)") + try createMergedRelativeLinkTree(from: linkModulePath, to: relativeLinkPath, shallow: false) + dependentModules.append(linkModuleName) } - let sourcePath = try AbsolutePath(path, validating: fileName) - let outputPath = try AbsolutePath(outputFilePath, validating: fileName) + return dependentModules + } - if fs.isDirectory(sourcePath) { - let subPaths = try linkSkipFolder(sourcePath, to: outputPath, topLevel: false) - copiedFiles.formUnion(subPaths) - } else { - if let outputFilePath = try sourceFileOutputPath(for: sourcePath.basename, in: topLevel ? nil : outputFilePath) { - copiedFiles.insert(outputFilePath) - try fs.createDirectory(outputFilePath.parentDirectory, recursive: true) - try addLink(outputFilePath, pointingAt: sourcePath, relative: false) - info("\(outputFilePath.relative(to: moduleBasePath).pathString) override linked from project source \(sourcePath.pathString)", sourceFile: sourcePath.sourceFile) + /// Attempts to make a link from the `fromPath` to the given relative path. + /// If `fromPath` already exists and is a directory, attempt to create links for each of the contents of the directory to the updated relative folder + func createMergedRelativeLinkTree(from fromPath: AbsolutePath, to relative: String, shallow: Bool) throws { + let destPath = try AbsolutePath(validating: relative, relativeTo: fromPath.parentDirectory) + if !fs.isDirectory(destPath) { + // skip over anything that is not a destination folder + // if it doesn't exist at all, then it is an error + if !fs.exists(destPath) { + warn("Expected destination path did not exist: \(destPath)") } + return } - } - return copiedFiles - } - - /// Creates merged relative symbolic links from one module's output to another. - /// - /// If the destination is a directory and the source already exists as a directory, - /// recursively creates links for each child. Otherwise, creates a single relative link. - /// - /// - Parameters: - /// - fromPath: The path to create the link at. - /// - relative: The relative path to the link target. - /// - shallow: Whether to create shallow (non-recursive) links. - func createMergedRelativeLinkTree(from fromPath: AbsolutePath, to relative: String, shallow: Bool) throws { - let destPath = try AbsolutePath(validating: relative, relativeTo: fromPath.parentDirectory) - if !fs.isDirectory(destPath) { - if !fs.exists(destPath) { - warn("Expected destination path did not exist: \(destPath)") + trace("creating merged link tree from: \(fromPath) to: \(relative)") + if fs.isSymlink(fromPath) { + removePath(fromPath) // clear any pre-existing symlink } - return - } - trace("creating merged link tree from: \(fromPath) to: \(relative)") - if fs.isSymlink(fromPath) { - removePath(fromPath) - } - if !shallow && fs.isDirectory(fromPath) { - for fsEntry in try fs.getDirectoryContents(destPath) { - let fromSubPath = fromPath.appending(try RelativePath(validating: fsEntry)) - try createMergedRelativeLinkTree(from: fromSubPath, to: "../" + relative + "/" + fsEntry, shallow: shallow) + // the folder is a directory; recurse into the destination paths in order to link to the local paths + if !shallow && fs.isDirectory(fromPath) { + for fsEntry in try fs.getDirectoryContents(destPath) { + let fromSubPath = fromPath.appending(try RelativePath(validating: fsEntry)) + // bump up all the relative links to account for the folder we just recursed into. + // e.g.: ../SomeSharedRoot/OtherModule/ + // becomes: ../../SomeSharedRoot/OtherModule/someFolder/ + try createMergedRelativeLinkTree(from: fromSubPath, to: "../" + relative + "/" + fsEntry, shallow: shallow) + } + } else { + try addLink(fromPath, pointingAt: destPath, relative: true) } - } else { - try addLink(fromPath, pointingAt: destPath, relative: true) } - } - /// Creates a mirror of a directory structure using symbolic links. - /// - /// Recursively traverses the source directory and creates corresponding links - /// in the destination. A content handler can intercept individual files to - /// provide custom handling (e.g., modifying Package.swift). - /// - /// - Parameters: - /// - destPath: The destination path to create the mirror at. - /// - fromPath: The source path to mirror. - /// - shallow: Whether to create shallow links (link children, don't recurse). - /// - excludePaths: Set of filenames to exclude from the mirror. - /// - contentHandler: Optional handler called for each file. Return false to skip linking. - func createMirroredLinkTree(_ destPath: AbsolutePath, pointingAt fromPath: AbsolutePath, shallow: Bool, excluding excludePaths: Set = [], contentHandler: ((_ destPath: AbsolutePath, _ fromPath: AbsolutePath) throws -> Bool)? = nil) throws { - trace("creating absolute merged link tree from: \(fromPath) to: \(destPath)") - if fs.isDirectory(fromPath) { - try fs.createDirectory(destPath, recursive: true) - for fsEntry in try fs.getDirectoryContents(fromPath) { - if fsEntry.hasPrefix(".") || excludePaths.contains(fsEntry) { - continue - } - let rel = try RelativePath(validating: fsEntry) - let childDestPath = destPath.appending(rel) - let childFromPath = fromPath.appending(rel) - if shallow { - if try contentHandler?(childDestPath, childFromPath) != false { - try addLink(childDestPath, pointingAt: childFromPath, relative: false) + /// Create a mirror hierarchy of the directory structure at `from` in the folder specified by `to`, and link each individual file in the hierarchy + func createMirroredLinkTree(_ destPath: AbsolutePath, pointingAt fromPath: AbsolutePath, shallow: Bool, excluding excludePaths: Set = [], contentHandler: ((_ destPath: AbsolutePath, _ fromPath: AbsolutePath) throws -> Bool)? = nil) throws { + trace("creating absolute merged link tree from: \(fromPath) to: \(destPath)") + // the folder is a directory; recurse into the destination paths in order to link to the local paths + if fs.isDirectory(fromPath) { + // we create output directories and link the contents, rather than just linking the folders themselves, since Gradle wants to be able to write to the output folders + try fs.createDirectory(destPath, recursive: true) + for fsEntry in try fs.getDirectoryContents(fromPath) { + if fsEntry.hasPrefix(".") || excludePaths.contains(fsEntry) { + continue } + let rel = try RelativePath(validating: fsEntry) + let childDestPath = destPath.appending(rel) + let childFromPath = fromPath.appending(rel) + if shallow { + if try contentHandler?(childDestPath, childFromPath) != false { + try addLink(childDestPath, pointingAt: childFromPath, relative: false) + } + } else { + try createMirroredLinkTree(childDestPath, pointingAt: childFromPath, shallow: shallow, contentHandler: contentHandler) + } + } + } else if fs.isFile(fromPath) { + // check whether the contentHandler want to override linking the file + if try contentHandler?(destPath, fromPath) != false { + try addLink(destPath, pointingAt: fromPath, relative: false) } else { - try createMirroredLinkTree(childDestPath, pointingAt: childFromPath, shallow: shallow, contentHandler: contentHandler) + warn("unknown file type encountered when creating links: \(fromPath)") } } - } else if fs.isFile(fromPath) { - if try contentHandler?(destPath, fromPath) != false { - try addLink(destPath, pointingAt: fromPath, relative: false) - } else { - warn("unknown file type encountered when creating links: \(fromPath)") + } + + @discardableResult + func removePath(_ path: AbsolutePath) -> Bool { + do { + if !fs.exists(path, followSymlink: false) { + return false + } + try fs.removeFileTree(path) + return true + } catch { + warn("unable to remove entry \(path): \(error)", sourceFile: path.sourceFile) + return false } } } - // MARK: - Utility Helpers + /// Generate transpiler transformers from the given skip config + func createTransformers(for config: SkipConfig, with moduleMap: [String: SkipConfig]) throws -> [KotlinTransformer] { + var transformers: [KotlinTransformer] = builtinKotlinTransformers() - /// Returns the relative path for a module's codebase info JSON file. - /// - /// - Parameter moduleName: The module name. - /// - Returns: The relative path like "ModuleName.skipcode.json". - func moduleExportPath(forModule moduleName: String) throws -> RelativePath { - try RelativePath(validating: moduleName + skipcodeExtension) - } + let configOptions = config.skip?.bridgingOptions() ?? [] + let transformerOptions = KotlinBridgeOptions.parse(configOptions) + transformers.append(KotlinBridgeTransformer(options: transformerOptions)) - // MARK: - Static Pure Functions (Testable) - - /// Determines the output path for a source file based on its type and the package structure. - /// - /// - Kotlin (`.kt`) files are placed under `kotlinFolder/package/path/File.kt` - /// - Java (`.java`) files are placed under `javaFolder/package/path/File.java` - /// - `AndroidManifest.xml` is placed one level up from the type-specific folder - /// - `skip.yml` files return nil (excluded from output) - /// - /// - Parameters: - /// - fileName: The source file's base name. - /// - packageName: The Kotlin package name (e.g., "skip.foundation"). - /// - kotlinFolder: The base Kotlin output folder. - /// - javaFolder: The base Java output folder. - /// - manifestName: The Android manifest filename. - /// - basePath: Optional override base path; when set, the file is placed relative to it. - /// - Returns: The resolved output path, or nil if the file should be skipped. - static func resolveSourceFileOutputPath( - for fileName: String, - packageName: String, - kotlinFolder: AbsolutePath, - javaFolder: AbsolutePath, - manifestName: String, - basePath: AbsolutePath? - ) throws -> AbsolutePath? { - if fileName == "skip.yml" { - return nil + if let root = config.skip?.dynamicroot { + transformers.append(KotlinDynamicObjectTransformer(root: root)) } - let rawSourceDestination = fileName.hasSuffix(".kt") ? kotlinFolder : javaFolder - - let isManifest = fileName == manifestName - return try (basePath ?? rawSourceDestination - .appending(components: isManifest ? [".."] : packageName.split(separator: ".").map(\.description))) - .appending(RelativePath(validating: fileName)) + return transformers } - /// Determines the module mode for a given module based on skip.yml configuration. - /// - /// The mode controls how the module is processed: - /// - `.native` — Swift is compiled natively on Android via the Swift toolchain. - /// - `.transpiled` — Swift is transpiled to Kotlin. - /// - /// When the mode is `"automatic"` (the default), the presence of SkipFuse in the - /// dependency graph causes the primary module to use native mode. - /// - /// - Parameters: - /// - moduleName: The module to check, or nil for the primary module. - /// - configMap: Map of module names to their skip.yml configs. - /// - baseConfig: The base skip.yml config for the current module. - /// - hasSkipFuse: Whether SkipFuse is in the dependency graph. - /// - primaryModuleName: The primary module name being processed. - /// - Returns: The resolved module mode. - static func resolveModuleMode( - moduleName: String?, - configMap: [String: SkipConfig], - baseConfig: SkipConfig, - hasSkipFuse: Bool, - primaryModuleName: String - ) -> ModuleMode { - let moduleMode: String? - - if let moduleName { - moduleMode = configMap[moduleName]?.skip?.mode - } else { - moduleMode = baseConfig.skip?.mode - } + func loadSourceHashes(from allSourceURLs: [URL]) async throws -> [URL: String] { + // take a snapshot of all the source hashes for each of the URLs so we know when anything has changes + // TODO: this doesn't need to be a full SHA256 hash, it can be something faster (or maybe even just a snapshot of the file's size and last modified date…) + let sourcehashes = try await withThrowingTaskGroup(of: (URL, String).self) { group in + for url in allSourceURLs { + group.addTask { + let data = try Data(contentsOf: url, options: .mappedIfSafe) + return (url, data.SHA256Hash()) + } + } - switch moduleMode { - case "native": return .native - case "transpiled": return .transpiled - case "automatic", .none: return hasSkipFuse && (moduleName == primaryModuleName || moduleName == nil) ? .native : .transpiled - default: - return .transpiled + var results = [URL: String]() + results.reserveCapacity(allSourceURLs.count) + + for try await (url, sha256) in group { + results[url] = sha256 + } + + return results } - } - /// Determines whether a module is a test dependency (not the primary or its test peer). - /// - /// A module is NOT a test module if: - /// - It equals the primary module name, OR - /// - It equals the primary module name with "Tests" stripped (the test peer relationship) - /// - /// This is used to determine Gradle dependency types: test modules get - /// `testImplementation` while non-test modules get `api`. - /// - /// - Parameters: - /// - moduleName: The module name to check. - /// - primaryModuleName: The primary module name. - /// - Returns: true if the module is a test dependency. - static func isTestModule(_ moduleName: String, primaryModuleName: String) -> Bool { - primaryModuleName != moduleName && primaryModuleName != moduleName + "Tests" + return sourcehashes } +} - /// Identifies output files from a previous run that are no longer being produced. - /// - /// Takes the set difference between the snapshot file paths and the current output - /// file paths. `Package.resolved` exclusion is handled by the caller. - /// - /// - Parameters: - /// - snapshot: URLs of files that existed before the current run. - /// - outputFiles: Paths of files produced during the current run. - /// - Returns: Set of stale file path strings that should be cleaned up. - static func identifyStaleFiles(snapshot: [URL], outputFiles: [AbsolutePath]) -> Set { - Set(snapshot.map(\.path)) - .subtracting(outputFiles.map(\.pathString)) - } +struct SkipstoneCommandOptions: ParsableArguments { + @Option(name: [.customLong("project"), .long], help: ArgumentHelp("The project folder to transpile", valueName: "folder")) + var projectFolder: String // --project - /// Partitions source file URLs into transpilation targets and native bridge files. - /// - /// In native mode, all files become bridge files (compiled natively on Android). - /// In transpiled mode, all files become transpilation targets. - /// - /// - Parameters: - /// - sourceURLs: The source file URLs to categorize. - /// - isNative: Whether the module uses native mode. - /// - Returns: Tuple of (transpileFiles, swiftFiles) as sorted path strings. - static func categorizeSourceFiles(sourceURLs: [URL], isNative: Bool) -> (transpile: [String], swift: [String]) { - var transpileFiles: [String] = [] - var swiftFiles: [String] = [] - for sourceFile in sourceURLs.map(\.path).sorted() { - if isNative { - swiftFiles.append(sourceFile) - } else { - transpileFiles.append(sourceFile) - } - } - return (transpile: transpileFiles, swift: swiftFiles) - } + @Option(name: [.long], help: ArgumentHelp("The path to the source hash file to output", valueName: "path")) + var sourcehash: String // --sourcehash - /// Merges default Gradle properties with custom overrides from skip.yml. - /// - /// Parses the default properties string into key-value pairs, overlays custom - /// properties, and produces a sorted output string. Comments and blank lines - /// from the defaults are discarded. - /// - /// - Parameters: - /// - defaults: The default Gradle properties as a multi-line string. - /// - custom: Optional custom properties that override or extend defaults. - /// - Returns: The merged properties as a newline-terminated string. - static func mergeGradleProperties(defaults: String, custom: [String: String]?) -> String { - var properties: [String: String] = [:] - - for line in defaults.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty || trimmed.hasPrefix("#") { - continue - } - let parts = trimmed.split(separator: "=", maxSplits: 1) - if parts.count == 2 { - let key = String(parts[0]).trimmingCharacters(in: .whitespaces) - let value = String(parts[1]).trimmingCharacters(in: .whitespaces) - properties[key] = value - } - } + @Option(name: [.customLong("module")], help: ArgumentHelp("ModuleName:SourcePath", valueName: "module")) + var moduleNames: [String] = [] // --module name:path - if let custom { - for (key, value) in custom { - properties[key] = value - } - } + @Option(name: [.customLong("link")], help: ArgumentHelp("ModuleName:LinkPath", valueName: "module")) + var linkPaths: [String] = [] // --link name:path - var result = "" - for (key, value) in properties.sorted(by: { $0.key < $1.key }) { - result += "\(key)=\(value)\n" - } - result += "\n" - return result - } + @Option(help: ArgumentHelp("Path to the folder that contains skip.yml and overrides", valueName: "path")) + var skipFolder: String? = nil // --skip-folder - /// Builds structured resource entries from skip.yml configuration. - /// - /// If the skip.yml declares resource paths with modes, each path is enumerated - /// and paired with its declared mode. Otherwise, falls back to the default - /// `Resources/` folder contents with process mode. - /// - /// - Parameters: - /// - config: The base skip.yml config. - /// - resourceURLs: Default resource URLs from the Resources/ folder. - /// - projectBaseURL: The project folder URL for resolving relative resource paths. - /// - Returns: Array of resource entries with their files and processing modes. - static func buildResourceEntries(config: SkipConfig, resourceURLs: [URL], projectBaseURL: URL) throws -> [ResourceEntry] { - if let resourceConfigs = config.skip?.resources { - return try resourceConfigs.map { resourceConfig in - let resourceDirURL = projectBaseURL.appendingPathComponent(resourceConfig.path, isDirectory: true) - let urls: [URL] = try FileManager.default.enumeratedURLs(of: resourceDirURL) - return ResourceEntry(path: resourceConfig.path, urls: urls, isCopyMode: resourceConfig.isCopyMode) - } - } else if !resourceURLs.isEmpty { - return [ResourceEntry(path: "Resources", urls: resourceURLs, isCopyMode: false)] - } else { - return [] - } - } + @Option(help: ArgumentHelp("Path to the output module root folder", valueName: "path")) + var moduleRoot: String? = nil // --module-root - /// Filters YAML content by removing blocks marked with `export: false`. - /// - /// When a module's skip.yml is loaded for use by a dependent module, blocks - /// explicitly marked as non-exported are stripped. This allows modules to have - /// configuration that only applies locally. - /// - /// - Parameter yaml: The YAML content to filter. - /// - Returns: The filtered YAML, or nil if the entire block should be removed. - static func filterExportYAML(_ yaml: YAML) -> YAML? { - guard var obj = yaml.object else { - if let array = yaml.array { - return .array(array.compactMap(filterExportYAML(_:))) - } else { - return yaml - } - } - for (key, value) in obj { - if key == "export" { - if value.boolean == false { - return nil - } - } else { - obj[key] = filterExportYAML(value) - } - } - return .object(obj) - } + @Option(name: [.customShort("D", allowingJoined: true)], help: ArgumentHelp("Set preprocessor variable for transpilation", valueName: "value")) + var preprocessorVariables: [String] = [] + + @Option(name: [.long], help: ArgumentHelp("Output directory", valueName: "dir")) + var outputFolder: String? = nil + + @Option(name: [.customLong("dependency")], help: ArgumentHelp("id:path", valueName: "dependency")) + var dependencies: [String] = [] // --dependency id:path + + @Option(name: [.long], help: ArgumentHelp("Folder for SkipBridge generated Swift files", valueName: "suffix")) + var skipBridgeOutput: String? = nil } + extension Universal.XMLNode { mutating func addPlist(key: String, stringValue: String) { append(Universal.XMLNode(elementName: "key", children: [.content(key)])) diff --git a/Tests/SkipBuildTests/SkipstoneCommandTests.swift b/Tests/SkipBuildTests/SkipstoneCommandTests.swift deleted file mode 100644 index 1e6fb9e3..00000000 --- a/Tests/SkipBuildTests/SkipstoneCommandTests.swift +++ /dev/null @@ -1,435 +0,0 @@ -// Copyright (c) 2023 - 2026 Skip -// Licensed under the GNU Affero General Public License v3.0 -// SPDX-License-Identifier: AGPL-3.0-only - -import XCTest -@testable import SkipBuild -import TSCBasic -import Universal - -final class SkipstoneCommandTests: XCTestCase { - - // MARK: - resolveSourceFileOutputPath Tests - - /// Kotlin files should be placed under the kotlin output folder with package-derived subdirectories. - func testSourceFileOutputPath_KotlinFile() throws { - let kotlinFolder = try AbsolutePath(validating: "/output/kotlin") - let javaFolder = try AbsolutePath(validating: "/output/java") - - let result = try SkipstoneSession.resolveSourceFileOutputPath( - for: "MyClass.kt", - packageName: "skip.foundation", - kotlinFolder: kotlinFolder, - javaFolder: javaFolder, - manifestName: "AndroidManifest.xml", - basePath: nil) - - XCTAssertEqual(result?.pathString, "/output/kotlin/skip/foundation/MyClass.kt") - } - - /// Java files should be placed under the java output folder with package-derived subdirectories. - func testSourceFileOutputPath_JavaFile() throws { - let kotlinFolder = try AbsolutePath(validating: "/output/kotlin") - let javaFolder = try AbsolutePath(validating: "/output/java") - - let result = try SkipstoneSession.resolveSourceFileOutputPath( - for: "Helper.java", - packageName: "skip.model", - kotlinFolder: kotlinFolder, - javaFolder: javaFolder, - manifestName: "AndroidManifest.xml", - basePath: nil) - - XCTAssertEqual(result?.pathString, "/output/java/skip/model/Helper.java") - } - - /// skip.yml files should be excluded from output (returns nil). - func testSourceFileOutputPath_SkipYml() throws { - let kotlinFolder = try AbsolutePath(validating: "/output/kotlin") - let javaFolder = try AbsolutePath(validating: "/output/java") - - let result = try SkipstoneSession.resolveSourceFileOutputPath( - for: "skip.yml", - packageName: "skip.ui", - kotlinFolder: kotlinFolder, - javaFolder: javaFolder, - manifestName: "AndroidManifest.xml", - basePath: nil) - - XCTAssertNil(result) - } - - /// AndroidManifest.xml should be placed one level up from the type-specific folder. - func testSourceFileOutputPath_AndroidManifest() throws { - let kotlinFolder = try AbsolutePath(validating: "/output/kotlin") - let javaFolder = try AbsolutePath(validating: "/output/java") - - let result = try SkipstoneSession.resolveSourceFileOutputPath( - for: "AndroidManifest.xml", - packageName: "skip.ui", - kotlinFolder: kotlinFolder, - javaFolder: javaFolder, - manifestName: "AndroidManifest.xml", - basePath: nil) - - // AndroidManifest goes up one level from java folder (since it's not .kt) - XCTAssertEqual(result?.pathString, "/output/AndroidManifest.xml") - } - - /// When basePath is provided, files should be placed relative to it. - func testSourceFileOutputPath_WithBasePath() throws { - let kotlinFolder = try AbsolutePath(validating: "/output/kotlin") - let javaFolder = try AbsolutePath(validating: "/output/java") - let basePath = try AbsolutePath(validating: "/custom/path") - - let result = try SkipstoneSession.resolveSourceFileOutputPath( - for: "Override.kt", - packageName: "skip.ui", - kotlinFolder: kotlinFolder, - javaFolder: javaFolder, - manifestName: "AndroidManifest.xml", - basePath: basePath) - - XCTAssertEqual(result?.pathString, "/custom/path/Override.kt") - } - - /// Deep package names should create nested subdirectories. - func testSourceFileOutputPath_DeepPackage() throws { - let kotlinFolder = try AbsolutePath(validating: "/output/kotlin") - let javaFolder = try AbsolutePath(validating: "/output/java") - - let result = try SkipstoneSession.resolveSourceFileOutputPath( - for: "File.kt", - packageName: "com.example.deep.package", - kotlinFolder: kotlinFolder, - javaFolder: javaFolder, - manifestName: "AndroidManifest.xml", - basePath: nil) - - XCTAssertEqual(result?.pathString, "/output/kotlin/com/example/deep/package/File.kt") - } - - // MARK: - resolveModuleMode Tests - - /// Explicit "native" mode in config should return .native. - func testModuleMode_Native() throws { - let config = try makeSkipConfig(mode: "native") - let configMap: [String: SkipConfig] = ["TestModule": config] - - let result = SkipstoneSession.resolveModuleMode( - moduleName: nil, configMap: configMap, - baseConfig: config, hasSkipFuse: false, - primaryModuleName: "TestModule") - - XCTAssertEqual(result, .native) - } - - /// Explicit "transpiled" mode should return .transpiled. - func testModuleMode_Transpiled() throws { - let config = try makeSkipConfig(mode: "transpiled") - let configMap: [String: SkipConfig] = ["TestModule": config] - - let result = SkipstoneSession.resolveModuleMode( - moduleName: nil, configMap: configMap, - baseConfig: config, hasSkipFuse: false, - primaryModuleName: "TestModule") - - XCTAssertEqual(result, .transpiled) - } - - /// Automatic mode with SkipFuse present should return .native for the primary module. - func testModuleMode_AutomaticWithFuse() throws { - let config = try makeSkipConfig(mode: nil) - let configMap: [String: SkipConfig] = ["TestModule": config, "SkipFuse": config] - - let result = SkipstoneSession.resolveModuleMode( - moduleName: nil, configMap: configMap, - baseConfig: config, hasSkipFuse: true, - primaryModuleName: "TestModule") - - XCTAssertEqual(result, .native) - } - - /// Automatic mode without SkipFuse should return .transpiled. - func testModuleMode_AutomaticWithoutFuse() throws { - let config = try makeSkipConfig(mode: nil) - let configMap: [String: SkipConfig] = ["TestModule": config] - - let result = SkipstoneSession.resolveModuleMode( - moduleName: nil, configMap: configMap, - baseConfig: config, hasSkipFuse: false, - primaryModuleName: "TestModule") - - XCTAssertEqual(result, .transpiled) - } - - /// Automatic mode with SkipFuse for a non-primary module should return .transpiled. - func testModuleMode_AutomaticWithFuseNonPrimary() throws { - let config = try makeSkipConfig(mode: nil) - let configMap: [String: SkipConfig] = ["OtherModule": config, "SkipFuse": config] - - let result = SkipstoneSession.resolveModuleMode( - moduleName: "OtherModule", configMap: configMap, - baseConfig: config, hasSkipFuse: true, - primaryModuleName: "TestModule") - - XCTAssertEqual(result, .transpiled) - } - - // MARK: - isTestModule Tests - - /// The primary module itself should not be considered a test module. - func testIsTestModule_SameModule() { - XCTAssertFalse(SkipstoneSession.isTestModule("MyModule", primaryModuleName: "MyModule")) - } - - /// The test peer (primary name without "Tests" suffix) should not be a test module. - func testIsTestModule_TestPeer() { - XCTAssertFalse(SkipstoneSession.isTestModule("MyModule", primaryModuleName: "MyModuleTests")) - } - - /// A different module should be considered a test module. - func testIsTestModule_DifferentModule() { - XCTAssertTrue(SkipstoneSession.isTestModule("SkipFoundation", primaryModuleName: "MyModule")) - } - - /// SkipUnit should be considered a test module for any primary module. - func testIsTestModule_SkipUnit() { - XCTAssertTrue(SkipstoneSession.isTestModule("SkipUnit", primaryModuleName: "MyModule")) - } - - // MARK: - identifyStaleFiles Tests - - /// When all snapshot files are still in output, there should be no stale files. - func testIdentifyStaleFiles_NoStale() { - let snapshot = [ - URL(fileURLWithPath: "/output/File1.kt"), - URL(fileURLWithPath: "/output/File2.kt"), - ] - let outputFiles = [ - try! AbsolutePath(validating: "/output/File1.kt"), - try! AbsolutePath(validating: "/output/File2.kt"), - ] - - let stale = SkipstoneSession.identifyStaleFiles(snapshot: snapshot, outputFiles: outputFiles) - XCTAssertTrue(stale.isEmpty) - } - - /// Files in snapshot but not in output should be identified as stale. - func testIdentifyStaleFiles_WithStale() { - let snapshot = [ - URL(fileURLWithPath: "/output/File1.kt"), - URL(fileURLWithPath: "/output/OldFile.kt"), - URL(fileURLWithPath: "/output/File2.kt"), - ] - let outputFiles = [ - try! AbsolutePath(validating: "/output/File1.kt"), - try! AbsolutePath(validating: "/output/File2.kt"), - ] - - let stale = SkipstoneSession.identifyStaleFiles(snapshot: snapshot, outputFiles: outputFiles) - XCTAssertEqual(stale, Set(["/output/OldFile.kt"])) - } - - /// An empty snapshot should produce no stale files. - func testIdentifyStaleFiles_EmptySnapshot() { - let stale = SkipstoneSession.identifyStaleFiles(snapshot: [], outputFiles: []) - XCTAssertTrue(stale.isEmpty) - } - - // MARK: - categorizeSourceFiles Tests - - /// In transpiled mode, all files should be in the transpile list. - func testCategorizeSourceFiles_Transpiled() { - let urls = [ - URL(fileURLWithPath: "/src/A.swift"), - URL(fileURLWithPath: "/src/B.swift"), - ] - - let (transpile, swift) = SkipstoneSession.categorizeSourceFiles(sourceURLs: urls, isNative: false) - - XCTAssertEqual(transpile.count, 2) - XCTAssertTrue(swift.isEmpty) - // Should be sorted - XCTAssertEqual(transpile, transpile.sorted()) - } - - /// In native mode, all files should be in the swift (bridge) list. - func testCategorizeSourceFiles_Native() { - let urls = [ - URL(fileURLWithPath: "/src/A.swift"), - URL(fileURLWithPath: "/src/B.swift"), - ] - - let (transpile, swift) = SkipstoneSession.categorizeSourceFiles(sourceURLs: urls, isNative: true) - - XCTAssertTrue(transpile.isEmpty) - XCTAssertEqual(swift.count, 2) - XCTAssertEqual(swift, swift.sorted()) - } - - /// Empty source list should produce empty results. - func testCategorizeSourceFiles_Empty() { - let (transpile, swift) = SkipstoneSession.categorizeSourceFiles(sourceURLs: [], isNative: false) - XCTAssertTrue(transpile.isEmpty) - XCTAssertTrue(swift.isEmpty) - } - - // MARK: - mergeGradleProperties Tests - - /// Default properties should be parsed and output sorted. - func testMergeGradleProperties_DefaultsOnly() { - let defaults = """ - org.gradle.jvmargs=-Xmx4g - android.useAndroidX=true - kotlin.code.style=official - """ - - let result = SkipstoneSession.mergeGradleProperties(defaults: defaults, custom: nil) - - XCTAssertTrue(result.contains("android.useAndroidX=true")) - XCTAssertTrue(result.contains("kotlin.code.style=official")) - XCTAssertTrue(result.contains("org.gradle.jvmargs=-Xmx4g")) - } - - /// Custom properties should override defaults. - func testMergeGradleProperties_WithOverrides() { - let defaults = """ - org.gradle.jvmargs=-Xmx4g - android.useAndroidX=true - """ - - let result = SkipstoneSession.mergeGradleProperties( - defaults: defaults, - custom: ["org.gradle.jvmargs": "-Xmx8g"]) - - XCTAssertTrue(result.contains("org.gradle.jvmargs=-Xmx8g")) - XCTAssertFalse(result.contains("org.gradle.jvmargs=-Xmx4g")) - } - - /// Custom properties should add new entries. - func testMergeGradleProperties_CustomAdded() { - let defaults = """ - org.gradle.jvmargs=-Xmx4g - """ - - let result = SkipstoneSession.mergeGradleProperties( - defaults: defaults, - custom: ["custom.prop": "value"]) - - XCTAssertTrue(result.contains("custom.prop=value")) - XCTAssertTrue(result.contains("org.gradle.jvmargs=-Xmx4g")) - } - - /// Comments and blank lines in defaults should be ignored. - func testMergeGradleProperties_IgnoresComments() { - let defaults = """ - # This is a comment - key1=value1 - - key2=value2 - """ - - let result = SkipstoneSession.mergeGradleProperties(defaults: defaults, custom: nil) - - XCTAssertTrue(result.contains("key1=value1")) - XCTAssertTrue(result.contains("key2=value2")) - XCTAssertFalse(result.contains("#")) - } - - /// Output should be sorted by key. - func testMergeGradleProperties_Sorted() { - let defaults = """ - zebra=last - alpha=first - middle=mid - """ - - let result = SkipstoneSession.mergeGradleProperties(defaults: defaults, custom: nil) - - let lines = result.components(separatedBy: "\n").filter { !$0.isEmpty } - XCTAssertEqual(lines, ["alpha=first", "middle=mid", "zebra=last"]) - } - - // MARK: - buildResourceEntries Tests - - /// When no config resources and no resource URLs, should return empty. - func testBuildResourceEntries_EmptyResources() throws { - let config = try makeSkipConfig(mode: nil) - let result = try SkipstoneSession.buildResourceEntries( - config: config, resourceURLs: [], projectBaseURL: URL(fileURLWithPath: "/project")) - - XCTAssertTrue(result.isEmpty) - } - - /// When resource URLs exist but no config, should fall back to default Resources/ entry. - func testBuildResourceEntries_FallbackToResources() throws { - let config = try makeSkipConfig(mode: nil) - let resourceURLs = [URL(fileURLWithPath: "/project/Resources/file.txt")] - - let result = try SkipstoneSession.buildResourceEntries( - config: config, resourceURLs: resourceURLs, projectBaseURL: URL(fileURLWithPath: "/project")) - - XCTAssertEqual(result.count, 1) - XCTAssertEqual(result.first?.path, "Resources") - XCTAssertFalse(result.first?.isCopyMode ?? true) - } - - // MARK: - filterExportYAML Tests - - /// Blocks with export:false should be removed. - func testFilterExportYAML_RemovesExportFalse() throws { - let yaml: YAML = .object([ - "key1": .string("value1"), - "export": .boolean(false), - ]) - - let result = SkipstoneSession.filterExportYAML(yaml) - XCTAssertNil(result) - } - - /// Blocks without export:false should be preserved. - func testFilterExportYAML_PreservesNonExportBlocks() throws { - let yaml: YAML = .object([ - "key1": .string("value1"), - "key2": .string("value2"), - ]) - - let result = SkipstoneSession.filterExportYAML(yaml) - XCTAssertNotNil(result) - XCTAssertEqual(result?.object?["key1"]?.string, "value1") - } - - /// Nested blocks with export:false should be removed from arrays. - func testFilterExportYAML_FiltersNestedArrayItems() throws { - let yaml: YAML = .array([ - .object(["key1": .string("keep")]), - .object(["export": .boolean(false), "key2": .string("remove")]), - .object(["key3": .string("also keep")]), - ]) - - let result = SkipstoneSession.filterExportYAML(yaml) - XCTAssertNotNil(result) - let array = result?.array - XCTAssertEqual(array?.count, 2) - } - - /// Scalar values should pass through unchanged. - func testFilterExportYAML_ScalarPassthrough() throws { - let yaml: YAML = .string("hello") - let result = SkipstoneSession.filterExportYAML(yaml) - XCTAssertEqual(result?.string, "hello") - } - - // MARK: - Helpers - - /// Creates a minimal SkipConfig with an optional mode for testing. - private func makeSkipConfig(mode: String?) throws -> SkipConfig { - if let mode { - let json: Universal.JSON = .object(["skip": .object(["mode": .string(mode)])]) - return try json.decode() - } else { - return try Universal.JSON.object([:]).decode() - } - } -} From 70b614b52a633eb123a3f2c643378addf6297c71 Mon Sep 17 00:00:00 2001 From: Marc Prud'hommeaux Date: Tue, 10 Mar 2026 18:22:33 -0400 Subject: [PATCH 4/4] Update CI to not run skip checkup --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b92e2a3..b69f7c52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,7 +134,10 @@ jobs: - run: skip doctor - run: skip checkup --native - if: runner.os != 'Linux' + # there's a bug runing checkup with a local skipstone comand build: + # [✗] error: Dependencies could not be resolved because root depends on 'skip' 1.7.5..<2.0.0. + if: false + #if: runner.os != 'Linux' - name: "Prepare Android emulator environment" if: runner.os == 'Linux'