carton/Sources/SwiftToolchain/Toolchain.swift

301 lines
9.8 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
import TSCUtility
private let compatibleJSKitRevision = "6e84a70"
public let compatibleJSKitVersion = Version(0, 7, 2)
enum ToolchainError: Error, CustomStringConvertible {
case directoryDoesNotExist(AbsolutePath)
case invalidResponseCode(UInt)
case invalidInstallationArchive(AbsolutePath)
case noExecutableProduct
case failedToBuild(product: String)
case failedToBuildTestBundle
case missingPackageManifest
case invalidVersion(version: String)
case invalidResponse(url: String, status: UInt)
case unsupportedOperatingSystem
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 .missingPackageManifest:
return """
The `Package.swift` manifest file could not be found. Please navigate to a directory that \
contains `Package.swift` and restart.
"""
case let .invalidVersion(version):
return "Invalid version \(version)"
case let .invalidResponse(url: url, status: status):
return "Response from \(url) had invalid status \(status) or didn't contain body"
case .unsupportedOperatingSystem:
return "This version of the operating system is not supported"
}
}
}
extension Package.Dependency.Requirement {
var isJavaScriptKitCompatible: Bool {
if let upperBound = range?.first?.upperBound, let version = Version(string: upperBound) {
return version >= compatibleJSKitVersion
}
return revision == [compatibleJSKitRevision] ||
exact?.compactMap { Version(string: $0) } == [compatibleJSKitVersion]
}
var version: String {
revision?.first ?? range?.first?.lowerBound ?? ""
}
}
public final class Toolchain {
private let fileSystem: FileSystem
private let terminal: InteractiveWriter
private let version: String
private let swiftPath: AbsolutePath
public let package: Result<Package, Error>
public init(
for versionSpec: String? = nil,
_ fileSystem: FileSystem,
_ terminal: InteractiveWriter
) throws {
let (swiftPath, version) = try fileSystem.inferSwiftPath(from: versionSpec, terminal)
self.swiftPath = swiftPath
self.version = version
self.fileSystem = fileSystem
self.terminal = terminal
package = Result { try Package(with: swiftPath, terminal) }
}
private func inferBinPath(isRelease: Bool) throws -> AbsolutePath {
guard
let output = try processStringOutput([
swiftPath.pathString, "build", "-c", isRelease ? "release" : "debug",
"--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? {
let package = try self.package.get()
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
}
}
private func inferManifestDirectory() throws -> AbsolutePath {
guard (try? package.get()) != nil, var cwd = fileSystem.currentWorkingDirectory else {
throw ToolchainError.missingPackageManifest
}
repeat {
guard !fileSystem.isFile(cwd.appending(component: "Package.swift")) else {
return cwd
}
// `parentDirectory` just returns `self` if it's `root`
cwd = cwd.parentDirectory
} while !cwd.isRoot
throw ToolchainError.missingPackageManifest
}
public func inferSourcesPaths() throws -> [AbsolutePath] {
let package = try self.package.get()
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
}
let manifestDirectory = try inferManifestDirectory()
return try targetPaths.compactMap {
try manifestDirectory.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 }
let package = try self.package.get()
if let jsKit = package.dependencies?.first(where: { $0.name == "JavaScriptKit" }),
!jsKit.requirement.isJavaScriptKitCompatible
{
let version = jsKit.requirement.version
terminal.write(
"""
This version of JavaScriptKit \(version) is not known to be compatible with \
carton \(cartonVersion). Please specify a JavaScriptKit dependency on version \
\(compatibleJSKitVersion) in your `Package.swift`.\n
""",
inColor: .red
)
}
let binPath = try inferBinPath(isRelease: isRelease)
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 Builder(arguments: builderArguments, mainWasmPath: mainWasmPath, fileSystem, terminal)
.runAndWaitUntilFinished()
guard fileSystem.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 {
let package = try self.package.get()
let binPath = try inferBinPath(isRelease: isRelease)
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",
"--enable-test-discovery", "--destination", inferDestinationPath().pathString,
"-Xswiftc", "-color-diagnostics",
]
try Builder(
arguments: builderArguments,
mainWasmPath: testBundlePath,
environment: .other,
fileSystem,
terminal
)
.runAndWaitUntilFinished()
guard fileSystem.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()
}
public func runPackage(_ arguments: [String]) throws {
let args = [swiftPath.pathString, "package"] + arguments
try ProcessRunner(args, terminal)
.waitUntilFinished()
}
}