Skip to content

Commit fc46afd

Browse files
BridgeJS: Add support for @js properties
Enables JavaScript getter/setter access to Swift class properties marked with @js. Includes complete implementation with runtime tests and TypeScript definitions.
1 parent 15e9491 commit fc46afd

File tree

14 files changed

+336
-18
lines changed

14 files changed

+336
-18
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: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,57 @@ 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 isReadonly = node.bindingSpecifier.tokenKind == .keyword(.let)
313+
314+
let exportedProperty = ExportedProperty(
315+
name: propertyName,
316+
type: propertyType,
317+
isReadonly: isReadonly
318+
)
319+
320+
exportedClassByName[className]?.properties.append(exportedProperty)
321+
}
322+
323+
return .skipChildren
324+
}
325+
275326
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
276327
let name = node.name.text
277328

@@ -284,6 +335,7 @@ public class ExportSwift {
284335
name: name,
285336
constructor: nil,
286337
methods: [],
338+
properties: [],
287339
namespace: namespace
288340
)
289341
exportedClassNames.append(name)
@@ -492,6 +544,21 @@ public class ExportSwift {
492544
append(item)
493545
}
494546

547+
func callPropertyGetter(klassName: String, propertyName: String, returnType: BridgeType) {
548+
let _selfParam = self.abiParameterForwardings.removeFirst()
549+
let retMutability = returnType == .string ? "var" : "let"
550+
if returnType == .void {
551+
append("\(raw: _selfParam).\(raw: propertyName)")
552+
} else {
553+
append("\(raw: retMutability) ret = \(raw: _selfParam).\(raw: propertyName)")
554+
}
555+
}
556+
557+
func callPropertySetter(klassName: String, propertyName: String) {
558+
let _selfParam = self.abiParameterForwardings.removeFirst()
559+
append("\(raw: _selfParam).\(raw: propertyName) = value")
560+
}
561+
495562
func lowerReturnValue(returnType: BridgeType) {
496563
if effects.isAsync {
497564
// Async functions always return a Promise, which is a JSObject
@@ -717,6 +784,39 @@ public class ExportSwift {
717784
decls.append(builder.render(abiName: method.abiName))
718785
}
719786

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

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("}")

Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,20 @@ public struct ExportedClass: Codable {
6363
public var name: String
6464
public var constructor: ExportedConstructor?
6565
public var methods: [ExportedFunction]
66+
public var properties: [ExportedProperty]
6667
public var namespace: [String]?
6768

6869
public init(
6970
name: String,
7071
constructor: ExportedConstructor? = nil,
7172
methods: [ExportedFunction],
73+
properties: [ExportedProperty] = [],
7274
namespace: [String]? = nil
7375
) {
7476
self.name = name
7577
self.constructor = constructor
7678
self.methods = methods
79+
self.properties = properties
7780
self.namespace = namespace
7881
}
7982
}
@@ -92,6 +95,26 @@ public struct ExportedConstructor: Codable {
9295
}
9396
}
9497

98+
public struct ExportedProperty: Codable {
99+
public var name: String
100+
public var type: BridgeType
101+
public var isReadonly: Bool
102+
103+
public init(name: String, type: BridgeType, isReadonly: Bool = false) {
104+
self.name = name
105+
self.type = type
106+
self.isReadonly = isReadonly
107+
}
108+
109+
public func getterAbiName(className: String) -> String {
110+
return "bjs_\(className)_\(name)_get"
111+
}
112+
113+
public func setterAbiName(className: String) -> String {
114+
return "bjs_\(className)_\(name)_set"
115+
}
116+
}
117+
95118
public struct ExportedSkeleton: Codable {
96119
public let moduleName: String
97120
public let functions: [ExportedFunction]

Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/SwiftClass.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
@JS class Greeter {
2-
var name: String
2+
@JS var name: String
33

44
@JS init(name: String) {
55
self.name = name

Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/SwiftClass.Export.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface SwiftHeapObject {
1414
export interface Greeter extends SwiftHeapObject {
1515
greet(): string;
1616
changeName(name: string): void;
17+
name: string;
1718
}
1819
export type Exports = {
1920
Greeter: {

Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/SwiftClass.Export.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,18 @@ export async function createInstantiator(options, swift) {
113113
instance.exports.bjs_Greeter_changeName(this.pointer, nameId, nameBytes.length);
114114
swift.memory.release(nameId);
115115
}
116+
get name() {
117+
instance.exports.bjs_Greeter_name_get(this.pointer);
118+
const ret = tmpRetString;
119+
tmpRetString = undefined;
120+
return ret;
121+
}
122+
set name(value) {
123+
const valueBytes = textEncoder.encode(value);
124+
const valueId = swift.memory.retain(valueBytes);
125+
instance.exports.bjs_Greeter_name_set(this.pointer, valueId, valueBytes.length);
126+
swift.memory.release(valueId);
127+
}
116128
}
117129
return {
118130
Greeter,

Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/Namespaces.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141
"namespace" : [
4242
"__Swift",
4343
"Foundation"
44+
],
45+
"properties" : [
46+
4447
]
4548
},
4649
{
@@ -84,6 +87,9 @@
8487
"namespace" : [
8588
"Utils",
8689
"Converters"
90+
],
91+
"properties" : [
92+
8793
]
8894
},
8995
{
@@ -109,6 +115,9 @@
109115
"namespace" : [
110116
"__Swift",
111117
"Foundation"
118+
],
119+
"properties" : [
120+
112121
]
113122
}
114123
],

Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/ExportSwiftTests/SwiftClass.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,18 @@
6161
}
6262
}
6363
],
64-
"name" : "Greeter"
64+
"name" : "Greeter",
65+
"properties" : [
66+
{
67+
"isReadonly" : false,
68+
"name" : "name",
69+
"type" : {
70+
"string" : {
71+
72+
}
73+
}
74+
}
75+
]
6576
}
6677
],
6778
"functions" : [

0 commit comments

Comments
 (0)