Skip to content

Commit 452fd36

Browse files
BridgeJS: split snippet and external module import origins
Use `from: .snippet("/my-file.js")` for a JavaScript file shipped with the Swift target and `from: .module("node:path")` for an external module, so each keeps its own validation and each mistaken form points at the other. The skeleton encoding is unchanged.
1 parent 02b8bce commit 452fd36

18 files changed

Lines changed: 229 additions & 147 deletions

File tree

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -98,21 +98,31 @@ public final class SwiftToSkeleton {
9898
importCollector.importedFunctions.compactMap(\.from)
9999
+ importCollector.importedTypes.compactMap(\.from)
100100
+ importCollector.importedGlobalGetters.compactMap(\.from)
101-
// Only target-local module paths are validated here. Bare specifiers are
102-
// resolved by the JavaScript host (a bundler, an import map, or Node's
103-
// `node_modules` lookup), so there is nothing we can check without
104-
// rejecting setups that legitimately work.
105-
let modulePaths = Set(importOrigins.compactMap(\.localModulePath))
106-
for path in modulePaths.sorted() {
101+
// Only snippet paths are validated here. Bare module specifiers are resolved
102+
// by the JavaScript host (a bundler, an import map, or Node's `node_modules`
103+
// lookup), so there is nothing we can check without rejecting setups that
104+
// legitimately work.
105+
let snippetPaths = Set(importOrigins.compactMap(\.snippetPath))
106+
for path in snippetPaths.sorted() {
107107
if validatedJavaScriptModulePaths.contains(path) {
108108
continue
109109
}
110110
let pathNode = importCollector.importedModulePathNodes[path] ?? Syntax(sourceFile)
111+
guard path.hasPrefix("/") else {
112+
importCollector.errors.append(
113+
DiagnosticError(
114+
node: pathNode,
115+
message: "JavaScript snippet paths must start with '/' to indicate the Swift target root: "
116+
+ "'\(path)'. For an external module, use 'from: .module(\"\(path)\")' instead."
117+
)
118+
)
119+
continue
120+
}
111121
guard !path.split(separator: "/").contains("..") else {
112122
importCollector.errors.append(
113123
DiagnosticError(
114124
node: pathNode,
115-
message: "JavaScript module paths must not contain '..': '\(path)'."
125+
message: "JavaScript snippet paths must not contain '..': '\(path)'."
116126
)
117127
)
118128
continue
@@ -122,7 +132,7 @@ public final class SwiftToSkeleton {
122132
importCollector.errors.append(
123133
DiagnosticError(
124134
node: pathNode,
125-
message: "JavaScript modules must use a '.js' or '.mjs' extension: '\(path)'."
135+
message: "JavaScript snippets must use a '.js' or '.mjs' extension: '\(path)'."
126136
)
127137
)
128138
continue
@@ -131,7 +141,7 @@ public final class SwiftToSkeleton {
131141
importCollector.errors.append(
132142
DiagnosticError(
133143
node: pathNode,
134-
message: "JavaScript module file was not found at '\(path)'."
144+
message: "JavaScript snippet file was not found at '\(path)'."
135145
)
136146
)
137147
continue
@@ -2643,21 +2653,21 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
26432653
return
26442654
}
26452655
switch from {
2646-
case .module:
2656+
case .module, .snippet:
26472657
return
26482658
case .global:
26492659
errors.append(
26502660
DiagnosticError(
26512661
node: node,
2652-
message: "'jsName: .default' requires 'from: .module(...)'; "
2662+
message: "'jsName: .default' requires 'from: .module(...)' or 'from: .snippet(...)'; "
26532663
+ "globalThis has no default export."
26542664
)
26552665
)
26562666
case nil:
26572667
errors.append(
26582668
DiagnosticError(
26592669
node: node,
2660-
message: "'jsName: .default' requires 'from: .module(...)'."
2670+
message: "'jsName: .default' requires 'from: .module(...)' or 'from: .snippet(...)'."
26612671
)
26622672
)
26632673
}
@@ -2671,8 +2681,10 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
26712681
}
26722682

26732683
if let call = argument.expression.as(FunctionCallExprSyntax.self),
2674-
call.calledExpression.trimmedDescription.split(separator: ".").last == "module"
2684+
let caseName = call.calledExpression.trimmedDescription.split(separator: ".").last,
2685+
caseName == "module" || caseName == "snippet"
26752686
{
2687+
let isSnippet = caseName == "snippet"
26762688
guard call.arguments.count == 1,
26772689
let pathExpression = call.arguments.first?.expression,
26782690
let literal = pathExpression.as(StringLiteralExprSyntax.self),
@@ -2681,7 +2693,9 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
26812693
errors.append(
26822694
DiagnosticError(
26832695
node: call.arguments.first?.expression ?? argument.expression,
2684-
message: "JavaScript module path must be a string literal."
2696+
message: isSnippet
2697+
? "JavaScript snippet path must be a string literal."
2698+
: "JavaScript module specifier must be a string literal."
26852699
)
26862700
)
26872701
return nil
@@ -2690,7 +2704,28 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
26902704
errors.append(
26912705
DiagnosticError(
26922706
node: literal,
2693-
message: "JavaScript module specifier must not be empty."
2707+
message: isSnippet
2708+
? "JavaScript snippet path must not be empty."
2709+
: "JavaScript module specifier must not be empty."
2710+
)
2711+
)
2712+
return nil
2713+
}
2714+
if isSnippet {
2715+
// Full validation of the path happens in `finalize()`, where the file
2716+
// can also be checked for existence.
2717+
if importedModulePathNodes[path] == nil {
2718+
importedModulePathNodes[path] = Syntax(literal)
2719+
}
2720+
return .snippet(path)
2721+
}
2722+
guard !path.hasPrefix("/") else {
2723+
errors.append(
2724+
DiagnosticError(
2725+
node: literal,
2726+
message: "'\(path)' looks like a file in this target. "
2727+
+ "Use 'from: .snippet(\"\(path)\")' for a JavaScript file you ship with the target, "
2728+
+ "and 'from: .module(...)' for an external module (e.g. 'node:path')."
26942729
)
26952730
)
26962731
return nil
@@ -2700,15 +2735,12 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
27002735
DiagnosticError(
27012736
node: literal,
27022737
message: "Relative JavaScript module specifiers are not supported: '\(path)'. "
2703-
+ "Use a '/'-prefixed path for a file in this target (e.g. '/Modules/utils.mjs'), "
2738+
+ "Use 'from: .snippet(\"/path/to/file.js\")' for a file in this target, "
27042739
+ "or a bare specifier for an external module (e.g. 'node:path')."
27052740
)
27062741
)
27072742
return nil
27082743
}
2709-
if importedModulePathNodes[path] == nil {
2710-
importedModulePathNodes[path] = Syntax(literal)
2711-
}
27122744
return .module(path)
27132745
}
27142746

Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift

Lines changed: 37 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,19 @@ import Foundation
77
final class ImportedJSModuleRegistry {
88
/// A JavaScript module that imported declarations are read from.
99
///
10-
/// A `local` reference is a file inside a Swift target, which packaging copies
11-
/// into the generated output. A `bare` reference is a specifier resolved by the
12-
/// JavaScript host (a bundler, an import map, or Node's `node_modules` lookup),
13-
/// so it has no file and nothing to copy. Because a bare specifier names the
14-
/// same module no matter which Swift module mentions it — and ECMAScript caches
15-
/// module instances — it is keyed by specifier alone and shared across targets.
10+
/// A `snippet` reference is a file inside a Swift target, which packaging copies
11+
/// into the generated output. A `module` reference is a bare specifier resolved by
12+
/// the JavaScript host (a bundler, an import map, or Node's `node_modules` lookup),
13+
/// so it has no file and nothing to copy. Because a bare specifier names the same
14+
/// module no matter which Swift module mentions it — and ECMAScript caches module
15+
/// instances — it is keyed by specifier alone and shared across targets.
1616
enum Reference: Hashable {
17-
case local(swiftModuleName: String, path: String)
18-
case bare(specifier: String)
17+
case snippet(swiftModuleName: String, path: String)
18+
case module(specifier: String)
1919
}
2020

21-
/// A target-local JavaScript file that packaging must copy into the output.
22-
struct LocalModule: Hashable {
21+
/// A JavaScript file shipped in a Swift target that packaging must copy into the output.
22+
struct SnippetFile: Hashable {
2323
let swiftModuleName: String
2424
let path: String
2525

@@ -40,11 +40,11 @@ final class ImportedJSModuleRegistry {
4040
private var bindings: [Reference: Binding] = [:]
4141
private(set) var references: [Reference] = []
4242

43-
/// The target-local files packaging must copy, in deterministic order.
44-
var localModules: [LocalModule] {
43+
/// The snippet files packaging must copy, in deterministic order.
44+
var snippetFiles: [SnippetFile] {
4545
references.compactMap { reference in
46-
guard case .local(let swiftModuleName, let path) = reference else { return nil }
47-
return LocalModule(swiftModuleName: swiftModuleName, path: path)
46+
guard case .snippet(let swiftModuleName, let path) = reference else { return nil }
47+
return SnippetFile(swiftModuleName: swiftModuleName, path: path)
4848
}
4949
}
5050

@@ -82,18 +82,18 @@ final class ImportedJSModuleRegistry {
8282
return references.sorted(by: isOrderedBefore)
8383
}
8484

85-
static func collectLocalModules(skeletons: [BridgeJSSkeleton]) -> [LocalModule] {
85+
static func collectSnippetFiles(skeletons: [BridgeJSSkeleton]) -> [SnippetFile] {
8686
collectReferences(skeletons: skeletons).compactMap { reference in
87-
guard case .local(let swiftModuleName, let path) = reference else { return nil }
88-
return LocalModule(swiftModuleName: swiftModuleName, path: path)
87+
guard case .snippet(let swiftModuleName, let path) = reference else { return nil }
88+
return SnippetFile(swiftModuleName: swiftModuleName, path: path)
8989
}
9090
}
9191

9292
/// Visits every module origin mentioned by the skeleton, whether or not code
9393
/// generation looks a member up on it.
9494
///
95-
/// This is what decides which modules are imported at all, and for local paths
96-
/// which files packaging copies. It stays broader than `forEachMemberLookup` so
95+
/// This is what decides which modules are imported at all, and for snippets which
96+
/// files packaging copies. It stays broader than `forEachMemberLookup` so
9797
/// that a module mentioned only by a wrapper-only `@JSClass` is still imported,
9898
/// preserving its side effects.
9999
private static func forEachOrigin(
@@ -146,22 +146,25 @@ final class ImportedJSModuleRegistry {
146146
}
147147

148148
private static func reference(swiftModuleName: String, from: JSImportFrom?) -> Reference? {
149-
guard let specifier = from?.moduleSpecifier else { return nil }
150-
if let path = from?.localModulePath {
151-
return .local(swiftModuleName: swiftModuleName, path: path)
149+
switch from {
150+
case .snippet(let path):
151+
return .snippet(swiftModuleName: swiftModuleName, path: path)
152+
case .module(let specifier):
153+
return .module(specifier: specifier)
154+
case .global, nil:
155+
return nil
152156
}
153-
return .bare(specifier: specifier)
154157
}
155158

156159
private static func isOrderedBefore(_ lhs: Reference, _ rhs: Reference) -> Bool {
157160
switch (lhs, rhs) {
158-
case (.local(let lhsModule, let lhsPath), .local(let rhsModule, let rhsPath)):
161+
case (.snippet(let lhsModule, let lhsPath), .snippet(let rhsModule, let rhsPath)):
159162
return (lhsModule, lhsPath) < (rhsModule, rhsPath)
160-
case (.bare(let lhsSpecifier), .bare(let rhsSpecifier)):
163+
case (.module(let lhsSpecifier), .module(let rhsSpecifier)):
161164
return lhsSpecifier < rhsSpecifier
162-
case (.local, .bare):
165+
case (.snippet, .module):
163166
return true
164-
case (.bare, .local):
167+
case (.module, .snippet):
165168
return false
166169
}
167170
}
@@ -185,12 +188,13 @@ final class ImportedJSModuleRegistry {
185188
objectExpr: "globalThis",
186189
propertyName: memberName
187190
)
188-
case .module(let specifier):
191+
case .snippet, .module:
189192
guard let reference = Self.reference(swiftModuleName: swiftModuleName, from: from),
190193
let binding = bindings[reference]
191194
else {
192195
throw BridgeJSLinkError(
193-
message: "Missing JavaScript module \(swiftModuleName)\(specifier)"
196+
message:
197+
"Missing JavaScript module \(swiftModuleName)\(from?.snippetPath ?? from?.moduleSpecifier ?? "")"
194198
)
195199
}
196200
if binding.usesNamedImports {
@@ -216,11 +220,11 @@ final class ImportedJSModuleRegistry {
216220
guard let binding = bindings[reference] else { return nil }
217221
let specifier: String
218222
switch reference {
219-
case .local(let swiftModuleName, let path):
220-
let output = LocalModule(swiftModuleName: swiftModuleName, path: path).relativeOutputPath
223+
case .snippet(let swiftModuleName, let path):
224+
let output = SnippetFile(swiftModuleName: swiftModuleName, path: path).relativeOutputPath
221225
specifier = "./" + BridgeJSLink.escapeForJavaScriptStringLiteral(output)
222-
case .bare(let bareSpecifier):
223-
specifier = BridgeJSLink.escapeForJavaScriptStringLiteral(bareSpecifier)
226+
case .module(let moduleSpecifier):
227+
specifier = BridgeJSLink.escapeForJavaScriptStringLiteral(moduleSpecifier)
224228
}
225229
guard binding.usesNamedImports else {
226230
return "import * as \(Self.namespaceAlias(index: binding.index)) from \"\(specifier)\";"

0 commit comments

Comments
 (0)