diff --git a/Package.swift b/Package.swift index 426a40d9..00c88e43 100644 --- a/Package.swift +++ b/Package.swift @@ -32,6 +32,10 @@ let package = Package( name: "xcswiftc", dependencies: ["XCRemoteCache"] ), + .target( + name: "xcswift-frontend", + dependencies: ["XCRemoteCache"] + ), .target( name: "xclibtoolSupport", dependencies: ["XCRemoteCache"] @@ -69,6 +73,7 @@ let package = Package( dependencies: [ "xcprebuild", "xcswiftc", + "xcswift-frontend", "xclibtool", "xcpostbuild", "xcprepare", diff --git a/README.md b/README.md index 3e2dd1e2..72f9e8f9 100755 --- a/README.md +++ b/README.md @@ -359,6 +359,7 @@ Note: This step is not required if at least one of these is true: | `custom_rewrite_envs` | A list of extra ENVs that should be used as placeholders in the dependency list. ENV rewrite process is optimistic - does nothing if an ENV is not defined in the pre/postbuild process. | `[]` | ⬜️ | | `irrelevant_dependencies_paths` | Regexes of files that should not be included in a list of dependencies. Warning! Add entries here with caution - excluding dependencies that are relevant might lead to a target overcaching. The regex can match either partially or fully the filepath, e.g. `\\.modulemap$` will exclude all `.modulemap` files. | `[]` | ⬜️ | | `gracefully_handle_missing_common_sha` | If true, do not fail `prepare` if cannot find the most recent common commits with the primary branch. That might be useful on CI, where a shallow clone is used and cloning depth is not big enough to fetch a commit from a primary branch | `false` | ⬜️ | +| `enable_swift_driver_integration` | Enable experimental integration with swift driver, added in Xcode 14 | `false` | ⬜️ | ## Backend cache server diff --git a/Sources/XCRemoteCache/Commands/Prepare/Integrate/IntegrateContext.swift b/Sources/XCRemoteCache/Commands/Prepare/Integrate/IntegrateContext.swift index aec0450c..84cc80d5 100644 --- a/Sources/XCRemoteCache/Commands/Prepare/Integrate/IntegrateContext.swift +++ b/Sources/XCRemoteCache/Commands/Prepare/Integrate/IntegrateContext.swift @@ -27,14 +27,14 @@ struct IntegrateContext { let configOverride: URL let fakeSrcRoot: URL let output: URL? + let buildSettingsAppenderOptions: BuildSettingsIntegrateAppenderOption } extension IntegrateContext { init( input: String, - repoRootPath: String, + config: XCRemoteCacheConfig, mode: Mode, - configOverridePath: String, env: [String: String], binariesDir: URL, fakeSrcRoot: String, @@ -42,15 +42,22 @@ extension IntegrateContext { ) throws { projectPath = URL(fileURLWithPath: input) let srcRoot = projectPath.deletingLastPathComponent() - repoRoot = URL(fileURLWithPath: repoRootPath, relativeTo: srcRoot) + repoRoot = URL(fileURLWithPath: config.repoRoot, relativeTo: srcRoot) self.mode = mode - configOverride = URL(fileURLWithPath: configOverridePath, relativeTo: srcRoot) + configOverride = URL(fileURLWithPath: config.extraConfigurationFile, relativeTo: srcRoot) output = outputPath.flatMap(URL.init(fileURLWithPath:)) self.fakeSrcRoot = URL(fileURLWithPath: fakeSrcRoot) + var swiftcBinaryName = "swiftc" + var buildSettingsAppenderOptions: BuildSettingsIntegrateAppenderOption = [] + // Keep the legacy behaviour (supported in Xcode 14 and lower) + if !config.enableSwiftDriverIntegration { + buildSettingsAppenderOptions.insert(.disableSwiftDriverIntegration) + swiftcBinaryName = "xcswiftc" + } binaries = XCRCBinariesPaths( prepare: binariesDir.appendingPathComponent("xcprepare"), cc: binariesDir.appendingPathComponent("xccc"), - swiftc: binariesDir.appendingPathComponent("xcswiftc"), + swiftc: binariesDir.appendingPathComponent(swiftcBinaryName), libtool: binariesDir.appendingPathComponent("xclibtool"), lipo: binariesDir.appendingPathComponent("xclipo"), ld: binariesDir.appendingPathComponent("xcld"), @@ -58,5 +65,6 @@ extension IntegrateContext { prebuild: binariesDir.appendingPathComponent("xcprebuild"), postbuild: binariesDir.appendingPathComponent("xcpostbuild") ) + self.buildSettingsAppenderOptions = buildSettingsAppenderOptions } } diff --git a/Sources/XCRemoteCache/Commands/Prepare/Integrate/XCIntegrate.swift b/Sources/XCRemoteCache/Commands/Prepare/Integrate/XCIntegrate.swift index 4b435cc5..e649b01b 100644 --- a/Sources/XCRemoteCache/Commands/Prepare/Integrate/XCIntegrate.swift +++ b/Sources/XCRemoteCache/Commands/Prepare/Integrate/XCIntegrate.swift @@ -82,9 +82,8 @@ public class XCIntegrate { let context = try IntegrateContext( input: projectPath, - repoRootPath: config.repoRoot, + config: config, mode: mode, - configOverridePath: config.extraConfigurationFile, env: env, binariesDir: binariesDir, fakeSrcRoot: fakeSrcRoot, @@ -98,15 +97,12 @@ public class XCIntegrate { excludes: targetsExclude.integrateArrayArguments, includes: targetsInclude.integrateArrayArguments ) - let buildSettingsAppenderOptions: BuildSettingsIntegrateAppenderOption = [ - .disableSwiftDriverIntegration, - ] let buildSettingsAppender = XcodeProjBuildSettingsIntegrateAppender( mode: context.mode, repoRoot: context.repoRoot, fakeSrcRoot: context.fakeSrcRoot, sdksExclude: sdksExclude.integrateArrayArguments, - options: buildSettingsAppenderOptions + options: context.buildSettingsAppenderOptions ) let lldbPatcher: LLDBInitPatcher switch lldbMode { diff --git a/Sources/XCRemoteCache/Commands/SwiftFrontend/SwiftFrontendArgInput.swift b/Sources/XCRemoteCache/Commands/SwiftFrontend/SwiftFrontendArgInput.swift new file mode 100644 index 00000000..d91d2006 --- /dev/null +++ b/Sources/XCRemoteCache/Commands/SwiftFrontend/SwiftFrontendArgInput.swift @@ -0,0 +1,244 @@ +// Copyright (c) 2023 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import Foundation + +enum SwiftFrontendArgInputError: Error, Equatable { + // swift-frontend should either be compling or emiting a module + case bothCompilationAndEmitAction + // no .swift files have been passed as input files + case noCompilationInputs + // no -primary-file .swift files have been passed as input files + case noPrimaryFileCompilationInputs + // number of -emit-dependencies-path doesn't match compilation inputs + case dependenciesOuputCountDoesntMatch(expected: Int, parsed: Int) + // number of -serialize-diagnostics-path doesn't match compilation inputs + case diagnosticsOuputCountDoesntMatch(expected: Int, parsed: Int) + // number of -o doesn't match compilation inputs + case outputsOuputCountDoesntMatch(expected: Int, parsed: Int) + // number of -o for emit-module can be only 1 + case emitModulOuputCountIsNot1(parsed: Int) + // number of -emit-dependencies-path for emit-module can be 0 or 1 (generate or not) + case emitModuleDependenciesOuputCountIsHigherThan1(parsed: Int) + // number of -serialize-diagnostics-path for emit-module can be 0 or 1 (generate or not) + case emitModuleDiagnosticsOuputCountIsHigherThan1(parsed: Int) + // emit-module requires -emit-objc-header-path + case emitModuleMissingObjcHeaderPath + // -target is required + case emitMissingTarget + // -moduleName is required + case emitMissingModuleName +} + +public struct SwiftFrontendArgInput { + let compile: Bool + let emitModule: Bool + let objcHeaderOutput: String? + let moduleName: String? + let target: String? + let primaryInputPaths: [String] + let inputPaths: [String] + var outputPaths: [String] + var dependenciesPaths: [String] + // Extra params + // Diagnostics are not supported yet in the XCRemoteCache (cached artifacts assumes no warnings) + var diagnosticsPaths: [String] + // Unsed for now: + // .swiftsourceinfo and .swiftdoc will be placed next to the .swiftmodule + let sourceInfoPath: String? + let docPath: String? + // Passed as -supplementary-output-file-map + let supplementaryOutputFileMap: String? + + /// Manual initializer implementation required to be public + public init( + compile: Bool, + emitModule: Bool, + objcHeaderOutput: String?, + moduleName: String?, + target: String?, + primaryInputPaths: [String], + inputPaths: [String], + outputPaths: [String], + dependenciesPaths: [String], + diagnosticsPaths: [String], + sourceInfoPath: String?, + docPath: String?, + supplementaryOutputFileMap: String? + ) { + self.compile = compile + self.emitModule = emitModule + self.objcHeaderOutput = objcHeaderOutput + self.moduleName = moduleName + self.target = target + self.primaryInputPaths = primaryInputPaths + self.inputPaths = inputPaths + self.outputPaths = outputPaths + self.dependenciesPaths = dependenciesPaths + self.diagnosticsPaths = diagnosticsPaths + self.sourceInfoPath = sourceInfoPath + self.docPath = docPath + self.supplementaryOutputFileMap = supplementaryOutputFileMap + } + + private func generateForCompilation( + config: XCRemoteCacheConfig, + target: String, + moduleName: String + ) throws -> SwiftcContext { + let primaryInputsCount = primaryInputPaths.count + + guard primaryInputsCount > 0 else { + throw SwiftFrontendArgInputError.noPrimaryFileCompilationInputs + } + guard [primaryInputsCount, 0].contains(dependenciesPaths.count) else { + throw SwiftFrontendArgInputError.dependenciesOuputCountDoesntMatch( + expected: primaryInputsCount, + parsed: dependenciesPaths.count + ) + } + guard [primaryInputsCount, 0].contains(diagnosticsPaths.count) else { + throw SwiftFrontendArgInputError.diagnosticsOuputCountDoesntMatch( + expected: primaryInputsCount, + parsed: diagnosticsPaths.count + ) + } + guard outputPaths.count == primaryInputsCount else { + throw SwiftFrontendArgInputError.outputsOuputCountDoesntMatch( + expected: primaryInputsCount, + parsed: outputPaths.count + ) + } + let primaryInputFilesURLs: [URL] = primaryInputPaths.map(URL.init(fileURLWithPath:)) + + let steps: SwiftcContext.SwiftcSteps = SwiftcContext.SwiftcSteps( + compileFilesScope: .subset(primaryInputFilesURLs), + emitModule: nil + ) + + let compilationFilesInputs = buildCompilationFilesInputs( + primaryInputsCount: primaryInputsCount, + primaryInputFilesURLs: primaryInputFilesURLs + ) + + return try .init( + config: config, + moduleName: moduleName, + steps: steps, + inputs: compilationFilesInputs, + target: target, + compilationFiles: .list(inputPaths), + exampleWorkspaceFilePath: outputPaths[0] + ) + } + + private func buildCompilationFilesInputs( + primaryInputsCount: Int, + primaryInputFilesURLs: [URL] + ) -> SwiftcContext.CompilationFilesInputs { + if let compimentaryFileMa = supplementaryOutputFileMap { + return .supplementaryFileMap(compimentaryFileMa) + } else { + return .map((0.. SwiftcContext { + guard outputPaths.count == 1 else { + throw SwiftFrontendArgInputError.emitModulOuputCountIsNot1(parsed: outputPaths.count) + } + guard let objcHeaderOutput = objcHeaderOutput else { + throw SwiftFrontendArgInputError.emitModuleMissingObjcHeaderPath + } + guard diagnosticsPaths.count <= 1 else { + throw SwiftFrontendArgInputError.emitModuleDiagnosticsOuputCountIsHigherThan1( + parsed: diagnosticsPaths.count + ) + } + guard dependenciesPaths.count <= 1 else { + throw SwiftFrontendArgInputError.emitModuleDependenciesOuputCountIsHigherThan1( + parsed: dependenciesPaths.count + ) + } + + let steps: SwiftcContext.SwiftcSteps = SwiftcContext.SwiftcSteps( + compileFilesScope: .none, + emitModule: SwiftcContext.SwiftcStepEmitModule( + objcHeaderOutput: URL(fileURLWithPath: objcHeaderOutput), + modulePathOutput: URL(fileURLWithPath: outputPaths[0]), + dependencies: dependenciesPaths.first.map(URL.init(fileURLWithPath:)) + ) + ) + return try .init( + config: config, + moduleName: moduleName, + steps: steps, + inputs: .map([:]), + target: target, + compilationFiles: .list(inputPaths), + exampleWorkspaceFilePath: objcHeaderOutput + ) + } + + func generateSwiftcContext(config: XCRemoteCacheConfig) throws -> SwiftcContext { + guard compile != emitModule else { + throw SwiftFrontendArgInputError.bothCompilationAndEmitAction + } + let inputPathsCount = inputPaths.count + guard inputPathsCount > 0 else { + throw SwiftFrontendArgInputError.noCompilationInputs + } + guard let target = target else { + throw SwiftFrontendArgInputError.emitMissingTarget + } + guard let moduleName = moduleName else { + throw SwiftFrontendArgInputError.emitMissingModuleName + } + + if compile { + return try generateForCompilation( + config: config, + target: target, + moduleName: moduleName + ) + } else { + return try generateForEmitModule( + config: config, + target: target, + moduleName: moduleName + ) + } + } +} diff --git a/Sources/XCRemoteCache/Commands/SwiftFrontend/SwiftFrontendOrchestrator.swift b/Sources/XCRemoteCache/Commands/SwiftFrontend/SwiftFrontendOrchestrator.swift new file mode 100644 index 00000000..8da45cea --- /dev/null +++ b/Sources/XCRemoteCache/Commands/SwiftFrontend/SwiftFrontendOrchestrator.swift @@ -0,0 +1,45 @@ +// Copyright (c) 2023 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import Foundation + +/// Manages the `swift-frontend` logic +protocol SwiftFrontendOrchestrator { + /// Executes the criticial secion according to the required order + /// - Parameter criticalSection: the block that should be synchronized + func run(criticalSection: () -> Void ) throws +} + +/// The default orchestrator that manages the order or swift-frontend invocations +/// For emit-module (the "first" process) action, it locks a shared file between all swift-frontend invcations, +/// verifies that the mocking can be done and continues the mocking/fallbacking along the lock release +/// For the compilation action, tries to ackquire a lock and waits until the "emit-module" makes a decision +/// if the compilation should be skipped and a "mocking" should used instead +class CommonSwiftFrontendOrchestrator { + private let mode: SwiftcContext.SwiftcMode + + init(mode: SwiftcContext.SwiftcMode) { + self.mode = mode + } + + func run(criticalSection: () throws -> Void) throws { + // TODO: implement synchronization in a separate PR + try criticalSection() + } +} diff --git a/Sources/XCRemoteCache/Commands/SwiftFrontend/XCSwiftFrontend.swift b/Sources/XCRemoteCache/Commands/SwiftFrontend/XCSwiftFrontend.swift new file mode 100644 index 00000000..64b26dc7 --- /dev/null +++ b/Sources/XCRemoteCache/Commands/SwiftFrontend/XCSwiftFrontend.swift @@ -0,0 +1,60 @@ +// Copyright (c) 2023 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import Foundation + +public class XCSwiftFrontend: XCSwiftAbstract { + // don't lock individual compilation invocations for more than 10s + private static let MaxLockingTimeout: TimeInterval = 10 + private let env: [String: String] + + public init( + command: String, + inputArgs: SwiftFrontendArgInput, + env: [String: String], + dependenciesWriter: @escaping (URL, FileManager) -> DependenciesWriter, + touchFactory: @escaping (URL, FileManager) -> Touch + ) throws { + self.env = env + super.init( + command: command, + inputArgs: inputArgs, + dependenciesWriter: dependenciesWriter, + touchFactory: touchFactory + ) + } + + override func buildContext() throws -> (XCRemoteCacheConfig, SwiftcContext) { + let fileManager = FileManager.default + let config: XCRemoteCacheConfig + let context: SwiftcContext + + let srcRoot: URL = URL(fileURLWithPath: fileManager.currentDirectoryPath) + config = try XCRemoteCacheConfigReader(srcRootPath: srcRoot.path, fileReader: fileManager) + .readConfiguration() + context = try SwiftcContext(config: config, input: inputArgs) + // do not cache this context, as it is subject to change when + // the emit-module finds that the cached artifact cannot be used + return (config, context) + } + + override public func run() throws { + // TODO: implement in a follow-up PR + } +} diff --git a/Sources/XCRemoteCache/Commands/Swiftc/SwiftcContext.swift b/Sources/XCRemoteCache/Commands/Swiftc/SwiftcContext.swift index 485c9d18..6cd89e08 100644 --- a/Sources/XCRemoteCache/Commands/Swiftc/SwiftcContext.swift +++ b/Sources/XCRemoteCache/Commands/Swiftc/SwiftcContext.swift @@ -159,4 +159,11 @@ public struct SwiftcContext { exampleWorkspaceFilePath: input.modulePathOutput ) } + + init( + config: XCRemoteCacheConfig, + input: SwiftFrontendArgInput + ) throws { + self = try input.generateSwiftcContext(config: config) + } } diff --git a/Sources/XCRemoteCache/Config/XCRemoteCacheConfig.swift b/Sources/XCRemoteCache/Config/XCRemoteCacheConfig.swift index 4ef7b77f..067e5d01 100644 --- a/Sources/XCRemoteCache/Config/XCRemoteCacheConfig.swift +++ b/Sources/XCRemoteCache/Config/XCRemoteCacheConfig.swift @@ -17,6 +17,8 @@ // specific language governing permissions and limitations // under the License. +// swiftlint:disable file_length + import Foundation import Yams @@ -57,6 +59,8 @@ public struct XCRemoteCacheConfig: Encodable { var clangCommand: String = "clang" /// Command for a standard Swift compilation (swiftc) var swiftcCommand: String = "swiftc" + /// Command for a standard Swift frontend compilation (swift-frontend) + var swiftFrontendCommand: String = "swift-frontend" /// Path of the primary repository that produces cache artifacts var primaryRepo: String = "" /// Main (primary) branch that produces cache artifacts (default to 'master') @@ -151,6 +155,8 @@ public struct XCRemoteCacheConfig: Encodable { /// If true, do not fail `prepare` if cannot find the most recent common commits with the primary branch /// That might useful on CI, where a shallow clone is used var gracefullyHandleMissingCommonSha: Bool = false + /// Enable experimental integration with swift driver, added in Xcode 14 + var enableSwiftDriverIntegration: Bool = false } extension XCRemoteCacheConfig { @@ -211,6 +217,7 @@ extension XCRemoteCacheConfig { merge.irrelevantDependenciesPaths = scheme.irrelevantDependenciesPaths ?? irrelevantDependenciesPaths merge.gracefullyHandleMissingCommonSha = scheme.gracefullyHandleMissingCommonSha ?? gracefullyHandleMissingCommonSha + merge.enableSwiftDriverIntegration = scheme.enableSwiftDriverIntegration ?? enableSwiftDriverIntegration return merge } @@ -279,6 +286,7 @@ struct ConfigFileScheme: Decodable { let customRewriteEnvs: [String]? let irrelevantDependenciesPaths: [String]? let gracefullyHandleMissingCommonSha: Bool? + let enableSwiftDriverIntegration: Bool? // Yams library doesn't support encoding strategy, see https://github.com/jpsim/Yams/issues/84 enum CodingKeys: String, CodingKey { @@ -330,6 +338,7 @@ struct ConfigFileScheme: Decodable { case customRewriteEnvs = "custom_rewrite_envs" case irrelevantDependenciesPaths = "irrelevant_dependencies_paths" case gracefullyHandleMissingCommonSha = "gracefully_handle_missing_common_sha" + case enableSwiftDriverIntegration = "enable_swift_driver_integration" } } diff --git a/Sources/XCRemoteCache/FlowControl/RemoteCommitInfo.swift b/Sources/XCRemoteCache/FlowControl/RemoteCommitInfo.swift index 094b43ac..5dc38af1 100644 --- a/Sources/XCRemoteCache/FlowControl/RemoteCommitInfo.swift +++ b/Sources/XCRemoteCache/FlowControl/RemoteCommitInfo.swift @@ -27,7 +27,7 @@ enum RemoteCommitInfo: Equatable { extension RemoteCommitInfo { init(_ commit: String?) { switch commit { - case .some(let value) where !value.isEmpty : + case .some(let value) where !value.isEmpty: self = .available(commit: value) default: self = .unavailable diff --git a/Sources/XCRemoteCache/Utils/Array+Utils.swift b/Sources/XCRemoteCache/Utils/Array+Utils.swift new file mode 100644 index 00000000..67e2e75a --- /dev/null +++ b/Sources/XCRemoteCache/Utils/Array+Utils.swift @@ -0,0 +1,29 @@ +// Copyright (c) 2023 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import Foundation + +public extension Array { + func get(_ i: Index) -> Element? { + guard count > i else { + return nil + } + return self[i] + } +} diff --git a/Sources/xcswift-frontend/XCSwiftcFrontendMain.swift b/Sources/xcswift-frontend/XCSwiftcFrontendMain.swift new file mode 100644 index 00000000..ec5a82ac --- /dev/null +++ b/Sources/xcswift-frontend/XCSwiftcFrontendMain.swift @@ -0,0 +1,141 @@ +// Copyright (c) 2023 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import Foundation +import XCRemoteCache + +/// Wrapper for a `swift-frontend` that skips compilation and +/// produces empty output files (.o). Just like in xcswiftc, compilation dependencies +/// (.d) files are copied from the prebuild marker file which includes all relevant files +/// Fallbacks to a standard `swift-frontend` when the +/// ramote cache is not applicable (e.g. modified sources) +public class XCSwiftcFrontendMain { + // swiftlint:disable:next function_body_length cyclomatic_complexity + public func main() { + let env = ProcessInfo.processInfo.environment + let command = ProcessInfo().processName + let args = ProcessInfo().arguments + var compile = false + var emitModule = false + var objcHeaderOutput: String? + var moduleName: String? + var target: String? + var inputPaths: [String] = [] + var primaryInputPaths: [String] = [] + var outputPaths: [String] = [] + var dependenciesPaths: [String] = [] + var diagnosticsPaths: [String] = [] + var sourceInfoPath: String? + var docPath: String? + var supplementaryOutputFileMap: String? + + for i in 0.. Never { + // DEVELOPER_DIR is always set by Xcode + let developerDir = env["DEVELOPER_DIR"]! + // limitation: always using the Xcode's toolchain, otherwise + // there will be a loop for invoking swift-frontend wrapper from XCRemoteCache + // Cause: for injecting into the swift driver pipeline, Xcode looks for + // an executable with the name `swift-frontend` that is placed in the same + // dir as `SWIFT_EXEC`'s `swiftc` wrapper + let swiftFrontendCommand = "\(developerDir)/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift-frontend" + + let args = ProcessInfo().arguments + let paramList = [swiftFrontendCommand] + args.dropFirst() + let cargs = paramList.map { strdup($0) } + [nil] + execvp(swiftFrontendCommand, cargs) + + /// C-function `execv` returns only when the command fails + exit(1) + } +} diff --git a/Sources/xcswift-frontend/main.swift b/Sources/xcswift-frontend/main.swift new file mode 100644 index 00000000..0bdf4a1a --- /dev/null +++ b/Sources/xcswift-frontend/main.swift @@ -0,0 +1,22 @@ +// Copyright (c) 2023 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import XCRemoteCache + +XCSwiftcFrontendMain().main() diff --git a/Tests/XCRemoteCacheTests/Commands/Prepare/Integrate/IntegrateContextTests.swift b/Tests/XCRemoteCacheTests/Commands/Prepare/Integrate/IntegrateContextTests.swift new file mode 100644 index 00000000..4e0a87c9 --- /dev/null +++ b/Tests/XCRemoteCacheTests/Commands/Prepare/Integrate/IntegrateContextTests.swift @@ -0,0 +1,67 @@ +// Copyright (c) 2023 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +@testable import XCRemoteCache +import XCTest + +class IntegrateTests: FileXCTestCase { + private var config: XCRemoteCacheConfig! + private var remoteCommitFile: URL! + + override func setUpWithError() throws { + try super.setUpWithError() + let workingDir = try prepareTempDir() + remoteCommitFile = workingDir.appendingPathComponent("arc.rc") + _ = workingDir.appendingPathComponent("mpo") + config = XCRemoteCacheConfig(remoteCommitFile: remoteCommitFile.path, sourceRoot: workingDir.path) + config.recommendedCacheAddress = "http://test.com" + } + + + func tesFallbacksToNoDriverByDefault() throws { + let context = try IntegrateContext( + input: "project.xcodeproj", + config: config, + mode: .producer, + env: [:], + binariesDir: "/binaries", + fakeSrcRoot: "/src", + outputPath: "/output" + ) + + XCTAssertEqual(context.buildSettingsAppenderOptions, [.disableSwiftDriverIntegration]) + XCTAssertEqual(context.binaries.swiftc, "/binaries/xcswiftc") + } + + func testEnablesDriverOnRequest() throws { + config.enableSwiftDriverIntegration = true + let context = try IntegrateContext( + input: "project.xcodeproj", + config: config, + mode: .producer, + env: [:], + binariesDir: "/binaries", + fakeSrcRoot: "/src", + outputPath: "/output" + ) + + XCTAssertEqual(context.buildSettingsAppenderOptions, []) + XCTAssertEqual(context.binaries.swiftc, "/binaries/swiftc") + } +} diff --git a/Tests/XCRemoteCacheTests/Commands/SwiftFrontend/SwiftFrontendArgInputTests.swift b/Tests/XCRemoteCacheTests/Commands/SwiftFrontend/SwiftFrontendArgInputTests.swift new file mode 100644 index 00000000..0d7e4af1 --- /dev/null +++ b/Tests/XCRemoteCacheTests/Commands/SwiftFrontend/SwiftFrontendArgInputTests.swift @@ -0,0 +1,363 @@ +// Copyright (c) 2023 Spotify AB. +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +@testable import XCRemoteCache +import XCTest + +// swiftlint:disable type_body_length +class SwiftFrontendArgInputTests: FileXCTestCase { + private var compile: Bool = true + private var emitModule: Bool = false + private var objcHeaderOutput: String? + private var moduleName: String? + private var target: String? + private var primaryInputPaths: [String] = [] + private var inputPaths: [String] = [] + private var outputPaths: [String] = [] + private var dependenciesPaths: [String] = [] + private var diagnosticsPaths: [String] = [] + private var sourceInfoPath: String? + private var docPath: String? + private var supplementaryOutputFileMap: String? + + private var config: XCRemoteCacheConfig! + private var input: SwiftFrontendArgInput! + + override func setUpWithError() throws { + try super.setUpWithError() + let workingDir = try prepareTempDir() + let remoteCommitFile = workingDir.appendingPathComponent("arc.rc") + config = XCRemoteCacheConfig(remoteCommitFile: remoteCommitFile.path, sourceRoot: workingDir.path) + config.recommendedCacheAddress = "http://test.com" + + buildInput() + } + + private func buildInput() { + input = SwiftFrontendArgInput( + compile: compile, + emitModule: emitModule, + objcHeaderOutput: objcHeaderOutput, + moduleName: moduleName, + target: target, + primaryInputPaths: primaryInputPaths, + inputPaths: inputPaths, + outputPaths: outputPaths, + dependenciesPaths: dependenciesPaths, + diagnosticsPaths: diagnosticsPaths, + sourceInfoPath: sourceInfoPath, + docPath: docPath, + supplementaryOutputFileMap: supplementaryOutputFileMap) + } + + private func assertGenerationError(_ expectedError: SwiftFrontendArgInputError) { + XCTAssertThrowsError(try input.generateSwiftcContext(config: config)) { error in + guard let e = error as? SwiftFrontendArgInputError else { + XCTFail("Received invalid error \(error). Expected: \(expectedError)") + return + } + XCTAssertEqual(e, expectedError) + } + } + + func testFailsForNoStep() throws { + compile = false + emitModule = false + buildInput() + + assertGenerationError(SwiftFrontendArgInputError.bothCompilationAndEmitAction) + } + + func testFailsIfNoCompilationFiles() throws { + buildInput() + + assertGenerationError(SwiftFrontendArgInputError.noCompilationInputs) + } + + func testFailsIfNoTarget() throws { + inputPaths = ["/file1"] + buildInput() + + assertGenerationError(SwiftFrontendArgInputError.emitMissingTarget) + } + + func testFailsIfNoModuleName() throws { + inputPaths = ["/file1"] + target = "Target" + buildInput() + + assertGenerationError(SwiftFrontendArgInputError.emitMissingModuleName) + } + + func testFailsIfNoCompileHasNoPrimaryInputs() throws { + inputPaths = ["/file1"] + target = "Target" + moduleName = "Module" + buildInput() + + assertGenerationError(SwiftFrontendArgInputError.noPrimaryFileCompilationInputs) + } + + func testFailsIfDependenciesAreMissing() throws { + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + primaryInputPaths = ["/file1", "/file2"] + dependenciesPaths = ["/file1.d"] + buildInput() + + assertGenerationError(SwiftFrontendArgInputError.dependenciesOuputCountDoesntMatch(expected: 2, parsed: 1)) + } + + func testDoesntFailForMissingDependenciesIfNoDependencies() throws { + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + primaryInputPaths = ["/file1", "/file2"] + dependenciesPaths = [] + buildInput() + + assertGenerationError(SwiftFrontendArgInputError.outputsOuputCountDoesntMatch(expected: 2, parsed: 0)) + } + + func testFailsIfDiagnosticsAreMissing() throws { + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + primaryInputPaths = ["/file1", "/file2"] + diagnosticsPaths = ["/file1.d"] + buildInput() + + assertGenerationError(SwiftFrontendArgInputError.diagnosticsOuputCountDoesntMatch(expected: 2, parsed: 1)) + } + + func testDoesntFailForMissingDdiagnosticsIfNoDiagnostics() throws { + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + primaryInputPaths = ["/file1", "/file2"] + diagnosticsPaths = [] + buildInput() + + assertGenerationError(SwiftFrontendArgInputError.outputsOuputCountDoesntMatch(expected: 2, parsed: 0)) + } + + func testFailsIfOutputsAreMissing() throws { + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + primaryInputPaths = ["/file1", "/file2"] + outputPaths = ["/file1.o"] + buildInput() + + assertGenerationError(SwiftFrontendArgInputError.outputsOuputCountDoesntMatch(expected: 2, parsed: 1)) + } + + func testSetsCompilationSubsetForCompilation() throws { + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + primaryInputPaths = ["/file1"] + outputPaths = ["/file1.o"] + buildInput() + + let context = try input.generateSwiftcContext(config: config) + + XCTAssertEqual(context.steps, .init( + compileFilesScope: .subset(["/file1"]), + emitModule: .none + )) + } + + func testBuildCompilationFilesInputs() throws { + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + primaryInputPaths = ["/file1"] + outputPaths = ["/file1.o"] + dependenciesPaths = ["/file1.d"] + buildInput() + + let context = try input.generateSwiftcContext(config: config) + + XCTAssertEqual(context.inputs, .map([ + "/file1": SwiftFileCompilationInfo( + file: "/file1", + dependencies: "/file1.d", + object: "/file1.o", + swiftDependencies: nil), + ]) + ) + } + + func testRecognizesArchFromOuputFirstPaths() throws { + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + primaryInputPaths = ["/file1"] + outputPaths = ["/TARGET_TEMP_DIR/Object-normal/arm64/file1.o"] + dependenciesPaths = ["/file1.d"] + buildInput() + + let context = try input.generateSwiftcContext(config: config) + + XCTAssertEqual(context.arch, "arm64") + } + + func testPassesExtraParams() throws { + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + primaryInputPaths = ["/file1"] + outputPaths = ["/file1.o"] + dependenciesPaths = ["/file1.d"] + buildInput() + + let context = try input.generateSwiftcContext(config: config) + + XCTAssertEqual(context.moduleName, "Module") + XCTAssertEqual(context.target, "Target") + XCTAssertEqual(context.compilationFiles, .list(inputPaths)) + XCTAssertEqual(context.mode, .consumer(commit: .unavailable)) + } + + func testEmitModuleFailsForMissingOutput() throws { + emitModule = true + compile = false + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + outputPaths = [] + buildInput() + + assertGenerationError(SwiftFrontendArgInputError.emitModulOuputCountIsNot1(parsed: 0)) + } + + func testEmitModuleFailsForMissingObjcHeader() throws { + emitModule = true + compile = false + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + outputPaths = ["/Module.swiftmodule"] + buildInput() + + assertGenerationError(SwiftFrontendArgInputError.emitModuleMissingObjcHeaderPath) + } + + func testEmitModuleFailsForExcessiveDiagnostics() throws { + emitModule = true + compile = false + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + outputPaths = ["/Module.swiftmodule"] + objcHeaderOutput = "/file-Swift.h" + diagnosticsPaths = ["/file.diag", "/file2.diag"] + buildInput() + + assertGenerationError(SwiftFrontendArgInputError.emitModuleDiagnosticsOuputCountIsHigherThan1(parsed: 2)) + } + + func testEmitModuleFailsForExcessiveDependencies() throws { + emitModule = true + compile = false + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + outputPaths = ["/Module.swiftmodule"] + objcHeaderOutput = "/file-Swift.h" + dependenciesPaths = ["/file.d", "/file2.d"] + buildInput() + + assertGenerationError(SwiftFrontendArgInputError.emitModuleDependenciesOuputCountIsHigherThan1(parsed: 2)) + } + + func testEmitModuleSetsStep() throws { + emitModule = true + compile = false + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + outputPaths = ["/Module.swiftmodule"] + objcHeaderOutput = "/file-Swift.h" + diagnosticsPaths = ["/file.dia"] + dependenciesPaths = ["/file.d"] + buildInput() + + let context = try input.generateSwiftcContext(config: config) + + XCTAssertEqual(context.steps, .init( + compileFilesScope: .none, + emitModule: .init( + objcHeaderOutput: "/file-Swift.h", + modulePathOutput: "/Module.swiftmodule", + dependencies: "/file.d")) + ) + } + + func testEmitModuleSetsAllIntpus() throws { + emitModule = true + compile = false + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + outputPaths = ["/Module.swiftmodule"] + objcHeaderOutput = "/file-Swift.h" + buildInput() + + let context = try input.generateSwiftcContext(config: config) + + XCTAssertEqual(context.compilationFiles, .list(inputPaths)) + } + + func testEmitModuleRecognizesArchFromObjCHeader() throws { + emitModule = true + compile = false + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + outputPaths = ["file.swiftmodule"] + objcHeaderOutput = "/TARGET_TEMP_DIR/Object-normal/arm64/file-Swift.h" + buildInput() + + let context = try input.generateSwiftcContext(config: config) + + XCTAssertEqual(context.arch, "arm64") + } + + func testEmitModulePassesExtraParams() throws { + emitModule = true + compile = false + inputPaths = ["/file1", "/file2", "/file3"] + target = "Target" + moduleName = "Module" + outputPaths = ["/Module.swiftmodule"] + objcHeaderOutput = "/file-Swift.h" + buildInput() + + let context = try input.generateSwiftcContext(config: config) + + XCTAssertEqual(context.moduleName, "Module") + XCTAssertEqual(context.target, "Target") + XCTAssertEqual(context.compilationFiles, .list(inputPaths)) + XCTAssertEqual(context.mode, .consumer(commit: .unavailable)) + } +} +// swiftlint:enable type_body_length