Skip to content

Commit c067113

Browse files
Provide better help message and friendly build error diagnostics
1 parent 1003cc2 commit c067113

File tree

1 file changed

+154
-112
lines changed

1 file changed

+154
-112
lines changed

Plugins/PackageToJS/PackageToJS.swift

Lines changed: 154 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,14 @@
1-
import PackagePlugin
21
import Foundation
3-
4-
struct PackageToJSError: Swift.Error, CustomStringConvertible {
5-
let description: String
6-
7-
init(_ message: String) {
8-
self.description = "Error: " + message
9-
}
10-
}
2+
import PackagePlugin
113

124
@main
135
struct PackageToJS: CommandPlugin {
146
struct Options {
7+
/// Product to build (default: executable target if there's only one)
158
var product: String?
9+
/// Name of the package (default: lowercased Package.swift name)
1610
var packageName: String?
11+
/// Whether to explain the build plan
1712
var explain: Bool = false
1813

1914
static func parse(from extractor: inout ArgumentExtractor) -> Options {
@@ -22,61 +17,124 @@ struct PackageToJS: CommandPlugin {
2217
let explain = extractor.extractFlag(named: "explain")
2318
return Options(product: product, packageName: packageName, explain: explain != 0)
2419
}
20+
21+
static func help() -> String {
22+
return """
23+
Usage: swift package --swift-sdk <swift-sdk> plugin run PackageToJS [options]
24+
25+
Options:
26+
--product <product> Product to build (default: executable target if there's only one)
27+
--package-name <name> Name of the package (default: lowercased Package.swift name)
28+
--explain Whether to explain the build plan
29+
"""
30+
}
2531
}
2632

33+
static let friendlyBuildDiagnostics:
34+
[(_ build: PackageManager.BuildResult, _ arguments: [String]) -> String?] = [
35+
(
36+
// In case user misses the `--swift-sdk` option
37+
{ build, arguments in
38+
guard
39+
build.logText.contains(
40+
"ld.gold: --export-if-defined=__main_argc_argv: unknown option")
41+
else { return nil }
42+
let didYouMean =
43+
[
44+
"swift", "package", "--swift-sdk", "wasm32-unknown-wasi", "js",
45+
] + arguments
46+
return """
47+
Please pass the `--swift-sdk` option to the "swift package" command.
48+
49+
Did you mean:
50+
\(didYouMean.joined(separator: " "))
51+
"""
52+
}),
53+
(
54+
// In case selected Swift SDK version is not compatible with the Swift compiler version
55+
{ build, arguments in
56+
let regex =
57+
#/module compiled with Swift (?<swiftSDKVersion>\d+\.\d+(?:\.\d+)?) cannot be imported by the Swift (?<compilerVersion>\d+\.\d+(?:\.\d+)?) compiler/#
58+
guard let match = build.logText.firstMatch(of: regex) else { return nil }
59+
let swiftSDKVersion = match.swiftSDKVersion
60+
let compilerVersion = match.compilerVersion
61+
return """
62+
Swift versions mismatch:
63+
- Swift SDK version: \(swiftSDKVersion)
64+
- Swift compiler version: \(compilerVersion)
65+
66+
Please ensure you are using matching versions of the Swift SDK and Swift compiler.
67+
68+
1. Use 'swift --version' to check your Swift compiler version
69+
2. Use 'swift sdk list' to check available Swift SDKs
70+
3. Select a matching SDK version with --swift-sdk option
71+
"""
72+
}),
73+
]
74+
2775
func performCommand(context: PluginContext, arguments: [String]) throws {
76+
if arguments.contains(where: { ["-h", "--help"].contains($0) }) {
77+
print(Options.help())
78+
return
79+
}
80+
2881
var extractor = ArgumentExtractor(arguments)
2982
let options = Options.parse(from: &extractor)
3083

31-
let productName = try options.product ?? deriveDefaultProduct(package: context.package)
3284
// Build products
85+
let (build, productName) = try buildWasm(options: options, context: context)
86+
guard build.succeeded else {
87+
for diagnostic in Self.friendlyBuildDiagnostics {
88+
if let message = diagnostic(build, arguments) {
89+
fputs("\n" + message + "\n", stderr)
90+
}
91+
}
92+
exit(1)
93+
}
94+
95+
let productArtifact = try build.findWasmArtifact(for: productName)
96+
let outputDir = context.pluginWorkDirectory.appending(subpath: "Package")
97+
guard
98+
let selfPackage = findPackageInDependencies(
99+
package: context.package, id: "javascriptkit")
100+
else {
101+
throw PackageToJSError("Failed to find JavaScriptKit in dependencies!?")
102+
}
103+
var make = MiniMake(explain: options.explain)
104+
let allTask = constructPackagingPlan(
105+
make: &make, options: options, context: context, wasmProductArtifact: productArtifact,
106+
selfPackage: selfPackage, outputDir: outputDir)
107+
try make.build(output: allTask)
108+
print("Packaging finished")
109+
}
110+
111+
private func buildWasm(options: Options, context: PluginContext) throws -> (
112+
build: PackageManager.BuildResult, productName: String
113+
) {
33114
var parameters = PackageManager.BuildParameters(
34115
configuration: .inherit,
35116
logging: .concise
36117
)
37118
parameters.echoLogs = true
38-
let buildingForEmbedded = ProcessInfo.processInfo.environment["JAVASCRIPTKIT_EXPERIMENTAL_EMBEDDED_WASM"].flatMap(Bool.init) ?? false
119+
let buildingForEmbedded =
120+
ProcessInfo.processInfo.environment["JAVASCRIPTKIT_EXPERIMENTAL_EMBEDDED_WASM"].flatMap(
121+
Bool.init) ?? false
39122
if !buildingForEmbedded {
40123
// NOTE: We only support static linking for now, and the new SwiftDriver
41124
// does not infer `-static-stdlib` for WebAssembly targets intentionally
42125
// for future dynamic linking support.
43-
parameters.otherSwiftcFlags = ["-static-stdlib", "-Xclang-linker", "-mexec-model=reactor"]
126+
parameters.otherSwiftcFlags = [
127+
"-static-stdlib", "-Xclang-linker", "-mexec-model=reactor",
128+
]
44129
parameters.otherLinkerFlags = ["--export-if-defined=__main_argc_argv"]
45130
}
46-
131+
let productName = try options.product ?? deriveDefaultProduct(package: context.package)
47132
let build = try self.packageManager.build(.product(productName), parameters: parameters)
48-
49-
guard build.succeeded else {
50-
print(build.logText)
51-
exit(1)
52-
}
53-
54-
guard let product = try context.package.products(named: [productName]).first else {
55-
throw PackageToJSError("Failed to find product named \"\(productName)\"")
56-
}
57-
guard let executableProduct = product as? ExecutableProduct else {
58-
throw PackageToJSError("Product type of \"\(productName)\" is not supported. Only executable products are supported.")
59-
}
60-
61-
let productArtifact = try build.findWasmArtifact(for: productName)
62-
let resourcesPaths = deriveResourcesPaths(
63-
productArtifactPath: productArtifact.path,
64-
sourceTargets: executableProduct.targets,
65-
package: context.package
66-
)
67-
68-
let outputDir = context.pluginWorkDirectory.appending(subpath: "Package")
69-
guard let selfPackage = findPackageInDependencies(package: context.package, id: "javascriptkit") else {
70-
throw PackageToJSError("Failed to find JavaScriptKit in dependencies!?")
71-
}
72-
var make = MiniMake(explain: options.explain)
73-
let allTask = constructBuild(make: &make, options: options, context: context, wasmProductArtifact: productArtifact, selfPackage: selfPackage, outputDir: outputDir)
74-
try make.build(output: allTask)
75-
print("Build finished")
133+
return (build, productName)
76134
}
77135

78136
/// Construct the build plan and return the root task key
79-
private func constructBuild(
137+
private func constructPackagingPlan(
80138
make: inout MiniMake,
81139
options: Options,
82140
context: PluginContext,
@@ -88,7 +146,8 @@ struct PackageToJS: CommandPlugin {
88146
let selfPath = String(#filePath)
89147
let outputDirTask = make.addTask(inputFiles: [selfPath], output: outputDir.string) {
90148
guard !FileManager.default.fileExists(atPath: $0.output) else { return }
91-
try FileManager.default.createDirectory(atPath: $0.output, withIntermediateDirectories: true, attributes: nil)
149+
try FileManager.default.createDirectory(
150+
atPath: $0.output, withIntermediateDirectories: true, attributes: nil)
92151
}
93152

94153
var packageInputs: [MiniMake.TaskKey] = []
@@ -118,25 +177,25 @@ struct PackageToJS: CommandPlugin {
118177
) {
119178
// Write package.json
120179
let packageJSON = """
121-
{
122-
"name": "\(options.packageName ?? context.package.id.lowercased())",
123-
"version": "0.0.0",
124-
"type": "module",
125-
"exports": {
126-
".": "./index.js",
127-
"./wasm": "./\(wasmFilename)"
128-
},
129-
"dependencies": {
130-
"@bjorn3/browser_wasi_shim": "^0.4.1"
180+
{
181+
"name": "\(options.packageName ?? context.package.id.lowercased())",
182+
"version": "0.0.0",
183+
"type": "module",
184+
"exports": {
185+
".": "./index.js",
186+
"./wasm": "./\(wasmFilename)"
187+
},
188+
"dependencies": {
189+
"@bjorn3/browser_wasi_shim": "^0.4.1"
190+
}
131191
}
132-
}
133-
"""
192+
"""
134193
try packageJSON.write(toFile: $0.output, atomically: true, encoding: .utf8)
135194
}
136195
packageInputs.append(packageJSON)
137196

138197
let substitutions = [
139-
"@PACKAGE_TO_JS_MODULE_PATH@": wasmFilename,
198+
"@PACKAGE_TO_JS_MODULE_PATH@": wasmFilename
140199
]
141200
for (file, output) in [
142201
("Plugins/PackageToJS/Templates/index.js", "index.js"),
@@ -161,68 +220,43 @@ struct PackageToJS: CommandPlugin {
161220
}
162221

163222
/// Derive default product from the package
223+
/// - Returns: The name of the product to build
224+
/// - Throws: `PackageToJSError` if there's no executable product or if there's more than one
164225
internal func deriveDefaultProduct(package: Package) throws -> String {
165-
let executableProducts = package.products(ofType: ExecutableProduct.self)
166-
guard !executableProducts.isEmpty else {
167-
throw PackageToJSError(
168-
"Make sure there's at least one executable product in your Package.swift")
169-
}
170-
guard executableProducts.count == 1 else {
171-
throw PackageToJSError(
172-
"Failed to disambiguate the product. Pass one of \(executableProducts.map(\.name).joined(separator: ", ")) to the --product option"
173-
)
174-
175-
}
176-
return executableProducts[0].name
177-
}
178-
179-
/// Returns the list of resource bundle paths for the given targets
180-
internal func deriveResourcesPaths(
181-
productArtifactPath: Path,
182-
sourceTargets: [any PackagePlugin.Target],
183-
package: Package
184-
) -> [Path] {
185-
return deriveResourcesPaths(
186-
buildDirectory: productArtifactPath.removingLastComponent(),
187-
sourceTargets: sourceTargets, package: package
188-
)
189-
}
226+
let executableProducts = package.products(ofType: ExecutableProduct.self)
227+
guard !executableProducts.isEmpty else {
228+
throw PackageToJSError(
229+
"Make sure there's at least one executable product in your Package.swift")
230+
}
231+
guard executableProducts.count == 1 else {
232+
throw PackageToJSError(
233+
"Failed to disambiguate the product. Pass one of \(executableProducts.map(\.name).joined(separator: ", ")) to the --product option"
234+
)
190235

191-
internal func deriveResourcesPaths(
192-
buildDirectory: Path,
193-
sourceTargets: [any PackagePlugin.Target],
194-
package: Package
195-
) -> [Path] {
196-
sourceTargets.compactMap { target -> Path? in
197-
// NOTE: The resource bundle file name is constructed from `displayName` instead of `id` for some reason
198-
// https://github.com/apple/swift-package-manager/blob/swift-5.9.2-RELEASE/Sources/PackageLoading/PackageBuilder.swift#L908
199-
let bundleName = package.displayName + "_" + target.name + ".resources"
200-
let resourcesPath = buildDirectory.appending(subpath: bundleName)
201-
guard FileManager.default.fileExists(atPath: resourcesPath.string) else { return nil }
202-
return resourcesPath
203-
}
236+
}
237+
return executableProducts[0].name
204238
}
205239

206-
207240
extension PackageManager.BuildResult {
208-
/// Find `.wasm` executable artifact
209-
internal func findWasmArtifact(for product: String) throws
210-
-> PackageManager.BuildResult.BuiltArtifact
211-
{
212-
let executables = self.builtArtifacts.filter {
213-
$0.kind == .executable && $0.path.lastComponent == "\(product).wasm"
214-
}
215-
guard !executables.isEmpty else {
216-
throw PackageToJSError(
217-
"Failed to find '\(product).wasm' from executable artifacts of product '\(product)'")
218-
}
219-
guard executables.count == 1, let executable = executables.first else {
220-
throw PackageToJSError(
221-
"Failed to disambiguate executable product artifacts from \(executables.map(\.path.string).joined(separator: ", "))"
222-
)
241+
/// Find `.wasm` executable artifact
242+
internal func findWasmArtifact(for product: String) throws
243+
-> PackageManager.BuildResult.BuiltArtifact
244+
{
245+
let executables = self.builtArtifacts.filter {
246+
$0.kind == .executable && $0.path.lastComponent == "\(product).wasm"
247+
}
248+
guard !executables.isEmpty else {
249+
throw PackageToJSError(
250+
"Failed to find '\(product).wasm' from executable artifacts of product '\(product)'"
251+
)
252+
}
253+
guard executables.count == 1, let executable = executables.first else {
254+
throw PackageToJSError(
255+
"Failed to disambiguate executable product artifacts from \(executables.map(\.path.string).joined(separator: ", "))"
256+
)
257+
}
258+
return executable
223259
}
224-
return executable
225-
}
226260
}
227261

228262
private func findPackageInDependencies(package: Package, id: Package.ID) -> Package? {
@@ -245,3 +279,11 @@ private func findPackageInDependencies(package: Package, id: Package.ID) -> Pack
245279
}
246280
return visit(package: package)
247281
}
282+
283+
private struct PackageToJSError: Swift.Error, CustomStringConvertible {
284+
let description: String
285+
286+
init(_ message: String) {
287+
self.description = "Error: " + message
288+
}
289+
}

0 commit comments

Comments
 (0)