Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 166 additions & 31 deletions Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,22 +98,16 @@ public final class SwiftToSkeleton {
importCollector.importedFunctions.compactMap(\.from)
+ importCollector.importedTypes.compactMap(\.from)
+ importCollector.importedGlobalGetters.compactMap(\.from)
let modulePaths = Set(importOrigins.compactMap(\.modulePath))
// Only target-local module paths are validated here. Bare 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 modulePaths = Set(importOrigins.compactMap(\.localModulePath))
for path in modulePaths.sorted() {
if validatedJavaScriptModulePaths.contains(path) {
continue
}
let pathNode = importCollector.importedModulePathNodes[path] ?? Syntax(sourceFile)
guard path.hasPrefix("/") else {
importCollector.errors.append(
DiagnosticError(
node: pathNode,
message: "JavaScript module paths must start with '/' to indicate the Swift target root: "
+ "'\(path)'."
)
)
continue
}
guard !path.split(separator: "/").contains("..") else {
importCollector.errors.append(
DiagnosticError(
Expand Down Expand Up @@ -2548,20 +2542,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:
return
case .global:
errors.append(
DiagnosticError(
node: node,
message: "'jsName: .default' requires 'from: .module(...)'; "
+ "globalThis has no default export."
)
)
case nil:
errors.append(
DiagnosticError(
node: node,
message: "'jsName: .default' requires 'from: .module(...)'."
)
)
}
}

Expand All @@ -2588,6 +2686,26 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
)
return nil
}
guard !path.isEmpty else {
errors.append(
DiagnosticError(
node: literal,
message: "JavaScript module specifier must not be empty."
)
)
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 a '/'-prefixed path for a file in this target (e.g. '/Modules/utils.mjs'), "
+ "or a bare specifier for an external module (e.g. 'node:path')."
)
)
return nil
}
if importedModulePathNodes[path] == nil {
importedModulePathNodes[path] = Syntax(literal)
}
Expand Down Expand Up @@ -2645,7 +2763,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 {
Expand Down Expand Up @@ -2779,10 +2899,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
}
Expand All @@ -2796,10 +2922,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
}
Expand Down Expand Up @@ -2990,8 +3122,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)
Expand Down Expand Up @@ -3043,12 +3177,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,
Expand Down
53 changes: 31 additions & 22 deletions Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading