carton/Sources/SwiftToolchain/Toolchain.swift

238 lines
7.7 KiB
Swift

// Copyright 2020 Carton contributors
//
// Licensed 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 CartonHelpers
import Foundation
import TSCBasic
private let compatibleJSKitRevision = "c90e82f"
enum ToolchainError: Error, CustomStringConvertible {
case directoryDoesNotExist(AbsolutePath)
case invalidResponseCode(UInt)
case invalidInstallationArchive(AbsolutePath)
case noExecutableProduct
case failedToBuild(product: String)
case failedToBuildTestBundle
case missingPackage
var description: String {
switch self {
case let .directoryDoesNotExist(path):
return "Directory at path \(path.pathString) does not exist and could not be created"
case let .invalidResponseCode(code):
return """
While attempting to download an archive, the server returned an invalid response code \(code)
"""
case let .invalidInstallationArchive(path):
return "Invalid toolchain/SDK archive was installed at path \(path)"
case .noExecutableProduct:
return "No executable product to build could be inferred"
case let .failedToBuild(product):
return "Failed to build executable product \(product)"
case .failedToBuildTestBundle:
return "Failed to build the test bundle"
case .missingPackage:
return "No package found"
}
}
}
public final class Toolchain {
private let fileSystem: FileSystem
private let terminal: TerminalController
private let version: String
private let swiftPath: AbsolutePath
private let package: Package?
public init(
for versionSpec: String? = nil,
_ fileSystem: FileSystem,
_ terminal: TerminalController
) throws {
(swiftPath, version) = try fileSystem.inferSwiftPath(from: versionSpec, terminal)
self.fileSystem = fileSystem
self.terminal = terminal
package = try? Package(with: swiftPath, terminal)
}
private func inferBinPath() throws -> AbsolutePath {
guard
let output = try processStringOutput([
swiftPath.pathString, "build", "--triple", "wasm32-unknown-wasi", "--show-bin-path",
])?.components(separatedBy: CharacterSet.newlines),
let binPath = output.first
else { fatalError("failed to decode UTF8 output of the `swift build` invocation") }
return AbsolutePath(binPath)
}
private func inferDevProduct(hint: String?) throws -> String? {
guard let package = package else {
throw ToolchainError.missingPackage
}
var candidateProducts = package.products
.filter { $0.type.library == nil }
.map(\.name)
if let product = hint {
candidateProducts = candidateProducts.filter { $0 == product }
guard candidateProducts.count == 1 else {
terminal.write("""
Failed to disambiguate the executable product, \
make sure `\(product)` product is present in Package.swift
""", inColor: .red)
return nil
}
terminal.logLookup("- development product: ", product)
return product
} else if candidateProducts.count == 1 {
return candidateProducts[0]
} else {
terminal.write("Failed to disambiguate the development product\n", inColor: .red)
if candidateProducts.count > 1 {
terminal.write("Pass one of \(candidateProducts) to the --product option\n", inColor: .red)
} else {
terminal.write(
"Make sure there's at least one executable product in your Package.swift\n",
inColor: .red
)
}
return nil
}
}
public func inferSourcesPaths() throws -> [AbsolutePath] {
let package = try Package(with: swiftPath, terminal)
let targetPaths = package.targets.compactMap { target -> String? in
guard let path = target.path else {
switch target.type {
case .regular:
return "Sources/\(target.name)"
case .test:
return nil
}
}
return path
}
return try targetPaths.compactMap {
try fileSystem.currentWorkingDirectory?.appending(RelativePath(validating: $0))
}
}
private func inferDestinationPath() throws -> AbsolutePath {
try fileSystem.inferDestinationPath(for: version, swiftPath: swiftPath)
}
public func buildCurrentProject(
product: String?,
destination: String?,
isRelease: Bool
) throws -> (builderArguments: [String], mainWasmPath: AbsolutePath) {
guard let product = try inferDevProduct(hint: product)
else { throw ToolchainError.noExecutableProduct }
if let package = package,
let jsKit = package.dependencies?.first(where: { $0.name == "JavaScriptKit" }),
jsKit.requirement.revision != ["c90e82f"] {
let version = jsKit.requirement.revision.flatMap { " (\($0[0]))" } ?? ""
terminal.write(
"""
This revision of JavaScriptKit\(version) is not known to be compatible with \
carton \(cartonVersion). Please specify a JavaScriptKit dependency to revision \
\(compatibleJSKitRevision) in your `Package.swift`.\n
""",
inColor: .red
)
}
let binPath = try inferBinPath()
let mainWasmPath = binPath.appending(component: product)
terminal.logLookup("- development binary to serve: ", mainWasmPath.pathString)
terminal.write("\nBuilding the project before spinning up a server...\n", inColor: .yellow)
let builderArguments = try [
swiftPath.pathString, "build", "-c", isRelease ? "release" : "debug", "--product", product,
"--enable-test-discovery", "--destination", destination ?? inferDestinationPath().pathString,
]
try ProcessRunner(builderArguments, terminal).waitUntilFinished()
guard localFileSystem.exists(mainWasmPath) else {
terminal.write(
"Failed to build the main executable binary, fix the build errors and restart\n",
inColor: .red
)
throw ToolchainError.failedToBuild(product: product)
}
return (builderArguments, mainWasmPath)
}
/// Returns an absolute path to the resulting test bundle
public func buildTestBundle(isRelease: Bool) throws -> AbsolutePath {
guard let package = package else {
throw ToolchainError.missingPackage
}
let binPath = try inferBinPath()
let testBundlePath = binPath.appending(component: "\(package.name)PackageTests.xctest")
terminal.logLookup("- test bundle to run: ", testBundlePath.pathString)
terminal.write(
"\nBuilding the test bundle before running the test suite...\n",
inColor: .yellow
)
let builderArguments = try [
swiftPath.pathString, "build", "-c", isRelease ? "release" : "debug", "--build-tests",
"--destination", inferDestinationPath().pathString,
]
try ProcessRunner(builderArguments, terminal).waitUntilFinished()
guard localFileSystem.exists(testBundlePath) else {
terminal.write(
"Failed to build the test bundle, fix the build errors and restart\n",
inColor: .red
)
throw ToolchainError.failedToBuildTestBundle
}
return testBundlePath
}
public func packageInit(name: String, type: PackageType, inPlace: Bool) throws {
var initArgs = [
swiftPath.pathString, "package", "init",
"--type", type.rawValue,
]
if !inPlace {
initArgs.append(contentsOf: ["--name", name])
}
try ProcessRunner(initArgs, terminal)
.waitUntilFinished()
}
}