diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift index 2d2a3ab6f..d327de307 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift @@ -98,8 +98,12 @@ public final class SwiftToSkeleton { importCollector.importedFunctions.compactMap(\.from) + importCollector.importedTypes.compactMap(\.from) + importCollector.importedGlobalGetters.compactMap(\.from) - let modulePaths = Set(importOrigins.compactMap(\.modulePath)) - for path in modulePaths.sorted() { + // Only snippet paths are validated here. Bare module specifiers are resolved + // by the JavaScript host (a bundler, an import map, or Node's `node_modules` + // lookup), so there is nothing we can check without rejecting setups that + // legitimately work. + let snippetPaths = Set(importOrigins.compactMap(\.snippetPath)) + for path in snippetPaths.sorted() { if validatedJavaScriptModulePaths.contains(path) { continue } @@ -108,8 +112,8 @@ public final class SwiftToSkeleton { importCollector.errors.append( DiagnosticError( node: pathNode, - message: "JavaScript module paths must start with '/' to indicate the Swift target root: " - + "'\(path)'." + message: "JavaScript snippet paths must start with '/' to indicate the Swift target root: " + + "'\(path)'. For an external module, use 'from: .module(\"\(path)\")' instead." ) ) continue @@ -118,7 +122,7 @@ public final class SwiftToSkeleton { importCollector.errors.append( DiagnosticError( node: pathNode, - message: "JavaScript module paths must not contain '..': '\(path)'." + message: "JavaScript snippet paths must not contain '..': '\(path)'." ) ) continue @@ -128,7 +132,7 @@ public final class SwiftToSkeleton { importCollector.errors.append( DiagnosticError( node: pathNode, - message: "JavaScript modules must use a '.js' or '.mjs' extension: '\(path)'." + message: "JavaScript snippets must use a '.js' or '.mjs' extension: '\(path)'." ) ) continue @@ -137,7 +141,7 @@ public final class SwiftToSkeleton { importCollector.errors.append( DiagnosticError( node: pathNode, - message: "JavaScript module file was not found at '\(path)'." + message: "JavaScript snippet file was not found at '\(path)'." ) ) continue @@ -2548,20 +2552,124 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { } } - /// Extracts the `jsName` argument value from an attribute, if present. - static func extractJSName(from attribute: AttributeSyntax) -> String? { - guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else { + } + + /// The result of reading a `jsName:` argument. + struct ExtractedJSName { + /// The JavaScript member name to look up. + /// + /// `.default` normalizes to `"default"`: in ECMAScript a module's default + /// export *is* its `default` named export, so no separate representation + /// is needed downstream. + let memberName: String + /// True when the source spelled `.default` rather than a string literal. + let isDefaultExportSpelling: Bool + } + + /// Extracts the `jsName` argument value from an attribute, if present. + private func extractJSName(from attribute: AttributeSyntax) -> ExtractedJSName? { + guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self), + let argument = arguments.first(where: { $0.label?.text == "jsName" }) + else { + return nil + } + + if let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self), + let value = stringLiteral.representedLiteralValue + { + return ExtractedJSName(memberName: value, isDefaultExportSpelling: false) + } + + // An explicit `jsName: nil` means the same as omitting the argument. + if argument.expression.is(NilLiteralExprSyntax.self) { + return nil + } + + // Accept the explicit `.name("...")` spelling of a plain member name. + if let call = argument.expression.as(FunctionCallExprSyntax.self), + call.calledExpression.trimmedDescription.split(separator: ".").last == "name" + { + guard call.arguments.count == 1, + let literal = call.arguments.first?.expression.as(StringLiteralExprSyntax.self), + let value = literal.representedLiteralValue + else { + errors.append( + DiagnosticError( + node: call.arguments.first?.expression ?? argument.expression, + message: "jsName must be a string literal or '.default'." + ) + ) return nil } - for argument in arguments { - if argument.label?.text == "jsName", - let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self), - let segment = stringLiteral.segments.first?.as(StringSegmentSyntax.self) - { - return segment.content.text - } - } - return nil + return ExtractedJSName(memberName: value, isDefaultExportSpelling: false) + } + + // Accept `.default`, `JSName.default`, and the backticked spellings. + let description = argument.expression.trimmedDescription + let caseName = description.split(separator: ".").last.map(String.init) ?? description + if caseName == "default" || caseName == "`default`" { + return ExtractedJSName(memberName: "default", isDefaultExportSpelling: true) + } + + errors.append( + DiagnosticError( + node: argument.expression, + message: "jsName must be a string literal or '.default'." + ) + ) + return nil + } + + /// Validates that a `jsName: .default` spelling appears somewhere it can mean something. + /// + /// `.default` names the default export of an ECMAScript module, so it only makes + /// sense on a top-level declaration that has a `from: .module(...)` origin. + private func validateDefaultExportUsage( + _ extracted: ExtractedJSName?, + from: JSImportFrom?, + node: some SyntaxProtocol, + isSetter: Bool = false + ) { + guard let extracted, extracted.isDefaultExportSpelling else { return } + + if isSetter { + errors.append( + DiagnosticError( + node: node, + message: "'jsName: .default' is not supported on @JSSetter; " + + "ECMAScript module bindings are read-only." + ) + ) + return + } + if case .jsClassBody = state { + errors.append( + DiagnosticError( + node: node, + message: "'jsName: .default' is not supported on a class member; " + + "members have no module origin. Did you mean jsName: \"default\"?" + ) + ) + return + } + switch from { + case .module, .snippet: + return + case .global: + errors.append( + DiagnosticError( + node: node, + message: "'jsName: .default' requires 'from: .module(...)' or 'from: .snippet(...)'; " + + "globalThis has no default export." + ) + ) + case nil: + errors.append( + DiagnosticError( + node: node, + message: "'jsName: .default' requires 'from: .module(...)' or 'from: .snippet(...)'." + ) + ) } } @@ -2573,8 +2681,10 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { } if let call = argument.expression.as(FunctionCallExprSyntax.self), - call.calledExpression.trimmedDescription.split(separator: ".").last == "module" + let caseName = call.calledExpression.trimmedDescription.split(separator: ".").last, + caseName == "module" || caseName == "snippet" { + let isSnippet = caseName == "snippet" guard call.arguments.count == 1, let pathExpression = call.arguments.first?.expression, let literal = pathExpression.as(StringLiteralExprSyntax.self), @@ -2583,13 +2693,53 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { errors.append( DiagnosticError( node: call.arguments.first?.expression ?? argument.expression, - message: "JavaScript module path must be a string literal." + message: isSnippet + ? "JavaScript snippet path must be a string literal." + : "JavaScript module specifier must be a string literal." + ) + ) + return nil + } + guard !path.isEmpty else { + errors.append( + DiagnosticError( + node: literal, + message: isSnippet + ? "JavaScript snippet path must not be empty." + : "JavaScript module specifier must not be empty." ) ) return nil } - if importedModulePathNodes[path] == nil { - importedModulePathNodes[path] = Syntax(literal) + if isSnippet { + // Full validation of the path happens in `finalize()`, where the file + // can also be checked for existence. + if importedModulePathNodes[path] == nil { + importedModulePathNodes[path] = Syntax(literal) + } + return .snippet(path) + } + guard !path.hasPrefix("/") else { + errors.append( + DiagnosticError( + node: literal, + message: "'\(path)' looks like a file in this target. " + + "Use 'from: .snippet(\"\(path)\")' for a JavaScript file you ship with the target, " + + "and 'from: .module(...)' for an external module (e.g. 'node:path')." + ) + ) + return nil + } + guard !path.hasPrefix("./"), !path.hasPrefix("../"), path != ".", path != ".." else { + errors.append( + DiagnosticError( + node: literal, + message: "Relative JavaScript module specifiers are not supported: '\(path)'. " + + "Use 'from: .snippet(\"/path/to/file.js\")' for a file in this target, " + + "or a bare specifier for an external module (e.g. 'node:path')." + ) + ) + return nil } return .module(path) } @@ -2645,7 +2795,9 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { return nil } - let jsName = AttributeChecker.extractJSName(from: jsSetter) + let extractedJSName = extractJSName(from: jsSetter) + validateDefaultExportUsage(extractedJSName, from: nil, node: node, isSetter: true) + let jsName = extractedJSName?.memberName let parameters = node.signature.parameterClause.parameters guard let firstParam = parameters.first else { @@ -2779,10 +2931,16 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { if AttributeChecker.hasJSClassAttribute(node.attributes) { let attribute = AttributeChecker.firstJSClassAttribute(node.attributes) - let jsName = attribute.flatMap(AttributeChecker.extractJSName) + let extractedJSName = attribute.flatMap { extractJSName(from: $0) } let from = attribute.flatMap { extractJSImportFrom(from: $0) } + validateDefaultExportUsage(extractedJSName, from: from, node: node) let accessLevel = Self.bridgeAccessLevel(from: node.modifiers) - enterJSClass(node.name.text, jsName: jsName, from: from, accessLevel: accessLevel) + enterJSClass( + node.name.text, + jsName: extractedJSName?.memberName, + from: from, + accessLevel: accessLevel + ) } return .visitChildren } @@ -2796,10 +2954,16 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { if AttributeChecker.hasJSClassAttribute(node.attributes) { let attribute = AttributeChecker.firstJSClassAttribute(node.attributes) - let jsName = attribute.flatMap(AttributeChecker.extractJSName) + let extractedJSName = attribute.flatMap { extractJSName(from: $0) } let from = attribute.flatMap { extractJSImportFrom(from: $0) } + validateDefaultExportUsage(extractedJSName, from: from, node: node) let accessLevel = Self.bridgeAccessLevel(from: node.modifiers) - enterJSClass(node.name.text, jsName: jsName, from: from, accessLevel: accessLevel) + enterJSClass( + node.name.text, + jsName: extractedJSName?.memberName, + from: from, + accessLevel: accessLevel + ) } return .visitChildren } @@ -2990,8 +3154,10 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { } let baseName = SwiftToSkeleton.normalizeIdentifier(node.name.text) - let jsName = AttributeChecker.extractJSName(from: jsFunction) + let extractedJSName = extractJSName(from: jsFunction) let from = extractJSImportFrom(from: jsFunction) + validateDefaultExportUsage(extractedJSName, from: from, node: node) + let jsName = extractedJSName?.memberName let name = baseName let parameters = parseParameters(from: node.signature.parameterClause) @@ -3043,12 +3209,13 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { return nil } let propertyName = SwiftToSkeleton.normalizeIdentifier(identifier.identifier.text) - let jsName = AttributeChecker.extractJSName(from: jsGetter) + let extractedJSName = extractJSName(from: jsGetter) let from = extractJSImportFrom(from: jsGetter) + validateDefaultExportUsage(extractedJSName, from: from, node: node) let accessLevel = Self.bridgeAccessLevel(from: node.modifiers) return ImportedGetterSkeleton( name: propertyName, - jsName: jsName, + jsName: extractedJSName?.memberName, from: from, type: propertyType, documentation: nil, diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift index d8a1ac780..1b6300595 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift @@ -2515,7 +2515,13 @@ extension BridgeJSLink { } func callConstructor(jsName: String, swiftTypeName: String, fromObjectExpr: String) throws { - let ctorExpr = Self.propertyAccessExpr(objectExpr: fromObjectExpr, propertyName: jsName) + try callConstructor( + ctorExpr: Self.propertyAccessExpr(objectExpr: fromObjectExpr, propertyName: jsName), + swiftTypeName: swiftTypeName + ) + } + + func callConstructor(ctorExpr: String, swiftTypeName: String) throws { let call = "new \(ctorExpr)(\(parameterForwardings.joined(separator: ", ")))" let type: BridgeType = .jsObject(swiftTypeName) let loweringFragment = try IntrinsicJSFragment.lowerReturn(type: type, context: context) @@ -2580,12 +2586,18 @@ extension BridgeJSLink { } func getImportProperty(name: String, fromObjectExpr: String, returnType: BridgeType) throws { + try getImportProperty( + accessExpr: Self.propertyAccessExpr(objectExpr: fromObjectExpr, propertyName: name), + returnType: returnType + ) + } + + func getImportProperty(accessExpr expr: String, returnType: BridgeType) throws { if returnType == .void { throw BridgeJSLinkError(message: "Void is not supported for imported JS properties") } let loweringFragment = try IntrinsicJSFragment.lowerReturn(type: returnType, context: context) - let expr = Self.propertyAccessExpr(objectExpr: fromObjectExpr, propertyName: name) let returnExpr: String? if loweringFragment.parameters.count == 0 { @@ -2623,8 +2635,7 @@ extension BridgeJSLink { } static func propertyAccessExpr(objectExpr: String, propertyName: String) -> String { - if propertyName.range(of: #"^[$A-Z_][0-9A-Z_$]*$"#, options: [.regularExpression, .caseInsensitive]) != nil - { + if ImportedJSModuleRegistry.isValidJSIdentifier(propertyName) { return "\(objectExpr).\(propertyName)" } let escapedName = BridgeJSLink.escapeForJavaScriptStringLiteral(propertyName) @@ -3469,12 +3480,13 @@ extension BridgeJSLink { try thunkBuilder.liftParameter(param: param) } let jsName = function.jsName ?? function.name - let importRootExpr = try importedModuleRegistry.namespaceExpression( + let calleeExpr = try importedModuleRegistry.memberExpression( swiftModuleName: importObjectBuilder.moduleName, - from: function.from + from: function.from, + memberName: jsName ) - try thunkBuilder.call(name: jsName, fromObjectExpr: importRootExpr) + try thunkBuilder.call(calleeExpr: calleeExpr) let funcLines = thunkBuilder.renderFunction(name: function.abiName(context: nil)) if function.from == nil { importObjectBuilder.appendDts( @@ -3496,13 +3508,13 @@ extension BridgeJSLink { intrinsicRegistry: intrinsicRegistry ) let jsName = getter.jsName ?? getter.name - let importRootExpr = try importedModuleRegistry.namespaceExpression( + let accessExpr = try importedModuleRegistry.memberExpression( swiftModuleName: importObjectBuilder.moduleName, - from: getter.from + from: getter.from, + memberName: jsName ) try thunkBuilder.getImportProperty( - name: jsName, - fromObjectExpr: importRootExpr, + accessExpr: accessExpr, returnType: getter.type ) let abiName = getter.abiName(context: nil) @@ -3602,14 +3614,14 @@ extension BridgeJSLink { for param in constructor.parameters { try thunkBuilder.liftParameter(param: param) } - let importRootExpr = try importedModuleRegistry.namespaceExpression( + let ctorExpr = try importedModuleRegistry.memberExpression( swiftModuleName: importObjectBuilder.moduleName, - from: type.from + from: type.from, + memberName: type.jsName ?? type.name ) try thunkBuilder.callConstructor( - jsName: type.jsName ?? type.name, - swiftTypeName: type.name, - fromObjectExpr: importRootExpr + ctorExpr: ctorExpr, + swiftTypeName: type.name ) let abiName = constructor.abiName(context: type) let funcLines = thunkBuilder.renderFunction(name: abiName) @@ -3661,13 +3673,10 @@ extension BridgeJSLink { for param in method.parameters { try thunkBuilder.liftParameter(param: param) } - let importRootExpr = try importedModuleRegistry.namespaceExpression( + let constructorExpr = try importedModuleRegistry.memberExpression( swiftModuleName: swiftModuleName, - from: context.from - ) - let constructorExpr = ImportedThunkBuilder.propertyAccessExpr( - objectExpr: importRootExpr, - propertyName: context.jsName ?? context.name + from: context.from, + memberName: context.jsName ?? context.name ) try thunkBuilder.callStaticMethod(on: constructorExpr, name: method.jsName ?? method.name) diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift index 2c5716030..96efcd1d4 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift @@ -2,8 +2,24 @@ import BridgeJSSkeleton #endif +import Foundation + final class ImportedJSModuleRegistry { - struct Reference: Hashable { + /// A JavaScript module that imported declarations are read from. + /// + /// A `snippet` reference is a file inside a Swift target, which packaging copies + /// into the generated output. A `module` reference is a bare specifier resolved by + /// the JavaScript host (a bundler, an import map, or Node's `node_modules` lookup), + /// so it has no file and nothing to copy. Because a bare specifier names the same + /// module no matter which Swift module mentions it — and ECMAScript caches module + /// instances — it is keyed by specifier alone and shared across targets. + enum Reference: Hashable { + case snippet(swiftModuleName: String, path: String) + case module(specifier: String) + } + + /// A JavaScript file shipped in a Swift target that packaging must copy into the output. + struct SnippetFile: Hashable { let swiftModuleName: String let path: String @@ -12,56 +28,211 @@ final class ImportedJSModuleRegistry { } } - private var aliases: [Reference: String] = [:] + private struct Binding { + let index: Int + /// Member names looked up on this module, sorted for stable output. + let members: [String] + /// Whether every member name is a valid JavaScript identifier, and so can be + /// reached with a named import instead of a namespace property lookup. + let usesNamedImports: Bool + } + + private var bindings: [Reference: Binding] = [:] private(set) var references: [Reference] = [] + /// The snippet files packaging must copy, in deterministic order. + var snippetFiles: [SnippetFile] { + references.compactMap { reference in + guard case .snippet(let swiftModuleName, let path) = reference else { return nil } + return SnippetFile(swiftModuleName: swiftModuleName, path: path) + } + } + func configure(skeletons: [BridgeJSSkeleton]) { - aliases.removeAll(keepingCapacity: true) + bindings.removeAll(keepingCapacity: true) references = Self.collectReferences(skeletons: skeletons) + + var membersByReference: [Reference: Set] = [:] + for skeleton in skeletons { + Self.forEachMemberLookup(skeleton: skeleton) { reference, memberName in + membersByReference[reference, default: []].insert(memberName) + } + } + for (index, reference) in references.enumerated() { - aliases[reference] = "__bjs_imported_module_\(index)" + let members = (membersByReference[reference] ?? []).sorted() + // A reference with no member lookups keeps the namespace form: a named import + // is a hard link-time requirement, so importing a name nothing references + // would fail the whole module load if the module does not export it. + bindings[reference] = Binding( + index: index, + members: members, + usesNamedImports: !members.isEmpty && members.allSatisfy(Self.isValidJSIdentifier) + ) } } static func collectReferences(skeletons: [BridgeJSSkeleton]) -> [Reference] { var references = Set() for skeleton in skeletons { - for file in skeleton.imported?.children ?? [] { - let origins = - file.functions.compactMap(\.from) - + file.globalGetters.compactMap(\.from) - + file.types.compactMap(\.from) - for case .module(let path) in origins { - references.insert(Reference(swiftModuleName: skeleton.moduleName, path: path)) - } + forEachOrigin(skeleton: skeleton) { reference in + references.insert(reference) } } - return references.sorted { - ($0.swiftModuleName, $0.path) < ($1.swiftModuleName, $1.path) + return references.sorted(by: isOrderedBefore) + } + + static func collectSnippetFiles(skeletons: [BridgeJSSkeleton]) -> [SnippetFile] { + collectReferences(skeletons: skeletons).compactMap { reference in + guard case .snippet(let swiftModuleName, let path) = reference else { return nil } + return SnippetFile(swiftModuleName: swiftModuleName, path: path) } } - func namespaceExpression(swiftModuleName: String, from: JSImportFrom?) throws -> String { + /// Visits every module origin mentioned by the skeleton, whether or not code + /// generation looks a member up on it. + /// + /// This is what decides which modules are imported at all, and for snippets which + /// files packaging copies. It stays broader than `forEachMemberLookup` so + /// that a module mentioned only by a wrapper-only `@JSClass` is still imported, + /// preserving its side effects. + private static func forEachOrigin( + skeleton: BridgeJSSkeleton, + _ body: (Reference) -> Void + ) { + func visit(from: JSImportFrom?) { + guard let reference = Self.reference(swiftModuleName: skeleton.moduleName, from: from) else { return } + body(reference) + } + for file in skeleton.imported?.children ?? [] { + for function in file.functions { visit(from: function.from) } + for getter in file.globalGetters { visit(from: getter.from) } + for type in file.types { visit(from: type.from) } + } + } + + /// Visits every module member lookup that code generation will emit. + /// + /// The member name taken here must match what the corresponding emitter in + /// `BridgeJSLink` looks up, and must not include names it never emits: a named + /// import is a hard link-time requirement, so recording a member that no + /// generated code references would make the module fail to load whenever the + /// module does not happen to export that name. + /// + /// A class contributes a single binding that serves both its constructor and its + /// static methods, and only when it has one of those. Instance methods, getters, + /// and setters contribute nothing because they go through an already-constructed + /// instance, so a wrapper-only `@JSClass` needs no export from the module at all. + private static func forEachMemberLookup( + skeleton: BridgeJSSkeleton, + _ body: (Reference, String) -> Void + ) { + func visit(from: JSImportFrom?, memberName: String) { + guard let reference = Self.reference(swiftModuleName: skeleton.moduleName, from: from) else { return } + body(reference, memberName) + } + for file in skeleton.imported?.children ?? [] { + for function in file.functions { + visit(from: function.from, memberName: function.jsName ?? function.name) + } + for getter in file.globalGetters { + visit(from: getter.from, memberName: getter.jsName ?? getter.name) + } + for type in file.types { + guard type.constructor != nil || !type.staticMethods.isEmpty else { continue } + visit(from: type.from, memberName: type.jsName ?? type.name) + } + } + } + + private static func reference(swiftModuleName: String, from: JSImportFrom?) -> Reference? { + switch from { + case .snippet(let path): + return .snippet(swiftModuleName: swiftModuleName, path: path) + case .module(let specifier): + return .module(specifier: specifier) + case .global, nil: + return nil + } + } + + private static func isOrderedBefore(_ lhs: Reference, _ rhs: Reference) -> Bool { + switch (lhs, rhs) { + case (.snippet(let lhsModule, let lhsPath), .snippet(let rhsModule, let rhsPath)): + return (lhsModule, lhsPath) < (rhsModule, rhsPath) + case (.module(let lhsSpecifier), .module(let rhsSpecifier)): + return lhsSpecifier < rhsSpecifier + case (.snippet, .module): + return true + case (.module, .snippet): + return false + } + } + + /// Whether `name` can appear as a bare identifier in generated JavaScript. + static func isValidJSIdentifier(_ name: String) -> Bool { + name.range(of: #"^[$A-Z_][0-9A-Z_$]*$"#, options: [.regularExpression, .caseInsensitive]) != nil + } + + /// Returns the JavaScript expression that evaluates to `memberName` of the given origin. + func memberExpression( + swiftModuleName: String, + from: JSImportFrom?, + memberName: String + ) throws -> String { switch from { case nil: - return "imports" + return BridgeJSLink.ImportedThunkBuilder.propertyAccessExpr(objectExpr: "imports", propertyName: memberName) case .global: - return "globalThis" - case .module(let path): - let reference = Reference(swiftModuleName: swiftModuleName, path: path) - guard let alias = aliases[reference] else { + return BridgeJSLink.ImportedThunkBuilder.propertyAccessExpr( + objectExpr: "globalThis", + propertyName: memberName + ) + case .snippet, .module: + guard let reference = Self.reference(swiftModuleName: swiftModuleName, from: from), + let binding = bindings[reference] + else { throw BridgeJSLinkError( - message: "Missing JavaScript module \(swiftModuleName)\(path)" + message: + "Missing JavaScript module \(swiftModuleName)\(from?.snippetPath ?? from?.moduleSpecifier ?? "")" ) } - return alias + if binding.usesNamedImports { + return Self.namedImportBinding(index: binding.index, memberName: memberName) + } + return BridgeJSLink.ImportedThunkBuilder.propertyAccessExpr( + objectExpr: Self.namespaceAlias(index: binding.index), + propertyName: memberName + ) } } + private static func namespaceAlias(index: Int) -> String { + "__bjs_imported_module_\(index)" + } + + private static func namedImportBinding(index: Int, memberName: String) -> String { + "__bjs_import_\(index)_\(memberName)" + } + var importLines: [String] { - references.enumerated().map { index, reference in - let path = BridgeJSLink.escapeForJavaScriptStringLiteral(reference.relativeOutputPath) - return "import * as __bjs_imported_module_\(index) from \"./\(path)\";" + references.compactMap { reference in + guard let binding = bindings[reference] else { return nil } + let specifier: String + switch reference { + case .snippet(let swiftModuleName, let path): + let output = SnippetFile(swiftModuleName: swiftModuleName, path: path).relativeOutputPath + specifier = "./" + BridgeJSLink.escapeForJavaScriptStringLiteral(output) + case .module(let moduleSpecifier): + specifier = BridgeJSLink.escapeForJavaScriptStringLiteral(moduleSpecifier) + } + guard binding.usesNamedImports else { + return "import * as \(Self.namespaceAlias(index: binding.index)) from \"\(specifier)\";" + } + let clauses = binding.members.map { + "\($0) as \(Self.namedImportBinding(index: binding.index, memberName: $0))" + } + return "import { \(clauses.joined(separator: ", ")) } from \"\(specifier)\";" } } } diff --git a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift index c70ccdd8b..234edfbe8 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift @@ -1126,35 +1126,93 @@ private struct AsyncClosureReturnTypeCollector: BridgeSkeletonVisitor { /// Controls where BridgeJS reads imported JS values from. /// /// - `global`: Read from `globalThis`. -/// - `module`: Read from a target-local ECMAScript module. +/// - `snippet`: Read from a `/`-rooted JavaScript file inside the Swift target, +/// which packaging copies into the generated output. +/// - `module`: Read from an external ECMAScript module named by a bare specifier +/// that the JavaScript host resolves (e.g. `node:path`, `lodash/fp`). +/// +/// The JSON encoding keeps `.global` as `"global"` and a snippet as its plain path +/// string; only `.module` uses a tagged object. That keeps checked-in skeletons +/// stable and avoids colliding with the `"global"` sentinel, since `global` is +/// itself a valid package name. public enum JSImportFrom: Codable, Equatable, Sendable { case global + case snippet(String) case module(String) + private enum CodingKeys: String, CodingKey { + case kind, specifier + } + public init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - let value = try container.decode(String.self) - if value == "global" { - self = .global - } else if value.hasPrefix("/") && !value.split(separator: "/").contains("..") { - self = .module(value) - } else { + if let container = try? decoder.singleValueContainer(), let value = try? container.decode(String.self) { + if value == "global" { + self = .global + return + } + guard value.hasPrefix("/"), !value.split(separator: "/").contains("..") else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unknown import origin '\(value)'. Expected \"global\" or a rooted snippet path." + ) + } + self = .snippet(value) + return + } + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + guard kind == "module" else { throw DecodingError.dataCorruptedError( + forKey: .kind, in: container, - debugDescription: "Unknown import origin '\(value)'. Expected \"global\" or a rooted module path." + debugDescription: "Unknown import origin kind '\(kind)'. Expected \"module\"." ) } + let specifier = try container.decode(String.self, forKey: .specifier) + // A bare specifier is resolved by the JavaScript host, so almost anything is + // legal, but the shapes that can never work are rejected here as well as at + // parse time: an empty specifier, a relative one, and a rooted path (which is + // a snippet and must be encoded as a plain string instead). + guard !specifier.isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: .specifier, + in: container, + debugDescription: "Module specifier must not be empty." + ) + } + guard !specifier.hasPrefix("."), !specifier.hasPrefix("/") else { + throw DecodingError.dataCorruptedError( + forKey: .specifier, + in: container, + debugDescription: + "Module specifier '\(specifier)' must not be a path. Rooted snippet paths are encoded as a plain string." + ) + } + self = .module(specifier) } public func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(modulePath ?? "global") + guard let specifier = moduleSpecifier else { + var container = encoder.singleValueContainer() + try container.encode(snippetPath ?? "global") + return + } + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("module", forKey: .kind) + try container.encode(specifier, forKey: .specifier) } - public var modulePath: String? { - guard case .module(let path) = self else { return nil } + /// The path of a target-local JavaScript file, rooted at the Swift target directory. + public var snippetPath: String? { + guard case .snippet(let path) = self else { return nil } return path } + + /// The bare specifier of an external ECMAScript module, resolved by the JavaScript host. + public var moduleSpecifier: String? { + guard case .module(let specifier) = self else { return nil } + return specifier + } } public struct ImportedFunctionSkeleton: Codable { diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift index 263d796a6..1f42d05de 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift @@ -18,10 +18,10 @@ import Testing func javaScriptModuleReferencesAreStoredWithoutSourceContents() throws { let modulePath = "/Modules/math.mjs" let swiftSource = """ - @JSFunction(from: .module("/Modules/math.mjs")) + @JSFunction(from: .snippet("/Modules/math.mjs")) func add(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int - @JSGetter(jsName: "version", from: .module("/Modules/math.mjs")) + @JSGetter(jsName: "version", from: .snippet("/Modules/math.mjs")) var moduleVersion: String """ var validationCount = 0 @@ -41,7 +41,7 @@ import Testing let imported = try #require(skeleton.imported) #expect(validationCount == 1) - #expect(imported.children.flatMap(\.functions).first?.from == .module(modulePath)) + #expect(imported.children.flatMap(\.functions).first?.from == .snippet(modulePath)) #expect(!encoded.contains(#""modules""#)) } @@ -52,6 +52,55 @@ import Testing } } + @Test(arguments: [ + JSImportFrom.global, + JSImportFrom.snippet("/Modules/utils.mjs"), + JSImportFrom.module("node:path"), + JSImportFrom.module("@scope/package/sub"), + // A package literally named "global" is why bare specifiers are encoded as a + // tagged object rather than a plain string: a plain string would be + // indistinguishable from the `.global` sentinel. + JSImportFrom.module("global"), + JSImportFrom.module("#internal"), + JSImportFrom.module("https://esm.sh/lodash@4"), + ]) + func jsImportFromRoundTrips(origin: JSImportFrom) throws { + let encoded = try JSONEncoder().encode(origin) + #expect(try JSONDecoder().decode(JSImportFrom.self, from: encoded) == origin) + } + + @Test + func legacyStringFormIsPreservedForGlobalAndLocalPaths() throws { + let encoder = JSONEncoder() + #expect(String(data: try encoder.encode(JSImportFrom.global), encoding: .utf8) == #""global""#) + #expect( + String(data: try encoder.encode(JSImportFrom.snippet("/a.js")), encoding: .utf8) == #""\/a.js""# + ) + } + + @Test + func unknownJSImportFromKindFailsToDecode() { + #expect(throws: DecodingError.self) { + try JSONDecoder().decode( + JSImportFrom.self, + from: Data(#"{"kind": "somethingElse", "specifier": "x"}"#.utf8) + ) + } + } + + /// The keyed form must reject what the string form rejects, so a specifier cannot reach + /// code generation through the tagged object that the plain-string path would refuse. + @Test(arguments: [ + #"{"kind": "module", "specifier": ""}"#, + #"{"kind": "module", "specifier": "./relative.mjs"}"#, + #"{"kind": "module", "specifier": "/../../escape.mjs"}"#, + ]) + func invalidKeyedJSImportFromFailsToDecode(json: String) { + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(JSImportFrom.self, from: Data(json.utf8)) + } + } + private func snapshotCodegen( skeleton: BridgeJSSkeleton, name: String, @@ -105,6 +154,17 @@ import Testing ) } + /// Target-local JavaScript module files that each input pretends to have on disk. + static let existingModulePaths: [String: Set] = [ + "JSImportModule.swift": [ + "/Modules/JSImportModule.mjs", + "/Modules/ModuleCounter.mjs", + ], + "JSImportBareModule.swift": [ + "/Modules/DefaultExport.mjs" + ], + ] + static func collectInputs() -> [String] { let fileManager = FileManager.default let inputs = try! fileManager.contentsOfDirectory(atPath: Self.inputsDirectory.path) @@ -116,13 +176,7 @@ import Testing let url = Self.inputsDirectory.appendingPathComponent(input) let name = url.deletingPathExtension().lastPathComponent let sourceFile = Parser.parse(source: try String(contentsOf: url, encoding: .utf8)) - let modulePaths: Set = - input == "JSImportModule.swift" - ? [ - "/Modules/JSImportModule.mjs", - "/Modules/ModuleCounter.mjs", - ] - : [] + let modulePaths = Self.existingModulePaths[input] ?? [] let swiftAPI = SwiftToSkeleton( progress: .silent, moduleName: "TestModule", diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift index 0445fe5e1..642debedc 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift @@ -37,6 +37,17 @@ import Testing "Inputs" ).appendingPathComponent("MacroSwift") + /// Target-local JavaScript module files that each input pretends to have on disk. + static let existingModulePaths: [String: Set] = [ + "JSImportModule.swift": [ + "/Modules/JSImportModule.mjs", + "/Modules/ModuleCounter.mjs", + ], + "JSImportBareModule.swift": [ + "/Modules/DefaultExport.mjs" + ], + ] + static func collectInputs(extension: String) -> [String] { let fileManager = FileManager.default let inputs = try! fileManager.contentsOfDirectory(atPath: Self.inputsDirectory.path) @@ -49,13 +60,7 @@ import Testing let name = url.deletingPathExtension().lastPathComponent let sourceFile = Parser.parse(source: try String(contentsOf: url, encoding: .utf8)) - let modulePaths: Set = - input == "JSImportModule.swift" - ? [ - "/Modules/JSImportModule.mjs", - "/Modules/ModuleCounter.mjs", - ] - : [] + let modulePaths = Self.existingModulePaths[input] ?? [] let importSwift = SwiftToSkeleton( progress: .silent, moduleName: "TestModule", diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift index e8cf963e3..316d51b41 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift @@ -30,44 +30,180 @@ import Testing func missingJavaScriptModuleProducesDiagnostic() throws { let source = """ let unrelated = 0 - @JSFunction(from: .module("/missing.js")) func imported() throws(JSException) + @JSFunction(from: .snippet("/missing.js")) func imported() throws(JSException) """ let diagnostics = try #require(moduleDiagnostics(source: source)) - #expect(diagnostics.description.contains("JavaScript module file was not found at '/missing.js'")) - #expect(diagnostics.description.contains("test.swift:2:27:")) + #expect(diagnostics.description.contains("JavaScript snippet file was not found at '/missing.js'")) + #expect(diagnostics.description.contains("test.swift:2:28:")) } @Test - func javaScriptModulePathMustStartAtTargetRoot() throws { + func bareJavaScriptModuleSpecifierIsAccepted() throws { let source = """ let unrelated = 0 - @JSFunction(from: .module("missing.js")) func imported() throws(JSException) + @JSFunction(jsName: "basename", from: .module("node:path")) func imported() throws(JSException) + """ + #expect(moduleDiagnostics(source: source) == nil) + } + + @Test + func relativeJavaScriptModuleSpecifierIsRejected() throws { + let source = """ + let unrelated = 0 + @JSFunction(from: .module("./missing.js")) func imported() throws(JSException) """ let diagnostics = try #require(moduleDiagnostics(source: source)) - #expect(diagnostics.description.contains("JavaScript module paths must start with '/'")) + #expect(diagnostics.description.contains("Relative JavaScript module specifiers are not supported")) #expect(diagnostics.description.contains("test.swift:2:27:")) } + @Test + func emptyJavaScriptModuleSpecifierIsRejected() throws { + let source = """ + let unrelated = 0 + @JSFunction(from: .module("")) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("JavaScript module specifier must not be empty.")) + } + + @Test + func defaultExportRequiresModuleOrigin() throws { + let source = """ + let unrelated = 0 + @JSFunction(jsName: .default) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect( + diagnostics.description.contains( + "'jsName: .default' requires 'from: .module(...)' or 'from: .snippet(...)'." + ) + ) + } + + @Test + func defaultExportIsRejectedForGlobalOrigin() throws { + let source = """ + let unrelated = 0 + @JSFunction(jsName: .default, from: .global) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("globalThis has no default export")) + } + + @Test + func defaultExportIsRejectedForSetter() throws { + let source = """ + @JSClass(from: .module("node:fs")) struct Wrapper { + @JSSetter(jsName: .default) func setValue(_ value: Int) throws(JSException) + } + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("ECMAScript module bindings are read-only")) + } + + @Test + func defaultExportIsRejectedForClassMember() throws { + let source = """ + @JSClass(from: .module("node:fs")) struct Wrapper { + @JSFunction(jsName: .default) func value() throws(JSException) -> Int + } + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("is not supported on a class member")) + } + + /// `jsName: nil` is valid Swift and means the same as omitting the argument. + @Test + func explicitNilJSNameIsAccepted() throws { + let source = """ + let unrelated = 0 + @JSFunction(jsName: nil, from: .global) func imported() throws(JSException) + """ + #expect(moduleDiagnostics(source: source) == nil) + } + + /// `JSName.name(_:)` is public and documented, so its explicit spelling must work. + @Test + func explicitNameCaseSpellingIsAccepted() throws { + let source = """ + let unrelated = 0 + @JSFunction(jsName: .name("basename"), from: .module("node:path")) func imported() throws(JSException) + """ + #expect(moduleDiagnostics(source: source) == nil) + } + + @Test + func jsNameMustBeStringLiteralOrDefault() throws { + let source = """ + let name = "basename" + @JSFunction(jsName: name, from: .module("node:path")) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("jsName must be a string literal or '.default'.")) + } + + /// A rooted path in `.module(...)` is the most likely mistake now that the two cases + /// are separate, so it must point at `.snippet(...)` rather than being passed to the + /// JavaScript resolver where it would fail much later. + @Test + func rootedPathInModuleSuggestsSnippet() throws { + let source = """ + let unrelated = 0 + @JSFunction(from: .module("/Modules/utils.mjs")) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("looks like a file in this target")) + #expect(diagnostics.description.contains(#"from: .snippet("/Modules/utils.mjs")"#)) + } + + /// The reverse mistake: a bare specifier in `.snippet(...)` must point at `.module(...)`. + @Test + func bareSpecifierInSnippetSuggestsModule() throws { + let source = """ + let unrelated = 0 + @JSFunction(from: .snippet("node:path")) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("JavaScript snippet paths must start with '/'")) + #expect(diagnostics.description.contains(#"from: .module("node:path")"#)) + } + + /// A snippet origin may also name a default export. + @Test + func defaultExportIsAcceptedForSnippetOrigin() throws { + let source = """ + let unrelated = 0 + @JSGetter(jsName: .default, from: .snippet("/Modules/utils.mjs")) var value: JSObject + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + // The file does not exist in this fixture, so the missing file must be the only + // complaint. Match on the diagnostic wording, not on `.default` itself, since the + // rendered diagnostic echoes the source line back. + #expect(diagnostics.description.contains("JavaScript snippet file was not found")) + #expect(!diagnostics.description.contains("requires 'from:")) + } + @Test func javaScriptModulePathMustNotTraverse() throws { let source = """ let unrelated = 0 - @JSFunction(from: .module("/../missing.js")) func imported() throws(JSException) + @JSFunction(from: .snippet("/../missing.js")) func imported() throws(JSException) """ let diagnostics = try #require(moduleDiagnostics(source: source)) - #expect(diagnostics.description.contains("JavaScript module paths must not contain '..'")) - #expect(diagnostics.description.contains("test.swift:2:27:")) + #expect(diagnostics.description.contains("JavaScript snippet paths must not contain '..'")) + #expect(diagnostics.description.contains("test.swift:2:28:")) } @Test func javaScriptModulePathMustUseSupportedExtension() throws { let source = """ let unrelated = 0 - @JSFunction(from: .module("/module.ts")) func imported() throws(JSException) + @JSFunction(from: .snippet("/module.ts")) func imported() throws(JSException) """ let diagnostics = try #require(moduleDiagnostics(source: source)) - #expect(diagnostics.description.contains("JavaScript modules must use a '.js' or '.mjs' extension")) - #expect(diagnostics.description.contains("test.swift:2:27:")) + #expect(diagnostics.description.contains("JavaScript snippets must use a '.js' or '.mjs' extension")) + #expect(diagnostics.description.contains("test.swift:2:28:")) } @Test @@ -77,7 +213,7 @@ import Testing @JSFunction(from: .module(modulePath)) func imported() throws(JSException) """ let diagnostics = try #require(moduleDiagnostics(source: source)) - #expect(diagnostics.description.contains("JavaScript module path must be a string literal.")) + #expect(diagnostics.description.contains("JavaScript module specifier must be a string literal.")) #expect(diagnostics.description.contains("test.swift:2:27:")) } diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportedJSModuleRegistryTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportedJSModuleRegistryTests.swift new file mode 100644 index 000000000..16c28be95 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportedJSModuleRegistryTests.swift @@ -0,0 +1,222 @@ +import Foundation +import Testing + +@testable import BridgeJSLink +@testable import BridgeJSSkeleton + +/// Covers module-origin behavior that the single-Swift-module snapshot tests cannot reach: +/// how references from *several* Swift modules are deduplicated, ordered, and named. +@Suite struct ImportedJSModuleRegistryTests { + private func skeleton( + moduleName: String, + functions: [ImportedFunctionSkeleton] = [], + types: [ImportedTypeSkeleton] = [] + ) -> BridgeJSSkeleton { + BridgeJSSkeleton( + moduleName: moduleName, + imported: ImportedModuleSkeleton( + children: [ImportedFileSkeleton(functions: functions, types: types)] + ) + ) + } + + private func function( + _ name: String, + jsName: String? = nil, + from: JSImportFrom + ) -> ImportedFunctionSkeleton { + ImportedFunctionSkeleton( + name: name, + jsName: jsName, + from: from, + parameters: [], + returnType: .void + ) + } + + private func importLines(_ skeletons: [BridgeJSSkeleton]) throws -> [String] { + var link = BridgeJSLink(sharedMemory: false) + let encoder = JSONEncoder() + for skeleton in skeletons { + _ = try link.addSkeletonFile(data: try encoder.encode(skeleton)) + } + let js = try link.link().outputJs + return js.split(separator: "\n").map(String.init).filter { $0.hasPrefix("import ") } + } + + /// A bare specifier names the same module regardless of which Swift module mentions it, + /// so two Swift modules must share one import and one binding. + @Test func bareSpecifierIsSharedAcrossSwiftModules() throws { + let lines = try importLines([ + skeleton(moduleName: "Alpha", functions: [function("a", jsName: "basename", from: .module("node:path"))]), + skeleton(moduleName: "Beta", functions: [function("b", jsName: "dirname", from: .module("node:path"))]), + ]) + #expect(lines.count == 1) + #expect(lines[0].contains("from \"node:path\"")) + #expect(lines[0].contains("basename as ")) + #expect(lines[0].contains("dirname as ")) + } + + /// A local path is only meaningful relative to its Swift target, so the same path in two + /// Swift modules must stay two separate copies with two separate imports. + @Test func localPathIsNotSharedAcrossSwiftModules() throws { + let lines = try importLines([ + skeleton(moduleName: "Alpha", functions: [function("a", from: .snippet("/utils.mjs"))]), + skeleton(moduleName: "Beta", functions: [function("b", from: .snippet("/utils.mjs"))]), + ]) + #expect(lines.count == 2) + #expect(lines.contains { $0.contains("bridge-js-modules/Alpha/utils.mjs") }) + #expect(lines.contains { $0.contains("bridge-js-modules/Beta/utils.mjs") }) + } + + /// A named import is a hard link-time requirement, so a class whose module export is + /// never looked up must not produce one. A wrapper-only `@JSClass` has no constructor + /// and no static methods, so nothing references the module's export of that name and + /// the module need not export it at all; requiring it would fail the whole module load. + @Test func wrapperOnlyClassDoesNotRequireANamedExport() throws { + let lines = try importLines([ + skeleton( + moduleName: "Alpha", + types: [ + ImportedTypeSkeleton( + name: "Wrapper", + from: .module("some-pkg"), + methods: [ + ImportedFunctionSkeleton(name: "read", parameters: [], returnType: .void) + ] + ) + ] + ) + ]) + #expect(lines.count == 1) + #expect(lines[0].hasPrefix("import * as ")) + #expect(!lines[0].contains("Wrapper as ")) + } + + /// A class with a constructor does have its export looked up, so it keeps a named import. + @Test func classWithConstructorUsesANamedImport() throws { + let lines = try importLines([ + skeleton( + moduleName: "Alpha", + types: [ + ImportedTypeSkeleton( + name: "Wrapper", + from: .module("some-pkg"), + constructor: ImportedConstructorSkeleton(parameters: []) + ) + ] + ) + ]) + #expect(lines == [#"import { Wrapper as __bjs_import_0_Wrapper } from "some-pkg";"#]) + } + + /// A class with only static methods also looks its export up. + @Test func classWithOnlyStaticMethodsUsesANamedImport() throws { + let lines = try importLines([ + skeleton( + moduleName: "Alpha", + types: [ + ImportedTypeSkeleton( + name: "Wrapper", + from: .module("some-pkg"), + staticMethods: [ + ImportedFunctionSkeleton(name: "create", parameters: [], returnType: .void) + ] + ) + ] + ) + ]) + #expect(lines == [#"import { Wrapper as __bjs_import_0_Wrapper } from "some-pkg";"#]) + } + + /// Local references are emitted before bare ones, each group sorted, so that the numbered + /// aliases in generated JavaScript are stable across runs. + @Test func referencesAreOrderedDeterministically() throws { + let lines = try importLines([ + skeleton( + moduleName: "Alpha", + functions: [ + function("z", jsName: "zeta", from: .module("zzz-package")), + function("a", jsName: "alpha", from: .module("aaa-package")), + function("l", from: .snippet("/local.mjs")), + ] + ) + ]) + #expect(lines.count == 3) + #expect(lines[0].contains("/local.mjs")) + #expect(lines[1].contains("aaa-package")) + #expect(lines[2].contains("zzz-package")) + } + + /// A class and a free function on the same specifier share one import line. + @Test func classAndFunctionOnSameSpecifierShareOneImport() throws { + let lines = try importLines([ + skeleton( + moduleName: "Alpha", + functions: [function("a", jsName: "helper", from: .module("pkg"))], + types: [ + ImportedTypeSkeleton( + name: "Widget", + from: .module("pkg"), + constructor: ImportedConstructorSkeleton(parameters: []) + ) + ] + ) + ]) + #expect(lines.count == 1) + #expect(lines[0].contains("helper as ")) + #expect(lines[0].contains("Widget as ")) + } + + /// One non-identifier export name degrades its own origin to a namespace import without + /// affecting any other origin in the same program. + @Test func namespaceFallbackIsScopedToOneOrigin() throws { + let lines = try importLines([ + skeleton( + moduleName: "Alpha", + functions: [ + function("a", jsName: "kebab-case", from: .module("weird-package")), + function("b", jsName: "join", from: .module("node:path")), + ] + ) + ]) + let weird = try #require(lines.first { $0.contains("weird-package") }) + let node = try #require(lines.first { $0.contains("node:path") }) + #expect(weird.hasPrefix("import * as ")) + #expect(node.hasPrefix("import { join as ")) + } + + /// Because a bare specifier is shared across Swift modules, its member names form a union. + /// A non-identifier name contributed by one Swift module therefore degrades the shared + /// import for the other module too. That is intended — one specifier means one import + /// statement — but it is worth pinning so the behavior is not changed by accident. + @Test func nonIdentifierNameInOneSwiftModuleDegradesTheSharedImport() throws { + let lines = try importLines([ + skeleton(moduleName: "Alpha", functions: [function("a", jsName: "join", from: .module("node:path"))]), + skeleton(moduleName: "Beta", functions: [function("b", jsName: "odd-name", from: .module("node:path"))]), + ]) + #expect(lines.count == 1) + #expect(lines[0].hasPrefix("import * as ")) + } + + /// A reserved word is a legal ECMAScript export name, and `default` is how the default + /// export is spelled, so both must survive as named imports. + @Test(arguments: ["default", "class", "import"]) + func reservedWordExportNamesUseNamedImports(memberName: String) throws { + let lines = try importLines([ + skeleton(moduleName: "Alpha", functions: [function("a", jsName: memberName, from: .module("pkg"))]) + ]) + #expect(lines.count == 1) + #expect(lines[0].hasPrefix("import { \(memberName) as ")) + } + + /// A specifier is embedded in a JavaScript string literal, so quotes and backslashes in it + /// must be escaped rather than terminating the literal. + @Test func specifierIsEscapedInTheImportLine() throws { + let lines = try importLines([ + skeleton(moduleName: "Alpha", functions: [function("a", jsName: "x", from: .module("pk\"g\\y"))]) + ]) + #expect(lines.count == 1) + #expect(lines[0].hasSuffix(#"from "pk\"g\\y";"#)) + } +} diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportBareModule.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportBareModule.swift new file mode 100644 index 000000000..968b700e4 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportBareModule.swift @@ -0,0 +1,22 @@ +@JSFunction(jsName: "basename", from: .module("node:path")) +func nodeBasename(_ path: String) throws(JSException) -> String + +@JSFunction(jsName: "dirname", from: .module("node:path")) +func nodeDirname(_ path: String) throws(JSException) -> String + +@JSGetter(jsName: "version", from: .module("some-package")) +var packageVersion: String + +@JSFunction(jsName: .default, from: .module("default-export-package")) +func callDefaultExport(_ value: Int) throws(JSException) -> Int + +@JSGetter(jsName: .default, from: .snippet("/Modules/DefaultExport.mjs")) +var localDefaultExport: JSObject + +@JSClass(jsName: "File", from: .module("@scope/package")) +struct ScopedFile { + @JSFunction init(_ value: Int) throws(JSException) + @JSFunction static func create(_ value: Int) throws(JSException) -> ScopedFile + @JSFunction func read() throws(JSException) -> Int + @JSGetter var size: Int +} diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportBareModuleFallback.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportBareModuleFallback.swift new file mode 100644 index 000000000..78671e33e --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportBareModuleFallback.swift @@ -0,0 +1,12 @@ +// `weird-package` needs a member name that is not a valid JavaScript identifier, so +// the whole origin falls back to a namespace import. `node:path` in the same file +// keeps using named imports, proving the fallback is per-origin. + +@JSFunction(jsName: "kebab-case-function", from: .module("weird-package")) +func kebabCaseFunction() throws(JSException) -> Int + +@JSGetter(jsName: "dashed-property", from: .module("weird-package")) +var dashedProperty: String + +@JSFunction(jsName: "join", from: .module("node:path")) +func joinPaths(_ lhs: String, _ rhs: String) throws(JSException) -> String diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift index 3fd9d79e7..091ac7911 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift @@ -1,13 +1,13 @@ -@JSFunction(from: .module("/Modules/JSImportModule.mjs")) +@JSFunction(from: .snippet("/Modules/JSImportModule.mjs")) func moduleAdd(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int -@JSFunction(jsName: "renamedFunction", from: .module("/Modules/JSImportModule.mjs")) +@JSFunction(jsName: "renamedFunction", from: .snippet("/Modules/JSImportModule.mjs")) func moduleRenamed() throws(JSException) -> String -@JSGetter(jsName: "version", from: .module("/Modules/JSImportModule.mjs")) +@JSGetter(jsName: "version", from: .snippet("/Modules/JSImportModule.mjs")) var moduleVersion: String -@JSClass(from: .module("/Modules/ModuleCounter.mjs")) +@JSClass(from: .snippet("/Modules/ModuleCounter.mjs")) struct ModuleCounter { @JSFunction init(_ value: Int) throws(JSException) @JSFunction static func create(_ value: Int) throws(JSException) -> ModuleCounter diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModule.json b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModule.json new file mode 100644 index 000000000..5e005d21f --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModule.json @@ -0,0 +1,229 @@ +{ + "imported" : { + "children" : [ + { + "functions" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : { + "kind" : "module", + "specifier" : "node:path" + }, + "jsName" : "basename", + "name" : "nodeBasename", + "parameters" : [ + { + "name" : "path", + "type" : { + "string" : { + + } + } + } + ], + "returnType" : { + "string" : { + + } + } + }, + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : { + "kind" : "module", + "specifier" : "node:path" + }, + "jsName" : "dirname", + "name" : "nodeDirname", + "parameters" : [ + { + "name" : "path", + "type" : { + "string" : { + + } + } + } + ], + "returnType" : { + "string" : { + + } + } + }, + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : { + "kind" : "module", + "specifier" : "default-export-package" + }, + "jsName" : "default", + "name" : "callDefaultExport", + "parameters" : [ + { + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "globalGetters" : [ + { + "accessLevel" : "internal", + "from" : { + "kind" : "module", + "specifier" : "some-package" + }, + "jsName" : "version", + "name" : "packageVersion", + "type" : { + "string" : { + + } + } + }, + { + "accessLevel" : "internal", + "from" : "\/Modules\/DefaultExport.mjs", + "jsName" : "default", + "name" : "localDefaultExport", + "type" : { + "jsObject" : { + + } + } + } + ], + "types" : [ + { + "accessLevel" : "internal", + "constructor" : { + "accessLevel" : "internal", + "parameters" : [ + { + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ] + }, + "from" : { + "kind" : "module", + "specifier" : "@scope\/package" + }, + "getters" : [ + { + "accessLevel" : "internal", + "name" : "size", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "jsName" : "File", + "methods" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "name" : "read", + "parameters" : [ + + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "name" : "ScopedFile", + "setters" : [ + + ], + "staticMethods" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "name" : "create", + "parameters" : [ + { + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "returnType" : { + "jsObject" : { + "_0" : "ScopedFile" + } + } + } + ] + } + ] + } + ] + }, + "moduleName" : "TestModule", + "usedExternalModules" : [ + + ] +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModule.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModule.swift new file mode 100644 index 000000000..4baf8d0f0 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModule.swift @@ -0,0 +1,192 @@ +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_packageVersion_get") +fileprivate func bjs_packageVersion_get_extern() -> Int32 +#else +fileprivate func bjs_packageVersion_get_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_packageVersion_get() -> Int32 { + return bjs_packageVersion_get_extern() +} + +func _$packageVersion_get() throws(JSException) -> String { + let ret = bjs_packageVersion_get() + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_localDefaultExport_get") +fileprivate func bjs_localDefaultExport_get_extern() -> Int32 +#else +fileprivate func bjs_localDefaultExport_get_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_localDefaultExport_get() -> Int32 { + return bjs_localDefaultExport_get_extern() +} + +func _$localDefaultExport_get() throws(JSException) -> JSObject { + let ret = bjs_localDefaultExport_get() + if let error = _swift_js_take_exception() { + throw error + } + return JSObject.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_nodeBasename") +fileprivate func bjs_nodeBasename_extern(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 +#else +fileprivate func bjs_nodeBasename_extern(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_nodeBasename(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 { + return bjs_nodeBasename_extern(pathBytes, pathLength) +} + +func _$nodeBasename(_ path: String) throws(JSException) -> String { + let ret0 = path.bridgeJSWithLoweredParameter { (pathBytes, pathLength) in + let ret = bjs_nodeBasename(pathBytes, pathLength) + return ret + } + let ret = ret0 + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_nodeDirname") +fileprivate func bjs_nodeDirname_extern(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 +#else +fileprivate func bjs_nodeDirname_extern(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_nodeDirname(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 { + return bjs_nodeDirname_extern(pathBytes, pathLength) +} + +func _$nodeDirname(_ path: String) throws(JSException) -> String { + let ret0 = path.bridgeJSWithLoweredParameter { (pathBytes, pathLength) in + let ret = bjs_nodeDirname(pathBytes, pathLength) + return ret + } + let ret = ret0 + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_callDefaultExport") +fileprivate func bjs_callDefaultExport_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_callDefaultExport_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_callDefaultExport(_ value: Int32) -> Int32 { + return bjs_callDefaultExport_extern(value) +} + +func _$callDefaultExport(_ value: Int) throws(JSException) -> Int { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_callDefaultExport(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ScopedFile_init") +fileprivate func bjs_ScopedFile_init_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ScopedFile_init_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ScopedFile_init(_ value: Int32) -> Int32 { + return bjs_ScopedFile_init_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ScopedFile_create_static") +fileprivate func bjs_ScopedFile_create_static_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ScopedFile_create_static_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ScopedFile_create_static(_ value: Int32) -> Int32 { + return bjs_ScopedFile_create_static_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ScopedFile_size_get") +fileprivate func bjs_ScopedFile_size_get_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ScopedFile_size_get_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ScopedFile_size_get(_ self: Int32) -> Int32 { + return bjs_ScopedFile_size_get_extern(self) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ScopedFile_read") +fileprivate func bjs_ScopedFile_read_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ScopedFile_read_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ScopedFile_read(_ self: Int32) -> Int32 { + return bjs_ScopedFile_read_extern(self) +} + +func _$ScopedFile_init(_ value: Int) throws(JSException) -> JSObject { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ScopedFile_init(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return JSObject.bridgeJSLiftReturn(ret) +} + +func _$ScopedFile_create(_ value: Int) throws(JSException) -> ScopedFile { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ScopedFile_create_static(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return ScopedFile.bridgeJSLiftReturn(ret) +} + +func _$ScopedFile_size_get(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ScopedFile_size_get(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +func _$ScopedFile_read(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ScopedFile_read(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModuleFallback.json b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModuleFallback.json new file mode 100644 index 000000000..451d21402 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModuleFallback.json @@ -0,0 +1,95 @@ +{ + "imported" : { + "children" : [ + { + "functions" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : { + "kind" : "module", + "specifier" : "weird-package" + }, + "jsName" : "kebab-case-function", + "name" : "kebabCaseFunction", + "parameters" : [ + + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + }, + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : { + "kind" : "module", + "specifier" : "node:path" + }, + "jsName" : "join", + "name" : "joinPaths", + "parameters" : [ + { + "name" : "lhs", + "type" : { + "string" : { + + } + } + }, + { + "name" : "rhs", + "type" : { + "string" : { + + } + } + } + ], + "returnType" : { + "string" : { + + } + } + } + ], + "globalGetters" : [ + { + "accessLevel" : "internal", + "from" : { + "kind" : "module", + "specifier" : "weird-package" + }, + "jsName" : "dashed-property", + "name" : "dashedProperty", + "type" : { + "string" : { + + } + } + } + ], + "types" : [ + + ] + } + ] + }, + "moduleName" : "TestModule", + "usedExternalModules" : [ + + ] +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModuleFallback.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModuleFallback.swift new file mode 100644 index 000000000..7780b5eb0 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModuleFallback.swift @@ -0,0 +1,66 @@ +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_dashedProperty_get") +fileprivate func bjs_dashedProperty_get_extern() -> Int32 +#else +fileprivate func bjs_dashedProperty_get_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_dashedProperty_get() -> Int32 { + return bjs_dashedProperty_get_extern() +} + +func _$dashedProperty_get() throws(JSException) -> String { + let ret = bjs_dashedProperty_get() + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_kebabCaseFunction") +fileprivate func bjs_kebabCaseFunction_extern() -> Int32 +#else +fileprivate func bjs_kebabCaseFunction_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_kebabCaseFunction() -> Int32 { + return bjs_kebabCaseFunction_extern() +} + +func _$kebabCaseFunction() throws(JSException) -> Int { + let ret = bjs_kebabCaseFunction() + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_joinPaths") +fileprivate func bjs_joinPaths_extern(_ lhsBytes: Int32, _ lhsLength: Int32, _ rhsBytes: Int32, _ rhsLength: Int32) -> Int32 +#else +fileprivate func bjs_joinPaths_extern(_ lhsBytes: Int32, _ lhsLength: Int32, _ rhsBytes: Int32, _ rhsLength: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_joinPaths(_ lhsBytes: Int32, _ lhsLength: Int32, _ rhsBytes: Int32, _ rhsLength: Int32) -> Int32 { + return bjs_joinPaths_extern(lhsBytes, lhsLength, rhsBytes, rhsLength) +} + +func _$joinPaths(_ lhs: String, _ rhs: String) throws(JSException) -> String { + let ret0 = lhs.bridgeJSWithLoweredParameter { (lhsBytes, lhsLength) in + let ret1 = rhs.bridgeJSWithLoweredParameter { (rhsBytes, rhsLength) in + let ret = bjs_joinPaths(lhsBytes, lhsLength, rhsBytes, rhsLength) + return ret + } + return ret1 + } + let ret = ret0 + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModule.d.ts b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModule.d.ts new file mode 100644 index 000000000..a6267bd31 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModule.d.ts @@ -0,0 +1,21 @@ +// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// `swift package bridge-js`. + +export interface ScopedFile { + read(): number; + readonly size: number; +} +export type Exports = { +} +export type Imports = { +} +export function createInstantiator(options: { + imports: Imports; +}, swift: any): Promise<{ + addImports: (importObject: WebAssembly.Imports) => void; + setInstance: (instance: WebAssembly.Instance) => void; + createExports: (instance: WebAssembly.Instance) => Exports; +}>; \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModule.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModule.js new file mode 100644 index 000000000..1c995923a --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModule.js @@ -0,0 +1,315 @@ +// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// `swift package bridge-js`. + +import { default as __bjs_import_0_default } from "./bridge-js-modules/TestModule/Modules/DefaultExport.mjs"; +import { File as __bjs_import_1_File } from "@scope/package"; +import { default as __bjs_import_2_default } from "default-export-package"; +import { basename as __bjs_import_3_basename, dirname as __bjs_import_3_dirname } from "node:path"; +import { version as __bjs_import_4_version } from "some-package"; + +export async function createInstantiator(options, swift) { + let instance; + let memory; + let setException; + let decodeString; + const textDecoder = new TextDecoder("utf-8"); + const textEncoder = new TextEncoder("utf-8"); + let tmpRetString; + let tmpRetBytes; + let tmpRetException; + let tmpRetOptionalBool; + let tmpRetOptionalInt; + let tmpRetOptionalFloat; + let tmpRetOptionalDouble; + let tmpRetOptionalHeapObject; + let strStack = []; + let i32Stack = []; + let i64Stack = []; + let f32Stack = []; + let f64Stack = []; + let ptrStack = []; + let taStack = []; + const enumHelpers = {}; + const structHelpers = {}; + + let _exports = null; + let bjs = null; + + return { + /** + * @param {WebAssembly.Imports} importObject + */ + addImports: (importObject, importsContext) => { + bjs = {}; + importObject["bjs"] = bjs; + bjs["swift_js_return_string"] = function(ptr, len) { + tmpRetString = decodeString(ptr, len); + } + bjs["swift_js_init_memory"] = function(sourceId, bytesPtr) { + const source = swift.memory.getObject(sourceId); + swift.memory.release(sourceId); + const bytes = new Uint8Array(memory.buffer, bytesPtr >>> 0); + bytes.set(source); + } + bjs["swift_js_make_js_string"] = function(ptr, len) { + return swift.memory.retain(decodeString(ptr, len)); + } + bjs["swift_js_init_memory_with_result"] = function(ptr, len) { + const target = new Uint8Array(memory.buffer, ptr >>> 0, len >>> 0); + target.set(tmpRetBytes); + tmpRetBytes = undefined; + } + bjs["swift_js_throw"] = function(id) { + tmpRetException = swift.memory.retainByRef(id); + } + bjs["swift_js_retain"] = function(id) { + return swift.memory.retainByRef(id); + } + bjs["swift_js_release"] = function(id) { + swift.memory.release(id); + } + bjs["swift_js_push_i32"] = function(v) { + i32Stack.push(v | 0); + } + bjs["swift_js_push_f32"] = function(v) { + f32Stack.push(Math.fround(v)); + } + bjs["swift_js_push_f64"] = function(v) { + f64Stack.push(v); + } + bjs["swift_js_push_string"] = function(ptr, len) { + const value = decodeString(ptr, len); + strStack.push(value); + } + bjs["swift_js_pop_i32"] = function() { + return i32Stack.pop(); + } + bjs["swift_js_pop_f32"] = function() { + return f32Stack.pop(); + } + bjs["swift_js_pop_f64"] = function() { + return f64Stack.pop(); + } + bjs["swift_js_push_pointer"] = function(pointer) { + ptrStack.push(pointer); + } + bjs["swift_js_pop_pointer"] = function() { + return ptrStack.pop(); + } + bjs["swift_js_push_i64"] = function(v) { + i64Stack.push(v); + } + bjs["swift_js_pop_i64"] = function() { + return i64Stack.pop(); + } + const taCtors = [Int8Array, Uint8Array, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array]; + bjs["swift_js_push_typed_array"] = function(kind, ptr, count) { + const Ctor = taCtors[kind]; + const byteLen = count * Ctor.BYTES_PER_ELEMENT; + const copy = memory.buffer.slice(ptr, ptr + byteLen); + taStack.push(Array.from(new Ctor(copy))); + } + const __bjs_promiseSettlers = Symbol("JavaScriptKit.promiseSettlers"); + bjs["swift_js_make_promise"] = function() { + let resolve, reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + promise[__bjs_promiseSettlers] = { resolve, reject }; + return swift.memory.retain(promise); + } + bjs["swift_js_return_optional_bool"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalBool = null; + } else { + tmpRetOptionalBool = value !== 0; + } + } + bjs["swift_js_return_optional_int"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalInt = null; + } else { + tmpRetOptionalInt = value | 0; + } + } + bjs["swift_js_return_optional_float"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalFloat = null; + } else { + tmpRetOptionalFloat = Math.fround(value); + } + } + bjs["swift_js_return_optional_double"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalDouble = null; + } else { + tmpRetOptionalDouble = value; + } + } + bjs["swift_js_return_optional_string"] = function(isSome, ptr, len) { + if (isSome === 0) { + tmpRetString = null; + } else { + tmpRetString = decodeString(ptr, len); + } + } + bjs["swift_js_return_optional_object"] = function(isSome, objectId) { + if (isSome === 0) { + tmpRetString = null; + } else { + tmpRetString = swift.memory.getObject(objectId); + } + } + bjs["swift_js_return_optional_heap_object"] = function(isSome, pointer) { + if (isSome === 0) { + tmpRetOptionalHeapObject = null; + } else { + tmpRetOptionalHeapObject = pointer; + } + } + bjs["swift_js_get_optional_int_presence"] = function() { + return tmpRetOptionalInt != null ? 1 : 0; + } + bjs["swift_js_get_optional_int_value"] = function() { + const value = tmpRetOptionalInt; + tmpRetOptionalInt = undefined; + return value; + } + bjs["swift_js_get_optional_string"] = function() { + const str = tmpRetString; + tmpRetString = undefined; + if (str == null) { + return -1; + } else { + const bytes = textEncoder.encode(str); + tmpRetBytes = bytes; + return bytes.length; + } + } + bjs["swift_js_get_optional_float_presence"] = function() { + return tmpRetOptionalFloat != null ? 1 : 0; + } + bjs["swift_js_get_optional_float_value"] = function() { + const value = tmpRetOptionalFloat; + tmpRetOptionalFloat = undefined; + return value; + } + bjs["swift_js_get_optional_double_presence"] = function() { + return tmpRetOptionalDouble != null ? 1 : 0; + } + bjs["swift_js_get_optional_double_value"] = function() { + const value = tmpRetOptionalDouble; + tmpRetOptionalDouble = undefined; + return value; + } + bjs["swift_js_get_optional_heap_object_pointer"] = function() { + const pointer = tmpRetOptionalHeapObject; + tmpRetOptionalHeapObject = undefined; + return pointer || 0; + } + bjs["swift_js_closure_unregister"] = function(funcRef) {} + const TestModule = importObject["TestModule"] = importObject["TestModule"] || {}; + TestModule["bjs_packageVersion_get"] = function bjs_packageVersion_get() { + try { + let ret = __bjs_import_4_version; + tmpRetBytes = textEncoder.encode(ret); + return tmpRetBytes.length; + } catch (error) { + setException(error); + } + } + TestModule["bjs_localDefaultExport_get"] = function bjs_localDefaultExport_get() { + try { + let ret = __bjs_import_0_default; + return swift.memory.retain(ret); + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_nodeBasename"] = function bjs_nodeBasename(pathBytes, pathCount) { + try { + const string = decodeString(pathBytes, pathCount); + let ret = __bjs_import_3_basename(string); + tmpRetBytes = textEncoder.encode(ret); + return tmpRetBytes.length; + } catch (error) { + setException(error); + } + } + TestModule["bjs_nodeDirname"] = function bjs_nodeDirname(pathBytes, pathCount) { + try { + const string = decodeString(pathBytes, pathCount); + let ret = __bjs_import_3_dirname(string); + tmpRetBytes = textEncoder.encode(ret); + return tmpRetBytes.length; + } catch (error) { + setException(error); + } + } + TestModule["bjs_callDefaultExport"] = function bjs_callDefaultExport(value) { + try { + let ret = __bjs_import_2_default(value); + return ret; + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ScopedFile_init"] = function bjs_ScopedFile_init(value) { + try { + return swift.memory.retain(new __bjs_import_1_File(value)); + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ScopedFile_size_get"] = function bjs_ScopedFile_size_get(self) { + try { + let ret = swift.memory.getObject(self).size; + return ret; + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ScopedFile_create_static"] = function bjs_ScopedFile_create_static(value) { + try { + let ret = __bjs_import_1_File.create(value); + return swift.memory.retain(ret); + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ScopedFile_read"] = function bjs_ScopedFile_read(self) { + try { + let ret = swift.memory.getObject(self).read(); + return ret; + } catch (error) { + setException(error); + return 0 + } + } + }, + setInstance: (i) => { + instance = i; + memory = instance.exports.memory; + + decodeString = (ptr, len) => { const bytes = new Uint8Array(memory.buffer, ptr >>> 0, len >>> 0); return textDecoder.decode(bytes); } + + setException = (error) => { + instance.exports._swift_js_exception.value = swift.memory.retain(error) + } + }, + /** @param {WebAssembly.Instance} instance */ + createExports: (instance) => { + const js = swift.memory.heap; + const exports = { + }; + _exports = exports; + return exports; + }, + } +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModuleFallback.d.ts b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModuleFallback.d.ts new file mode 100644 index 000000000..818d57a9d --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModuleFallback.d.ts @@ -0,0 +1,17 @@ +// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// `swift package bridge-js`. + +export type Exports = { +} +export type Imports = { +} +export function createInstantiator(options: { + imports: Imports; +}, swift: any): Promise<{ + addImports: (importObject: WebAssembly.Imports) => void; + setInstance: (instance: WebAssembly.Instance) => void; + createExports: (instance: WebAssembly.Instance) => Exports; +}>; \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModuleFallback.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModuleFallback.js new file mode 100644 index 000000000..038374240 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModuleFallback.js @@ -0,0 +1,259 @@ +// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// `swift package bridge-js`. + +import { join as __bjs_import_0_join } from "node:path"; +import * as __bjs_imported_module_1 from "weird-package"; + +export async function createInstantiator(options, swift) { + let instance; + let memory; + let setException; + let decodeString; + const textDecoder = new TextDecoder("utf-8"); + const textEncoder = new TextEncoder("utf-8"); + let tmpRetString; + let tmpRetBytes; + let tmpRetException; + let tmpRetOptionalBool; + let tmpRetOptionalInt; + let tmpRetOptionalFloat; + let tmpRetOptionalDouble; + let tmpRetOptionalHeapObject; + let strStack = []; + let i32Stack = []; + let i64Stack = []; + let f32Stack = []; + let f64Stack = []; + let ptrStack = []; + let taStack = []; + const enumHelpers = {}; + const structHelpers = {}; + + let _exports = null; + let bjs = null; + + return { + /** + * @param {WebAssembly.Imports} importObject + */ + addImports: (importObject, importsContext) => { + bjs = {}; + importObject["bjs"] = bjs; + bjs["swift_js_return_string"] = function(ptr, len) { + tmpRetString = decodeString(ptr, len); + } + bjs["swift_js_init_memory"] = function(sourceId, bytesPtr) { + const source = swift.memory.getObject(sourceId); + swift.memory.release(sourceId); + const bytes = new Uint8Array(memory.buffer, bytesPtr >>> 0); + bytes.set(source); + } + bjs["swift_js_make_js_string"] = function(ptr, len) { + return swift.memory.retain(decodeString(ptr, len)); + } + bjs["swift_js_init_memory_with_result"] = function(ptr, len) { + const target = new Uint8Array(memory.buffer, ptr >>> 0, len >>> 0); + target.set(tmpRetBytes); + tmpRetBytes = undefined; + } + bjs["swift_js_throw"] = function(id) { + tmpRetException = swift.memory.retainByRef(id); + } + bjs["swift_js_retain"] = function(id) { + return swift.memory.retainByRef(id); + } + bjs["swift_js_release"] = function(id) { + swift.memory.release(id); + } + bjs["swift_js_push_i32"] = function(v) { + i32Stack.push(v | 0); + } + bjs["swift_js_push_f32"] = function(v) { + f32Stack.push(Math.fround(v)); + } + bjs["swift_js_push_f64"] = function(v) { + f64Stack.push(v); + } + bjs["swift_js_push_string"] = function(ptr, len) { + const value = decodeString(ptr, len); + strStack.push(value); + } + bjs["swift_js_pop_i32"] = function() { + return i32Stack.pop(); + } + bjs["swift_js_pop_f32"] = function() { + return f32Stack.pop(); + } + bjs["swift_js_pop_f64"] = function() { + return f64Stack.pop(); + } + bjs["swift_js_push_pointer"] = function(pointer) { + ptrStack.push(pointer); + } + bjs["swift_js_pop_pointer"] = function() { + return ptrStack.pop(); + } + bjs["swift_js_push_i64"] = function(v) { + i64Stack.push(v); + } + bjs["swift_js_pop_i64"] = function() { + return i64Stack.pop(); + } + const taCtors = [Int8Array, Uint8Array, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array]; + bjs["swift_js_push_typed_array"] = function(kind, ptr, count) { + const Ctor = taCtors[kind]; + const byteLen = count * Ctor.BYTES_PER_ELEMENT; + const copy = memory.buffer.slice(ptr, ptr + byteLen); + taStack.push(Array.from(new Ctor(copy))); + } + const __bjs_promiseSettlers = Symbol("JavaScriptKit.promiseSettlers"); + bjs["swift_js_make_promise"] = function() { + let resolve, reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + promise[__bjs_promiseSettlers] = { resolve, reject }; + return swift.memory.retain(promise); + } + bjs["swift_js_return_optional_bool"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalBool = null; + } else { + tmpRetOptionalBool = value !== 0; + } + } + bjs["swift_js_return_optional_int"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalInt = null; + } else { + tmpRetOptionalInt = value | 0; + } + } + bjs["swift_js_return_optional_float"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalFloat = null; + } else { + tmpRetOptionalFloat = Math.fround(value); + } + } + bjs["swift_js_return_optional_double"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalDouble = null; + } else { + tmpRetOptionalDouble = value; + } + } + bjs["swift_js_return_optional_string"] = function(isSome, ptr, len) { + if (isSome === 0) { + tmpRetString = null; + } else { + tmpRetString = decodeString(ptr, len); + } + } + bjs["swift_js_return_optional_object"] = function(isSome, objectId) { + if (isSome === 0) { + tmpRetString = null; + } else { + tmpRetString = swift.memory.getObject(objectId); + } + } + bjs["swift_js_return_optional_heap_object"] = function(isSome, pointer) { + if (isSome === 0) { + tmpRetOptionalHeapObject = null; + } else { + tmpRetOptionalHeapObject = pointer; + } + } + bjs["swift_js_get_optional_int_presence"] = function() { + return tmpRetOptionalInt != null ? 1 : 0; + } + bjs["swift_js_get_optional_int_value"] = function() { + const value = tmpRetOptionalInt; + tmpRetOptionalInt = undefined; + return value; + } + bjs["swift_js_get_optional_string"] = function() { + const str = tmpRetString; + tmpRetString = undefined; + if (str == null) { + return -1; + } else { + const bytes = textEncoder.encode(str); + tmpRetBytes = bytes; + return bytes.length; + } + } + bjs["swift_js_get_optional_float_presence"] = function() { + return tmpRetOptionalFloat != null ? 1 : 0; + } + bjs["swift_js_get_optional_float_value"] = function() { + const value = tmpRetOptionalFloat; + tmpRetOptionalFloat = undefined; + return value; + } + bjs["swift_js_get_optional_double_presence"] = function() { + return tmpRetOptionalDouble != null ? 1 : 0; + } + bjs["swift_js_get_optional_double_value"] = function() { + const value = tmpRetOptionalDouble; + tmpRetOptionalDouble = undefined; + return value; + } + bjs["swift_js_get_optional_heap_object_pointer"] = function() { + const pointer = tmpRetOptionalHeapObject; + tmpRetOptionalHeapObject = undefined; + return pointer || 0; + } + bjs["swift_js_closure_unregister"] = function(funcRef) {} + const TestModule = importObject["TestModule"] = importObject["TestModule"] || {}; + TestModule["bjs_dashedProperty_get"] = function bjs_dashedProperty_get() { + try { + let ret = __bjs_imported_module_1["dashed-property"]; + tmpRetBytes = textEncoder.encode(ret); + return tmpRetBytes.length; + } catch (error) { + setException(error); + } + } + TestModule["bjs_kebabCaseFunction"] = function bjs_kebabCaseFunction() { + try { + let ret = __bjs_imported_module_1["kebab-case-function"](); + return ret; + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_joinPaths"] = function bjs_joinPaths(lhsBytes, lhsCount, rhsBytes, rhsCount) { + try { + const string = decodeString(lhsBytes, lhsCount); + const string1 = decodeString(rhsBytes, rhsCount); + let ret = __bjs_import_0_join(string, string1); + tmpRetBytes = textEncoder.encode(ret); + return tmpRetBytes.length; + } catch (error) { + setException(error); + } + } + }, + setInstance: (i) => { + instance = i; + memory = instance.exports.memory; + + decodeString = (ptr, len) => { const bytes = new Uint8Array(memory.buffer, ptr >>> 0, len >>> 0); return textDecoder.decode(bytes); } + + setException = (error) => { + instance.exports._swift_js_exception.value = swift.memory.retain(error) + } + }, + /** @param {WebAssembly.Instance} instance */ + createExports: (instance) => { + const js = swift.memory.heap; + const exports = { + }; + _exports = exports; + return exports; + }, + } +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js index cb4767f03..e03dcbab4 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js @@ -4,8 +4,8 @@ // To update this file, just rebuild your project or run // `swift package bridge-js`. -import * as __bjs_imported_module_0 from "./bridge-js-modules/TestModule/Modules/JSImportModule.mjs"; -import * as __bjs_imported_module_1 from "./bridge-js-modules/TestModule/Modules/ModuleCounter.mjs"; +import { moduleAdd as __bjs_import_0_moduleAdd, renamedFunction as __bjs_import_0_renamedFunction, version as __bjs_import_0_version } from "./bridge-js-modules/TestModule/Modules/JSImportModule.mjs"; +import { ModuleCounter as __bjs_import_1_ModuleCounter } from "./bridge-js-modules/TestModule/Modules/ModuleCounter.mjs"; export async function createInstantiator(options, swift) { let instance; @@ -209,7 +209,7 @@ export async function createInstantiator(options, swift) { const TestModule = importObject["TestModule"] = importObject["TestModule"] || {}; TestModule["bjs_moduleVersion_get"] = function bjs_moduleVersion_get() { try { - let ret = __bjs_imported_module_0.version; + let ret = __bjs_import_0_version; tmpRetBytes = textEncoder.encode(ret); return tmpRetBytes.length; } catch (error) { @@ -218,7 +218,7 @@ export async function createInstantiator(options, swift) { } TestModule["bjs_moduleAdd"] = function bjs_moduleAdd(lhs, rhs) { try { - let ret = __bjs_imported_module_0.moduleAdd(lhs, rhs); + let ret = __bjs_import_0_moduleAdd(lhs, rhs); return ret; } catch (error) { setException(error); @@ -227,7 +227,7 @@ export async function createInstantiator(options, swift) { } TestModule["bjs_moduleRenamed"] = function bjs_moduleRenamed() { try { - let ret = __bjs_imported_module_0.renamedFunction(); + let ret = __bjs_import_0_renamedFunction(); tmpRetBytes = textEncoder.encode(ret); return tmpRetBytes.length; } catch (error) { @@ -236,7 +236,7 @@ export async function createInstantiator(options, swift) { } TestModule["bjs_ModuleCounter_init"] = function bjs_ModuleCounter_init(value) { try { - return swift.memory.retain(new __bjs_imported_module_1.ModuleCounter(value)); + return swift.memory.retain(new __bjs_import_1_ModuleCounter(value)); } catch (error) { setException(error); return 0 @@ -260,7 +260,7 @@ export async function createInstantiator(options, swift) { } TestModule["bjs_ModuleCounter_create_static"] = function bjs_ModuleCounter_create_static(value) { try { - let ret = __bjs_imported_module_1.ModuleCounter.create(value); + let ret = __bjs_import_1_ModuleCounter.create(value); return swift.memory.retain(ret); } catch (error) { setException(error); diff --git a/Plugins/PackageToJS/Sources/PackageToJS.swift b/Plugins/PackageToJS/Sources/PackageToJS.swift index d86a34fa1..c1d09af0a 100644 --- a/Plugins/PackageToJS/Sources/PackageToJS.swift +++ b/Plugins/PackageToJS/Sources/PackageToJS.swift @@ -698,7 +698,10 @@ struct PackagingPlanner { for input in skeletons { let skeleton = try link.addSkeletonFile(data: Data(contentsOf: input.source)) - for reference in ImportedJSModuleRegistry.collectReferences(skeletons: [skeleton]) { + // Only target-local modules are files we copy. Bare specifiers (e.g. "node:path", + // "lodash") are resolved by the JavaScript host at load time, so there is + // nothing to find on disk and nothing to place in the output. + for reference in ImportedJSModuleRegistry.collectSnippetFiles(skeletons: [skeleton]) { guard let sourceURL = JavaScriptModulePath.resolve( reference.path, diff --git a/Plugins/PackageToJS/Tests/PackagingPlannerTests.swift b/Plugins/PackageToJS/Tests/PackagingPlannerTests.swift index e5f04402a..12187a201 100644 --- a/Plugins/PackageToJS/Tests/PackagingPlannerTests.swift +++ b/Plugins/PackageToJS/Tests/PackagingPlannerTests.swift @@ -134,7 +134,7 @@ import Testing functions: [ ImportedFunctionSkeleton( name: "value", - from: .module("/module.mjs"), + from: .snippet("/module.mjs"), parameters: [], returnType: .void ) @@ -208,4 +208,165 @@ import Testing #expect(system.writtenFiles.filter { $0.hasSuffix("/bridge-js.js") }.count == initialLinkCount) } } + + /// A bare specifier must not suppress copying of local modules that appear alongside it. + @Test func mixedLocalAndBareModulesBothWork() throws { + try withTemporaryDirectory { temporaryDirectory, _ in + let skeleton = temporaryDirectory.appending(path: "BridgeJS.json") + let module = temporaryDirectory.appending(path: "module.mjs") + let wasm = temporaryDirectory.appending(path: "main.wasm") + let plannerSource = temporaryDirectory.appending(path: "PackageToJS.swift") + let output = temporaryDirectory.appending(path: "output") + let intermediates = temporaryDirectory.appending(path: "intermediates") + + let bridgeSkeleton = BridgeJSSkeleton( + moduleName: "TestModule", + imported: ImportedModuleSkeleton( + children: [ + ImportedFileSkeleton( + functions: [ + ImportedFunctionSkeleton( + name: "value", + from: .snippet("/module.mjs"), + parameters: [], + returnType: .void + ), + ImportedFunctionSkeleton( + name: "basename", + from: .module("node:path"), + parameters: [], + returnType: .void + ), + ], + types: [] + ) + ] + ) + ) + try JSONEncoder().encode(bridgeSkeleton).write(to: skeleton) + try Data("export const value = 1;\n".utf8).write(to: module) + try Data([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]).write(to: wasm) + try Data().write(to: plannerSource) + + let system = TestPackagingSystem() + let planner = PackagingPlanner( + options: PackageToJS.PackageOptions(), + packageId: "test", + intermediatesDir: BuildPath(absolute: intermediates.path), + selfPackageDir: BuildPath( + absolute: URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .path + ), + skeletons: [.init(source: skeleton, targetDirectory: temporaryDirectory)], + outputDir: BuildPath(absolute: output.path), + wasmProductArtifact: BuildPath(absolute: wasm.path), + wasmFilename: "main.wasm", + configuration: "debug", + triple: "wasm32-unknown-wasi", + selfPath: BuildPath(absolute: plannerSource.path), + system: system + ) + var make = MiniMake(printProgress: { _, _ in }) + let root = try planner.planBuild( + make: &make, + buildOptions: PackageToJS.BuildOptions( + product: "test", + noOptimize: false, + debugInfoFormat: .none, + packageOptions: PackageToJS.PackageOptions() + ) + ) + try make.build(output: root, scope: MiniMake.VariableScope(variables: [:])) + + let copiedModule = output.appending(path: "bridge-js-modules/TestModule/module.mjs") + #expect(try String(contentsOf: copiedModule, encoding: .utf8) == "export const value = 1;\n") + let generated = try String(contentsOf: output.appending(path: "bridge-js.js"), encoding: .utf8) + #expect(generated.contains("from \"node:path\"")) + #expect(generated.contains("bridge-js-modules/TestModule/module.mjs")) + } + } + + /// A bare specifier such as "node:path" is resolved by the JavaScript host at load + /// time, so packaging must not look for a file on disk or copy anything for it. + @Test func bareJavaScriptModuleIsNotCopied() throws { + try withTemporaryDirectory { temporaryDirectory, _ in + let skeleton = temporaryDirectory.appending(path: "BridgeJS.json") + let wasm = temporaryDirectory.appending(path: "main.wasm") + let plannerSource = temporaryDirectory.appending(path: "PackageToJS.swift") + let output = temporaryDirectory.appending(path: "output") + let intermediates = temporaryDirectory.appending(path: "intermediates") + + let bridgeSkeleton = BridgeJSSkeleton( + moduleName: "TestModule", + imported: ImportedModuleSkeleton( + children: [ + ImportedFileSkeleton( + functions: [ + ImportedFunctionSkeleton( + name: "basename", + from: .module("node:path"), + parameters: [], + returnType: .void + ) + ], + types: [] + ) + ] + ) + ) + try JSONEncoder().encode(bridgeSkeleton).write(to: skeleton) + try Data([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]).write(to: wasm) + try Data().write(to: plannerSource) + + let system = TestPackagingSystem() + let planner = PackagingPlanner( + options: PackageToJS.PackageOptions(), + packageId: "test", + intermediatesDir: BuildPath(absolute: intermediates.path), + selfPackageDir: BuildPath( + absolute: URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .path + ), + skeletons: [ + .init( + source: skeleton, + targetDirectory: temporaryDirectory + ) + ], + outputDir: BuildPath(absolute: output.path), + wasmProductArtifact: BuildPath(absolute: wasm.path), + wasmFilename: "main.wasm", + configuration: "debug", + triple: "wasm32-unknown-wasi", + selfPath: BuildPath(absolute: plannerSource.path), + system: system + ) + var make = MiniMake(printProgress: { _, _ in }) + let root = try planner.planBuild( + make: &make, + buildOptions: PackageToJS.BuildOptions( + product: "test", + noOptimize: false, + debugInfoFormat: .none, + packageOptions: PackageToJS.PackageOptions() + ) + ) + try make.build(output: root, scope: MiniMake.VariableScope(variables: [:])) + + #expect(!FileManager.default.fileExists(atPath: output.appending(path: "bridge-js-modules").path)) + let generated = try String( + contentsOf: output.appending(path: "bridge-js.js"), + encoding: .utf8 + ) + #expect(generated.contains("from \"node:path\"")) + } + } } diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript-into-Swift.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript-into-Swift.md index 6d06d4339..c6818b923 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript-into-Swift.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript-into-Swift.md @@ -29,7 +29,7 @@ You can bring JavaScript into Swift in three ways: - **Inject at initialization**: Declare in Swift and supply the implementation in `getImports()` (e.g. a `today()` function). - **Import from `globalThis`**: For APIs on the JavaScript global object (e.g. `console`, `document`), use `@JSGetter(from: .global)` so they are read from `globalThis` and you don't pass them in `getImports()`. -- **Ship an ECMAScript module**: Use `from: .module("/path/from/target/root.js")` on a top-level function/getter or `@JSClass`. The leading `/` denotes the Swift target root; it is not a filesystem-absolute path. BridgeJS copies the referenced file into the generated package, so it is not supplied through `getImports()`. +- **Ship an ECMAScript module**: Use `from: .snippet("/path/from/target/root.js")` on a top-level function/getter or `@JSClass`. The leading `/` denotes the Swift target root; it is not a filesystem-absolute path. BridgeJS copies the referenced file into the generated package, so it is not supplied through `getImports()`. Exclude the directory containing `.js` or `.mjs` modules from the Swift target to avoid SwiftPM's unhandled-file warning. See for an example. diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Class.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Class.md index 185b47d1f..7c6ae5a03 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Class.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Class.md @@ -36,7 +36,7 @@ export class Greeter { ``` ```swift -@JSClass(from: .module("/JavaScript/greeter.js")) +@JSClass(from: .snippet("/JavaScript/greeter.js")) struct Greeter { @JSFunction init(_ name: String) throws(JSException) @JSFunction static func named(_ name: String) throws(JSException) -> Greeter @@ -44,6 +44,18 @@ struct Greeter { } ``` +To wrap a class exported by an installed npm package, use `from: .module(...)`. Pass `jsName: .default` when the class is the module's default export: + +```swift +@JSClass(jsName: "File", from: .module("@bjorn3/browser_wasi_shim")) +struct WasiFile { + @JSFunction init(_ data: JSObject) throws(JSException) + @JSGetter var size: Int64 +} +``` + +Nothing is copied for these, and you are responsible for making them resolvable at load time; see . + The path's leading `/` denotes the Swift target root, not the filesystem root. The module's named class export is the root for construction and static methods. Instance methods, getters, and setters operate on the wrapped object and must not specify their own `from:` argument. Use `jsName` on `@JSClass` to select a differently named class export. JavaScript inheritance may be implemented normally in the module; the Swift declaration describes the API visible on the exported class and its instances. ### 2. Wire the JavaScript side diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Function.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Function.md index c57aeda03..1b2be6cb0 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Function.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Function.md @@ -25,12 +25,24 @@ export function add(a, b) { return a + b; } ``` ```swift -@JSFunction(from: .module("/JavaScript/math.js")) +@JSFunction(from: .snippet("/JavaScript/math.js")) func add(_ a: Double, _ b: Double) throws(JSException) -> Double ``` The leading `/` denotes the Swift target root, not the filesystem root. BridgeJS copies explicitly referenced modules into the generated PackageToJS package. Multiple declarations may reference the same file; it is copied and imported only once. `jsName` selects a differently named export, otherwise BridgeJS uses the normalized Swift name. +To call a Node builtin or an installed npm package instead, use `from: .module(...)`. The specifier is passed to the JavaScript module resolver verbatim: + +```swift +@JSFunction(jsName: "basename", from: .module("node:path")) +func basename(_ path: String) throws(JSException) -> String + +@JSFunction(jsName: "chunk", from: .module("lodash/fp")) +func chunk(_ input: JSObject, _ size: Int) throws(JSException) -> JSObject +``` + +Nothing is copied for these, and you are responsible for making them resolvable at load time; see for what that entails. To call the module's default export instead of a named one, pass `jsName: .default`. + SwiftPM does not know what to do with `.js`/`.mjs` files inside a target, so exclude the directory holding them to avoid an "unhandled files" warning: ```swift diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Variable.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Variable.md index 6825875dd..3a92a690e 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Variable.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Variable.md @@ -25,12 +25,24 @@ export const environment = "production"; ``` ```swift -@JSGetter(jsName: "environment", from: .module("/JavaScript/config.js")) +@JSGetter(jsName: "environment", from: .snippet("/JavaScript/config.js")) var currentEnvironment: String ``` The path's leading `/` denotes the Swift target root, not the filesystem root. Module exports are read-only through this API. Top-level `@JSSetter` remains unsupported. +A getter can also read from an external module with `from: .module(...)`, covering Node builtins and installed npm packages. Pass `jsName: .default` to read the module's default export, which is how many npm packages expose their main value: + +```swift +@JSGetter(jsName: "version", from: .module("some-package")) +var packageVersion: String + +@JSGetter(jsName: .default, from: .module("some-package")) +var packageDefault: JSObject +``` + +Nothing is copied for these, and you are responsible for making them resolvable at load time; see . + ### 2. Add a setter for writable variables (optional) If the JavaScript property is writable and you need to set it from Swift, add a corresponding `@JSSetter` function. Property setters are exposed as functions (e.g. `setMyConfig(_:)`) because Swift property setters cannot `throw`. diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md index 238bc8687..74588b06b 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md @@ -6,13 +6,26 @@ Limitations and unsupported patterns when using BridgeJS. BridgeJS generates glue code per Swift target (module). Some patterns that are valid in Swift or TypeScript are not supported across the bridge today. This article summarizes the main limitations so you can design your APIs accordingly. -## File-backed JavaScript modules +## JavaScript modules -Files referenced by `JSImportFrom.module` must be nonempty `.js` or `.mjs` paths beginning with `/`. This leading slash denotes the Swift target root, not the filesystem root. Files must remain within that Swift target. Only explicitly referenced files are copied. BridgeJS does not discover or rewrite an imported module's dependency graph, so referenced files should currently be self-contained. +Two origins read from an ECMAScript module, and which one you use depends on who owns the JavaScript. -Generated packages use static ECMAScript module imports. This works with the existing PackageToJS browser and Node ESM entry points. CommonJS and classic non-module script output are not generated or translated. +`JSImportFrom.snippet` names a JavaScript file you ship inside the Swift target, such as `.snippet("/Modules/utils.mjs")`. The path must begin with `/` and end in `.js` or `.mjs`. That leading slash denotes the Swift target root, not the filesystem root, and the file must remain within that Swift target. Only explicitly referenced files are copied into the generated package. BridgeJS does not discover or rewrite a snippet's dependency graph, so snippets should currently be self-contained. -Module origins apply to top-level `@JSFunction`, top-level `@JSGetter`, and an entire `@JSClass`. Per-member origins, top-level setters, inline JavaScript source, package-root-relative paths, and per-member module overrides are not supported. +`JSImportFrom.module` names an external module resolved by the JavaScript host — for example `.module("node:path")`, `.module("lodash/fp")`, or `.module("@scope/package")`. Because resolution is host-defined, BridgeJS deliberately performs no build-time validation of these specifiers beyond rejecting the empty string, relative specifiers (`./x`, `../x`), and rooted paths (which are snippets). This has several consequences you are responsible for: + +- Importing a `node:`-prefixed builtin makes the generated package Node-only. It will fail to load in a browser. +- An npm package must be resolvable at load time — either from the generated output directory (Node walks up to the nearest `node_modules`), or through your bundler's aliasing or an import map. +- BridgeJS does not add anything to the generated `package.json`. Installing the dependency is up to you, and no version is inferred. +- A specifier is not verified to exist until the JavaScript host loads the generated module, so a typo surfaces as a resolution error from your JavaScript toolchain rather than a Swift compile error. + +Generated packages use static ECMAScript module imports. This works with the existing PackageToJS browser and Node ESM entry points. CommonJS and classic non-module script output are not generated or translated. Note that named exports of a CommonJS package are only importable when Node can statically detect them; when in doubt, use `jsName: .default` and reach members through the default export. + +A module export is called through a named import, so `this` is `undefined` inside the called function rather than the module namespace object. A function that reaches sibling exports through `this` — which happens in CommonJS packages consumed through Node's ESM interop — will fail. Import the default export and call the member through it when a package needs that receiver. + +Both origins apply to top-level `@JSFunction`, top-level `@JSGetter`, and an entire `@JSClass`. Per-member origins, top-level setters, inline JavaScript source, package-root-relative paths, and per-member overrides are not supported. `jsName: .default` is likewise only valid on those three declaration forms and only together with `from: .module(...)` or `from: .snippet(...)`; it cannot be used on `@JSSetter`, because ECMAScript module bindings are read-only. + +The TypeScript-definition workflow (`bridge-js.d.ts`) always imports from `globalThis` and cannot yet target a snippet or module origin. To import from either, declare the API with the macros instead. ## Type usage crossing module boundary diff --git a/Sources/JavaScriptKit/Macros.swift b/Sources/JavaScriptKit/Macros.swift index 7a1bb4091..2750f8268 100644 --- a/Sources/JavaScriptKit/Macros.swift +++ b/Sources/JavaScriptKit/Macros.swift @@ -9,13 +9,47 @@ public enum JSEnumStyle: String { /// Controls where BridgeJS reads imported JS values from. /// /// - `global`: Read from `globalThis`. -/// - `module`: Read a named export from an ECMAScript module file rooted at the Swift target. +/// - `snippet`: Read from a JavaScript file shipped inside the Swift target. +/// - `module`: Read from an external ECMAScript module. public enum JSImportFrom { case global - /// Read from an ECMAScript module file using a `/`-prefixed path rooted at the Swift target directory. + /// Read from a JavaScript file that ships with the Swift target. + /// + /// The path is rooted at the Swift target directory and must begin with `/` + /// and end in `.js` or `.mjs`, e.g. `.snippet("/Modules/utils.mjs")`. The + /// leading `/` denotes the target root, not the filesystem root, and the file + /// must exist inside that target. BridgeJS copies referenced files into the + /// generated package. + case snippet(String) + /// Read from an external ECMAScript module, resolved by the JavaScript host. + /// + /// The value is passed to the module resolver verbatim, which covers Node + /// built-in modules and installed packages, e.g. `.module("node:path")`, + /// `.module("lodash/fp")`, or `.module("@scope/package")`. Nothing is copied + /// for these, and making them resolvable at load time is up to you. + /// + /// To reference a file in your own target, use ``JSImportFrom/snippet(_:)``. case module(String) } +/// Names the JavaScript member that an imported declaration refers to. +/// +/// A string literal is accepted directly, so `jsName: "basename"` means +/// ``JSName/name(_:)`` with that value. +public enum JSName: ExpressibleByStringLiteral { + /// A member looked up by name. + case name(String) + /// The default export of an ECMAScript module. + /// + /// Only valid on a top-level `@JSFunction`, `@JSGetter`, or `@JSClass` + /// that also specifies `from: .module(...)` or `from: .snippet(...)`. + case `default` + + public init(stringLiteral value: String) { + self = .name(value) + } +} + /// A macro that exposes Swift functions, classes, and methods to JavaScript. /// /// Apply this macro to Swift declarations that you want to make callable from JavaScript: @@ -144,9 +178,11 @@ public macro JS( /// /// - Parameter from: Selects where the property is read from. /// Use `.global` to read from `globalThis` (e.g. `console`, `document`). -/// Use `.module("/path/to/module.js")` to read a named export from a file rooted at the Swift target. +/// Use `.snippet("/path/to/module.js")` to read a named export from a file rooted at the Swift target, +/// or `.module("node:os")` to read a named export from an external module. +/// Pass `jsName: .default` to read the module's default export. @attached(accessor) -public macro JSGetter(jsName: String? = nil, from: JSImportFrom? = nil) = +public macro JSGetter(jsName: JSName? = nil, from: JSImportFrom? = nil) = #externalMacro(module: "BridgeJSMacros", type: "JSGetterMacro") /// A macro that generates a Swift function body that writes a value to JavaScript. @@ -164,7 +200,7 @@ public macro JSGetter(jsName: String? = nil, from: JSImportFrom? = nil) = /// @JSSetter func setName(_ value: String) throws (JSException) /// ``` @attached(body) -public macro JSSetter(jsName: String? = nil, from: JSImportFrom? = nil) = +public macro JSSetter(jsName: JSName? = nil, from: JSImportFrom? = nil) = #externalMacro(module: "BridgeJSMacros", type: "JSSetterMacro") /// A macro that generates a Swift function body that calls a JavaScript function. @@ -184,9 +220,11 @@ public macro JSSetter(jsName: String? = nil, from: JSImportFrom? = nil) = /// If not provided, the Swift function name is used. /// - Parameter from: Selects where the function is looked up from. /// Use `.global` to call a function on `globalThis` (e.g. `setTimeout`). -/// Use `.module("/path/to/module.js")` to call a named export from a file rooted at the Swift target. +/// Use `.snippet("/path/to/module.js")` to call a named export from a file rooted at the Swift target, +/// or `.module("node:path")` to call a named export from an external module. +/// Pass `jsName: .default` to call the module's default export. @attached(body) -public macro JSFunction(jsName: String? = nil, from: JSImportFrom? = nil) = +public macro JSFunction(jsName: JSName? = nil, from: JSImportFrom? = nil) = #externalMacro(module: "BridgeJSMacros", type: "JSFunctionMacro") /// A macro that adds bridging members for a Swift type that represents a JavaScript class. @@ -209,8 +247,10 @@ public macro JSFunction(jsName: String? = nil, from: JSImportFrom? = nil) = /// /// - Parameter from: Selects where the constructor is looked up from. /// Use `.global` to construct globals like `WebSocket` via `globalThis`. -/// Use `.module("/path/to/module.js")` to construct a named class export from a file rooted at the Swift target. +/// Use `.snippet("/path/to/module.js")` to construct a named class export from a file rooted at the Swift target, +/// or `.module("@scope/package")` to construct a named class export from an external module. +/// Pass `jsName: .default` to construct the module's default export. @attached(member, names: named(jsObject), named(init(unsafelyWrapping:))) @attached(extension, conformances: _JSBridgedClass) -public macro JSClass(jsName: String? = nil, from: JSImportFrom? = nil) = +public macro JSClass(jsName: JSName? = nil, from: JSImportFrom? = nil) = #externalMacro(module: "BridgeJSMacros", type: "JSClassMacro") diff --git a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift index 39de49ca0..5337e923a 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift +++ b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift @@ -17010,6 +17010,119 @@ func _$JSClassSupportImports_makeJSClassWithArrayMembers(_ numbers: [Int], _ lab return JSClassWithArrayMembers.bridgeJSLiftReturn(ret) } +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_defaultExport_get") +fileprivate func bjs_defaultExport_get_extern() -> Int32 +#else +fileprivate func bjs_defaultExport_get_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_defaultExport_get() -> Int32 { + return bjs_defaultExport_get_extern() +} + +func _$defaultExport_get() throws(JSException) -> JSObject { + let ret = bjs_defaultExport_get() + if let error = _swift_js_take_exception() { + throw error + } + return JSObject.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_nodeBasename") +fileprivate func bjs_nodeBasename_extern(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 +#else +fileprivate func bjs_nodeBasename_extern(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_nodeBasename(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 { + return bjs_nodeBasename_extern(pathBytes, pathLength) +} + +func _$nodeBasename(_ path: String) throws(JSException) -> String { + let ret0 = path.bridgeJSWithLoweredParameter { (pathBytes, pathLength) in + let ret = bjs_nodeBasename(pathBytes, pathLength) + return ret + } + let ret = ret0 + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_nodeJoin") +fileprivate func bjs_nodeJoin_extern(_ lhsBytes: Int32, _ lhsLength: Int32, _ rhsBytes: Int32, _ rhsLength: Int32) -> Int32 +#else +fileprivate func bjs_nodeJoin_extern(_ lhsBytes: Int32, _ lhsLength: Int32, _ rhsBytes: Int32, _ rhsLength: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_nodeJoin(_ lhsBytes: Int32, _ lhsLength: Int32, _ rhsBytes: Int32, _ rhsLength: Int32) -> Int32 { + return bjs_nodeJoin_extern(lhsBytes, lhsLength, rhsBytes, rhsLength) +} + +func _$nodeJoin(_ lhs: String, _ rhs: String) throws(JSException) -> String { + let ret0 = lhs.bridgeJSWithLoweredParameter { (lhsBytes, lhsLength) in + let ret1 = rhs.bridgeJSWithLoweredParameter { (rhsBytes, rhsLength) in + let ret = bjs_nodeJoin(lhsBytes, lhsLength, rhsBytes, rhsLength) + return ret + } + return ret1 + } + let ret = ret0 + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_WasiFile_init") +fileprivate func bjs_WasiFile_init_extern(_ data: Int32) -> Int32 +#else +fileprivate func bjs_WasiFile_init_extern(_ data: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_WasiFile_init(_ data: Int32) -> Int32 { + return bjs_WasiFile_init_extern(data) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_WasiFile_size_get") +fileprivate func bjs_WasiFile_size_get_extern(_ self: Int32) -> Int64 +#else +fileprivate func bjs_WasiFile_size_get_extern(_ self: Int32) -> Int64 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_WasiFile_size_get(_ self: Int32) -> Int64 { + return bjs_WasiFile_size_get_extern(self) +} + +func _$WasiFile_init(_ data: JSObject) throws(JSException) -> JSObject { + let dataValue = data.bridgeJSLowerParameter() + let ret = bjs_WasiFile_init(dataValue) + if let error = _swift_js_take_exception() { + throw error + } + return JSObject.bridgeJSLiftReturn(ret) +} + +func _$WasiFile_size_get(_ self: JSObject) throws(JSException) -> Int64 { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_WasiFile_size_get(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int64.bridgeJSLiftReturn(ret) +} + #if arch(wasm32) @_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_moduleVersion_get") fileprivate func bjs_moduleVersion_get_extern() -> Int32 diff --git a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json index e0c30c428..dbab1c0ea 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json +++ b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json @@ -24174,6 +24174,136 @@ } ] }, + { + "functions" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : { + "kind" : "module", + "specifier" : "node:path" + }, + "jsName" : "basename", + "name" : "nodeBasename", + "parameters" : [ + { + "name" : "path", + "type" : { + "string" : { + + } + } + } + ], + "returnType" : { + "string" : { + + } + } + }, + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : { + "kind" : "module", + "specifier" : "node:path" + }, + "jsName" : "join", + "name" : "nodeJoin", + "parameters" : [ + { + "name" : "lhs", + "type" : { + "string" : { + + } + } + }, + { + "name" : "rhs", + "type" : { + "string" : { + + } + } + } + ], + "returnType" : { + "string" : { + + } + } + } + ], + "globalGetters" : [ + { + "accessLevel" : "internal", + "from" : "\/Modules\/DefaultExport.mjs", + "jsName" : "default", + "name" : "defaultExport", + "type" : { + "jsObject" : { + + } + } + } + ], + "types" : [ + { + "accessLevel" : "internal", + "constructor" : { + "accessLevel" : "internal", + "parameters" : [ + { + "name" : "data", + "type" : { + "jsObject" : { + + } + } + } + ] + }, + "from" : { + "kind" : "module", + "specifier" : "@bjorn3\/browser_wasi_shim" + }, + "getters" : [ + { + "accessLevel" : "internal", + "name" : "size", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "w64" + } + } + } + } + ], + "jsName" : "File", + "methods" : [ + + ], + "name" : "WasiFile", + "setters" : [ + + ], + "staticMethods" : [ + + ] + } + ] + }, { "functions" : [ { diff --git a/Tests/BridgeJSRuntimeTests/JSImportBareModuleTests.swift b/Tests/BridgeJSRuntimeTests/JSImportBareModuleTests.swift new file mode 100644 index 000000000..36c22913f --- /dev/null +++ b/Tests/BridgeJSRuntimeTests/JSImportBareModuleTests.swift @@ -0,0 +1,40 @@ +import JavaScriptKit +import XCTest + +// A Node built-in module. Nothing is copied into the generated package for this; +// the generated JavaScript imports "node:path" directly and Node resolves it. +@JSFunction(jsName: "basename", from: .module("node:path")) +func nodeBasename(_ path: String) throws(JSException) -> String + +@JSFunction(jsName: "join", from: .module("node:path")) +func nodeJoin(_ lhs: String, _ rhs: String) throws(JSException) -> String + +// An npm package, resolved through node_modules rather than being built into the runtime. +@JSClass(jsName: "File", from: .module("@bjorn3/browser_wasi_shim")) +struct WasiFile { + @JSFunction init(_ data: JSObject) throws(JSException) + @JSGetter var size: Int64 +} + +// The default export of a module, reached with `jsName: .default`. +@JSGetter(jsName: .default, from: .snippet("/Modules/DefaultExport.mjs")) +var defaultExport: JSObject + +final class JSImportBareModuleTests: XCTestCase { + func testNodeBuiltinModule() throws { + XCTAssertEqual(try nodeBasename("/a/b/c.txt"), "c.txt") + XCTAssertEqual(try nodeJoin("a", "b"), "a/b") + } + + func testNpmPackageClass() throws { + let bytes = JSObject.global.Uint8Array.function!.new(3) + let file = try WasiFile(bytes) + XCTAssertEqual(try file.size, 3) + } + + func testDefaultExport() throws { + let module = try defaultExport + XCTAssertEqual(module.label.string, "from the default export") + XCTAssertEqual(module.triple!(7).number, 21) + } +} diff --git a/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift b/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift index 4cc228328..9efb64161 100644 --- a/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift +++ b/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift @@ -1,19 +1,19 @@ import JavaScriptKit import XCTest -@JSFunction(from: .module("/Modules/JSImportModule.mjs")) +@JSFunction(from: .snippet("/Modules/JSImportModule.mjs")) func moduleAdd(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int -@JSFunction(jsName: "renamedFunction", from: .module("/Modules/JSImportModule.mjs")) +@JSFunction(jsName: "renamedFunction", from: .snippet("/Modules/JSImportModule.mjs")) func moduleRenamed() throws(JSException) -> String -@JSFunction(from: .module("/Modules/JSImportModule.mjs")) +@JSFunction(from: .snippet("/Modules/JSImportModule.mjs")) func moduleThrow() throws(JSException) -@JSGetter(jsName: "version", from: .module("/Modules/JSImportModule.mjs")) +@JSGetter(jsName: "version", from: .snippet("/Modules/JSImportModule.mjs")) var moduleVersion: String -@JSClass(from: .module("/Modules/ModuleCounter.mjs")) +@JSClass(from: .snippet("/Modules/ModuleCounter.mjs")) struct ModuleCounter { @JSFunction init(_ value: Int) throws(JSException) @JSFunction static func create(_ value: Int) throws(JSException) -> ModuleCounter diff --git a/Tests/BridgeJSRuntimeTests/Modules/DefaultExport.mjs b/Tests/BridgeJSRuntimeTests/Modules/DefaultExport.mjs new file mode 100644 index 000000000..295129c36 --- /dev/null +++ b/Tests/BridgeJSRuntimeTests/Modules/DefaultExport.mjs @@ -0,0 +1,6 @@ +export default { + label: "from the default export", + triple(value) { + return value * 3; + }, +};