Skip to content

Commit 9ed4820

Browse files
BridgeJS: Add property support
Add support for Swift properties in BridgeJS with proper readonly detection. - Support stored, lazy, computed, and observed properties - Proper readonly property detection - Generate correct JavaScript property descriptors - Add comprehensive runtime and snapshot tests
1 parent 15e9491 commit 9ed4820

File tree

19 files changed

+2554
-66
lines changed

19 files changed

+2554
-66
lines changed

Examples/PlayBridgeJS/Sources/PlayBridgeJS/Generated/JavaScript/BridgeJS.ExportSwift.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@
4646
}
4747
}
4848
],
49-
"name" : "PlayBridgeJS"
49+
"name" : "PlayBridgeJS",
50+
"properties" : [
51+
52+
]
5053
},
5154
{
5255
"methods" : [
@@ -115,7 +118,10 @@
115118
}
116119
}
117120
],
118-
"name" : "PlayBridgeJSOutput"
121+
"name" : "PlayBridgeJSOutput",
122+
"properties" : [
123+
124+
]
119125
}
120126
],
121127
"functions" : [

Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift

Lines changed: 143 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,75 @@ public class ExportSwift {
272272
return .skipChildren
273273
}
274274

275+
override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind {
276+
guard node.attributes.hasJSAttribute() else { return .skipChildren }
277+
guard case .classBody(let className) = state else {
278+
diagnose(node: node, message: "@JS var must be inside a @JS class")
279+
return .skipChildren
280+
}
281+
282+
if let jsAttribute = node.attributes.firstJSAttribute,
283+
extractNamespace(from: jsAttribute) != nil
284+
{
285+
diagnose(
286+
node: jsAttribute,
287+
message: "Namespace is not supported for property declarations",
288+
hint: "Remove the namespace from @JS attribute"
289+
)
290+
}
291+
292+
// Process each binding (variable declaration)
293+
for binding in node.bindings {
294+
guard let pattern = binding.pattern.as(IdentifierPatternSyntax.self) else {
295+
diagnose(node: binding.pattern, message: "Complex patterns not supported for @JS properties")
296+
continue
297+
}
298+
299+
let propertyName = pattern.identifier.text
300+
301+
guard let typeAnnotation = binding.typeAnnotation else {
302+
diagnose(node: binding, message: "@JS property must have explicit type annotation")
303+
continue
304+
}
305+
306+
guard let propertyType = self.parent.lookupType(for: typeAnnotation.type) else {
307+
diagnoseUnsupportedType(node: typeAnnotation.type, type: typeAnnotation.type.trimmedDescription)
308+
continue
309+
}
310+
311+
// Check if property is readonly
312+
let isLet = node.bindingSpecifier.tokenKind == .keyword(.let)
313+
let isGetterOnly = node.bindings.contains(where: {
314+
switch $0.accessorBlock?.accessors {
315+
case .accessors(let accessors):
316+
// Has accessors - check if it only has a getter (no setter, willSet, or didSet)
317+
return !accessors.contains(where: { accessor in
318+
let tokenKind = accessor.accessorSpecifier.tokenKind
319+
return tokenKind == .keyword(.set) || tokenKind == .keyword(.willSet)
320+
|| tokenKind == .keyword(.didSet)
321+
})
322+
case .getter:
323+
// Has only a getter block
324+
return true
325+
case nil:
326+
// No accessor block - this is a stored property, not readonly
327+
return false
328+
}
329+
})
330+
let isReadonly = isLet || isGetterOnly
331+
332+
let exportedProperty = ExportedProperty(
333+
name: propertyName,
334+
type: propertyType,
335+
isReadonly: isReadonly
336+
)
337+
338+
exportedClassByName[className]?.properties.append(exportedProperty)
339+
}
340+
341+
return .skipChildren
342+
}
343+
275344
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
276345
let name = node.name.text
277346

@@ -284,6 +353,7 @@ public class ExportSwift {
284353
name: name,
285354
constructor: nil,
286355
methods: [],
356+
properties: [],
287357
namespace: namespace
288358
)
289359
exportedClassNames.append(name)
@@ -350,7 +420,8 @@ public class ExportSwift {
350420

351421
class ExportedThunkBuilder {
352422
var body: [CodeBlockItemSyntax] = []
353-
var abiParameterForwardings: [LabeledExprSyntax] = []
423+
var liftedParameterExprs: [ExprSyntax] = []
424+
var parameters: [Parameter] = []
354425
var abiParameterSignatures: [(name: String, type: WasmCoreType)] = []
355426
var abiReturnType: WasmCoreType?
356427
let effects: Effects
@@ -369,38 +440,19 @@ public class ExportSwift {
369440
}
370441

371442
func liftParameter(param: Parameter) {
443+
parameters.append(param)
372444
switch param.type {
373445
case .bool:
374-
abiParameterForwardings.append(
375-
LabeledExprSyntax(
376-
label: param.label,
377-
expression: ExprSyntax("\(raw: param.name) == 1")
378-
)
379-
)
446+
liftedParameterExprs.append(ExprSyntax("\(raw: param.name) == 1"))
380447
abiParameterSignatures.append((param.name, .i32))
381448
case .int:
382-
abiParameterForwardings.append(
383-
LabeledExprSyntax(
384-
label: param.label,
385-
expression: ExprSyntax("\(raw: param.type.swiftType)(\(raw: param.name))")
386-
)
387-
)
449+
liftedParameterExprs.append(ExprSyntax("\(raw: param.type.swiftType)(\(raw: param.name))"))
388450
abiParameterSignatures.append((param.name, .i32))
389451
case .float:
390-
abiParameterForwardings.append(
391-
LabeledExprSyntax(
392-
label: param.label,
393-
expression: ExprSyntax("\(raw: param.name)")
394-
)
395-
)
452+
liftedParameterExprs.append(ExprSyntax("\(raw: param.name)"))
396453
abiParameterSignatures.append((param.name, .f32))
397454
case .double:
398-
abiParameterForwardings.append(
399-
LabeledExprSyntax(
400-
label: param.label,
401-
expression: ExprSyntax("\(raw: param.name)")
402-
)
403-
)
455+
liftedParameterExprs.append(ExprSyntax("\(raw: param.name)"))
404456
abiParameterSignatures.append((param.name, .f64))
405457
case .string:
406458
let bytesLabel = "\(param.name)Bytes"
@@ -412,46 +464,40 @@ public class ExportSwift {
412464
}
413465
"""
414466
append(prepare)
415-
abiParameterForwardings.append(
416-
LabeledExprSyntax(
417-
label: param.label,
418-
expression: ExprSyntax("\(raw: param.name)")
419-
)
420-
)
467+
liftedParameterExprs.append(ExprSyntax("\(raw: param.name)"))
421468
abiParameterSignatures.append((bytesLabel, .i32))
422469
abiParameterSignatures.append((lengthLabel, .i32))
423470
case .jsObject(nil):
424-
abiParameterForwardings.append(
425-
LabeledExprSyntax(
426-
label: param.label,
427-
expression: ExprSyntax("JSObject(id: UInt32(bitPattern: \(raw: param.name)))")
428-
)
429-
)
471+
liftedParameterExprs.append(ExprSyntax("JSObject(id: UInt32(bitPattern: \(raw: param.name)))"))
430472
abiParameterSignatures.append((param.name, .i32))
431473
case .jsObject(let name):
432-
abiParameterForwardings.append(
433-
LabeledExprSyntax(
434-
label: param.label,
435-
expression: ExprSyntax("\(raw: name)(takingThis: UInt32(bitPattern: \(raw: param.name)))")
436-
)
474+
liftedParameterExprs.append(
475+
ExprSyntax("\(raw: name)(takingThis: UInt32(bitPattern: \(raw: param.name)))")
437476
)
438477
abiParameterSignatures.append((param.name, .i32))
439478
case .swiftHeapObject:
440479
// UnsafeMutableRawPointer is passed as an i32 pointer
441480
let objectExpr: ExprSyntax =
442481
"Unmanaged<\(raw: param.type.swiftType)>.fromOpaque(\(raw: param.name)).takeUnretainedValue()"
443-
abiParameterForwardings.append(
444-
LabeledExprSyntax(label: param.label, expression: objectExpr)
445-
)
482+
liftedParameterExprs.append(objectExpr)
446483
abiParameterSignatures.append((param.name, .pointer))
447484
case .void:
448485
break
449486
}
450487
}
451488

489+
private func removeFirstLiftedParameter() -> (parameter: Parameter, expr: ExprSyntax) {
490+
let parameter = parameters.removeFirst()
491+
let expr = liftedParameterExprs.removeFirst()
492+
return (parameter, expr)
493+
}
494+
452495
private func renderCallStatement(callee: ExprSyntax, returnType: BridgeType) -> CodeBlockItemSyntax {
496+
let labeledParams = zip(parameters, liftedParameterExprs).map { param, expr in
497+
LabeledExprSyntax(label: param.label, expression: expr)
498+
}
453499
var callExpr: ExprSyntax =
454-
"\(raw: callee)(\(raw: abiParameterForwardings.map { $0.description }.joined(separator: ", ")))"
500+
"\(raw: callee)(\(raw: labeledParams.map { $0.description }.joined(separator: ", ")))"
455501
if effects.isAsync {
456502
callExpr = ExprSyntax(
457503
AwaitExprSyntax(awaitKeyword: .keyword(.await).with(\.trailingTrivia, .space), expression: callExpr)
@@ -484,14 +530,30 @@ public class ExportSwift {
484530
}
485531

486532
func callMethod(klassName: String, methodName: String, returnType: BridgeType) {
487-
let _selfParam = self.abiParameterForwardings.removeFirst()
533+
let (_, selfExpr) = removeFirstLiftedParameter()
488534
let item = renderCallStatement(
489-
callee: "\(raw: _selfParam).\(raw: methodName)",
535+
callee: "\(raw: selfExpr).\(raw: methodName)",
490536
returnType: returnType
491537
)
492538
append(item)
493539
}
494540

541+
func callPropertyGetter(klassName: String, propertyName: String, returnType: BridgeType) {
542+
let (_, selfExpr) = removeFirstLiftedParameter()
543+
let retMutability = returnType == .string ? "var" : "let"
544+
if returnType == .void {
545+
append("\(raw: selfExpr).\(raw: propertyName)")
546+
} else {
547+
append("\(raw: retMutability) ret = \(raw: selfExpr).\(raw: propertyName)")
548+
}
549+
}
550+
551+
func callPropertySetter(klassName: String, propertyName: String) {
552+
let (_, selfExpr) = removeFirstLiftedParameter()
553+
let (_, newValueExpr) = removeFirstLiftedParameter()
554+
append("\(raw: selfExpr).\(raw: propertyName) = \(raw: newValueExpr)")
555+
}
556+
495557
func lowerReturnValue(returnType: BridgeType) {
496558
if effects.isAsync {
497559
// Async functions always return a Promise, which is a JSObject
@@ -717,6 +779,39 @@ public class ExportSwift {
717779
decls.append(builder.render(abiName: method.abiName))
718780
}
719781

782+
// Generate property getters and setters
783+
for property in klass.properties {
784+
// Generate getter
785+
let getterBuilder = ExportedThunkBuilder(effects: Effects(isAsync: false, isThrows: false))
786+
getterBuilder.liftParameter(
787+
param: Parameter(label: nil, name: "_self", type: .swiftHeapObject(klass.name))
788+
)
789+
getterBuilder.callPropertyGetter(
790+
klassName: klass.name,
791+
propertyName: property.name,
792+
returnType: property.type
793+
)
794+
getterBuilder.lowerReturnValue(returnType: property.type)
795+
decls.append(getterBuilder.render(abiName: property.getterAbiName(className: klass.name)))
796+
797+
// Generate setter if property is not readonly
798+
if !property.isReadonly {
799+
let setterBuilder = ExportedThunkBuilder(effects: Effects(isAsync: false, isThrows: false))
800+
setterBuilder.liftParameter(
801+
param: Parameter(label: nil, name: "_self", type: .swiftHeapObject(klass.name))
802+
)
803+
setterBuilder.liftParameter(
804+
param: Parameter(label: "value", name: "value", type: property.type)
805+
)
806+
setterBuilder.callPropertySetter(
807+
klassName: klass.name,
808+
propertyName: property.name
809+
)
810+
setterBuilder.lowerReturnValue(returnType: .void)
811+
decls.append(setterBuilder.render(abiName: property.setterAbiName(className: klass.name)))
812+
}
813+
}
814+
720815
do {
721816
decls.append(
722817
"""

Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -511,13 +511,12 @@ struct BridgeJSLink {
511511
func renderFunction(
512512
name: String,
513513
parameters: [Parameter],
514-
returnType: BridgeType,
515514
returnExpr: String?,
516-
isMethod: Bool
515+
declarationPrefixKeyword: String?
517516
) -> [String] {
518517
var funcLines: [String] = []
519518
funcLines.append(
520-
"\(isMethod ? "" : "function ")\(name)(\(parameters.map { $0.name }.joined(separator: ", "))) {"
519+
"\(declarationPrefixKeyword.map { "\($0) "} ?? "")\(name)(\(parameters.map { $0.name }.joined(separator: ", "))) {"
521520
)
522521
funcLines.append(contentsOf: bodyLines.map { $0.indent(count: 4) })
523522
funcLines.append(contentsOf: cleanupLines.map { $0.indent(count: 4) })
@@ -550,9 +549,8 @@ struct BridgeJSLink {
550549
let funcLines = thunkBuilder.renderFunction(
551550
name: function.abiName,
552551
parameters: function.parameters,
553-
returnType: function.returnType,
554552
returnExpr: returnExpr,
555-
isMethod: false
553+
declarationPrefixKeyword: "function"
556554
)
557555
var dtsLines: [String] = []
558556
dtsLines.append(
@@ -615,16 +613,61 @@ struct BridgeJSLink {
615613
contentsOf: thunkBuilder.renderFunction(
616614
name: method.name,
617615
parameters: method.parameters,
618-
returnType: method.returnType,
619616
returnExpr: returnExpr,
620-
isMethod: true
617+
declarationPrefixKeyword: nil
621618
).map { $0.indent(count: 4) }
622619
)
623620
dtsTypeLines.append(
624621
"\(method.name)\(renderTSSignature(parameters: method.parameters, returnType: method.returnType, effects: method.effects));"
625622
.indent(count: 4)
626623
)
627624
}
625+
626+
// Generate property getters and setters
627+
for property in klass.properties {
628+
// Generate getter
629+
let getterThunkBuilder = ExportedThunkBuilder(effects: Effects(isAsync: false, isThrows: false))
630+
getterThunkBuilder.lowerSelf()
631+
let getterReturnExpr = getterThunkBuilder.call(
632+
abiName: property.getterAbiName(className: klass.name),
633+
returnType: property.type
634+
)
635+
jsLines.append(
636+
contentsOf: getterThunkBuilder.renderFunction(
637+
name: property.name,
638+
parameters: [],
639+
returnExpr: getterReturnExpr,
640+
declarationPrefixKeyword: "get"
641+
).map { $0.indent(count: 4) }
642+
)
643+
644+
// Generate setter if not readonly
645+
if !property.isReadonly {
646+
let setterThunkBuilder = ExportedThunkBuilder(effects: Effects(isAsync: false, isThrows: false))
647+
setterThunkBuilder.lowerSelf()
648+
setterThunkBuilder.lowerParameter(param: Parameter(label: "value", name: "value", type: property.type))
649+
_ = setterThunkBuilder.call(
650+
abiName: property.setterAbiName(className: klass.name),
651+
returnType: .void
652+
)
653+
jsLines.append(
654+
contentsOf: setterThunkBuilder.renderFunction(
655+
name: property.name,
656+
parameters: [.init(label: nil, name: "value", type: property.type)],
657+
returnExpr: nil,
658+
declarationPrefixKeyword: "set"
659+
).map { $0.indent(count: 4) }
660+
)
661+
}
662+
663+
// Add TypeScript property definition
664+
let readonly = property.isReadonly ? "readonly " : ""
665+
dtsTypeLines.append(
666+
"\(readonly)\(property.name): \(property.type.tsType);"
667+
.indent(count: 4)
668+
)
669+
}
670+
628671
jsLines.append("}")
629672

630673
dtsTypeLines.append("}")

0 commit comments

Comments
 (0)