Skip to content

Commit ed57d5e

Browse files
- Added @JSGetter(jsName: String? = nil) so generated getters can target non-Swift/quoted JS property names (Sources/JavaScriptKit/Macros.swift).
- Plumbed `jsName` through the imported-property IR (`Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift`) and extraction (`Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift`). - Updated TS→Swift generation to no longer drop quoted/invalid TS property names; it now emits Swift-safe identifiers plus `@JSGetter(jsName: ...)` / `@JSSetter(jsName: ...)` as needed (`Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js`). - Updated JS glue + generated `.d.ts` to use bracket property access and quote invalid TS property keys (`Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift`). - Updated snapshots; `swift test --package-path ./Plugins/BridgeJS` now passes (requires `npm ci` at repo root to provide `typescript`).
1 parent 7b0ef78 commit ed57d5e

File tree

13 files changed

+414
-80
lines changed

13 files changed

+414
-80
lines changed

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1762,6 +1762,12 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
17621762
hasAttribute(attributes, name: "JSGetter")
17631763
}
17641764

1765+
static func firstJSGetterAttribute(_ attributes: AttributeListSyntax?) -> AttributeSyntax? {
1766+
attributes?.first { attribute in
1767+
attribute.as(AttributeSyntax.self)?.attributeName.trimmedDescription == "JSGetter"
1768+
}?.as(AttributeSyntax.self)
1769+
}
1770+
17651771
static func hasJSSetterAttribute(_ attributes: AttributeListSyntax?) -> Bool {
17661772
hasAttribute(attributes, name: "JSSetter")
17671773
}
@@ -1784,7 +1790,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
17841790
}
17851791
}
17861792

1787-
/// Extracts the jsName argument value from a @JSSetter attribute, if present.
1793+
/// Extracts the `jsName` argument value from an attribute, if present.
17881794
static func extractJSName(from attribute: AttributeSyntax) -> String? {
17891795
guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else {
17901796
return nil
@@ -1883,22 +1889,15 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
18831889

18841890
// MARK: - Property Name Resolution
18851891

1886-
/// Helper for resolving property names from setter function names and jsName attributes
1892+
/// Helper for resolving property names from setter function names.
18871893
private struct PropertyNameResolver {
1888-
/// Resolves property name and function base name from a setter function and optional jsName
1889-
/// - Returns: (propertyName, functionBaseName) where propertyName preserves case for getter matching,
1890-
/// and functionBaseName has lowercase first char for ABI generation
1894+
/// Resolves property name and function base name from a setter function.
1895+
/// - Returns: (propertyName, functionBaseName) where `propertyName` is derived from the setter name,
1896+
/// and `functionBaseName` has lowercase first char for ABI generation.
18911897
static func resolve(
18921898
functionName: String,
1893-
jsName: String?,
18941899
normalizeIdentifier: (String) -> String
18951900
) -> (propertyName: String, functionBaseName: String)? {
1896-
if let jsName = jsName {
1897-
let propertyName = normalizeIdentifier(jsName)
1898-
let functionBaseName = propertyName.prefix(1).lowercased() + propertyName.dropFirst()
1899-
return (propertyName: propertyName, functionBaseName: functionBaseName)
1900-
}
1901-
19021901
let rawFunctionName =
19031902
functionName.hasPrefix("`") && functionName.hasSuffix("`") && functionName.count > 2
19041903
? String(functionName.dropFirst().dropLast())
@@ -2065,10 +2064,13 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
20652064
guard AttributeChecker.hasJSGetterAttribute(node.attributes) else {
20662065
return .visitChildren
20672066
}
2067+
guard let jsGetter = AttributeChecker.firstJSGetterAttribute(node.attributes) else {
2068+
return .skipChildren
2069+
}
20682070

20692071
switch state {
20702072
case .topLevel:
2071-
if let getter = parseGetterSkeleton(node) {
2073+
if let getter = parseGetterSkeleton(jsGetter, node) {
20722074
importedGlobalGetters.append(getter)
20732075
}
20742076
return .skipChildren
@@ -2085,7 +2087,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
20852087
"@JSGetter is not supported for static members. Use it only for instance members in @JSClass types."
20862088
)
20872089
)
2088-
} else if let getter = parseGetterSkeleton(node) {
2090+
} else if let getter = parseGetterSkeleton(jsGetter, node) {
20892091
type.getters.append(getter)
20902092
currentType = type
20912093
}
@@ -2223,16 +2225,21 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
22232225
return (identifier, typeAnnotation.type)
22242226
}
22252227

2226-
private func parseGetterSkeleton(_ node: VariableDeclSyntax) -> ImportedGetterSkeleton? {
2228+
private func parseGetterSkeleton(
2229+
_ jsGetter: AttributeSyntax,
2230+
_ node: VariableDeclSyntax
2231+
) -> ImportedGetterSkeleton? {
22272232
guard let (identifier, type) = extractPropertyInfo(node) else {
22282233
return nil
22292234
}
22302235
guard let propertyType = withLookupErrors({ parent.lookupType(for: type, errors: &$0) }) else {
22312236
return nil
22322237
}
22332238
let propertyName = SwiftToSkeleton.normalizeIdentifier(identifier.identifier.text)
2239+
let jsName = AttributeChecker.extractJSName(from: jsGetter)
22342240
return ImportedGetterSkeleton(
22352241
name: propertyName,
2242+
jsName: jsName,
22362243
type: propertyType,
22372244
documentation: nil,
22382245
functionName: nil
@@ -2252,7 +2259,6 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
22522259
guard
22532260
let (propertyName, functionBaseName) = PropertyNameResolver.resolve(
22542261
functionName: functionName,
2255-
jsName: validation.jsName,
22562262
normalizeIdentifier: SwiftToSkeleton.normalizeIdentifier
22572263
)
22582264
else {
@@ -2261,6 +2267,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
22612267

22622268
return ImportedSetterSkeleton(
22632269
name: propertyName,
2270+
jsName: validation.jsName,
22642271
type: validation.valueType,
22652272
documentation: nil,
22662273
functionName: "\(functionBaseName)_set"

Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,17 +1202,20 @@ public struct BridgeJSLink {
12021202
// Add properties from getters
12031203
var propertyNames = Set<String>()
12041204
for getter in type.getters {
1205-
propertyNames.insert(getter.name)
1206-
let hasSetter = type.setters.contains { $0.name == getter.name }
1205+
let propertyName = getter.jsName ?? getter.name
1206+
propertyNames.insert(propertyName)
1207+
let hasSetter = type.setters.contains { ($0.jsName ?? $0.name) == propertyName }
12071208
let propertySignature =
12081209
hasSetter
1209-
? "\(getter.name): \(resolveTypeScriptType(getter.type));"
1210-
: "readonly \(getter.name): \(resolveTypeScriptType(getter.type));"
1210+
? "\(renderTSPropertyName(propertyName)): \(resolveTypeScriptType(getter.type));"
1211+
: "readonly \(renderTSPropertyName(propertyName)): \(resolveTypeScriptType(getter.type));"
12111212
printer.write(propertySignature)
12121213
}
12131214
// Add setters that don't have corresponding getters
1214-
for setter in type.setters where !propertyNames.contains(setter.name) {
1215-
printer.write("\(setter.name): \(resolveTypeScriptType(setter.type));")
1215+
for setter in type.setters {
1216+
let propertyName = setter.jsName ?? setter.name
1217+
guard !propertyNames.contains(propertyName) else { continue }
1218+
printer.write("\(renderTSPropertyName(propertyName)): \(resolveTypeScriptType(setter.type));")
12161219
}
12171220

12181221
printer.unindent()
@@ -1387,6 +1390,20 @@ public struct BridgeJSLink {
13871390
return "(\(parameterSignatures.joined(separator: ", "))): \(returnTypeWithEffect)"
13881391
}
13891392

1393+
private func renderTSPropertyName(_ name: String) -> String {
1394+
// TypeScript allows quoted property names for keys that aren't valid identifiers.
1395+
if name.range(of: #"^[$A-Z_][0-9A-Z_$]*$"#, options: [.regularExpression, .caseInsensitive]) != nil {
1396+
return name
1397+
}
1398+
return "\"\(Self.escapeForJavaScriptStringLiteral(name))\""
1399+
}
1400+
1401+
fileprivate static func escapeForJavaScriptStringLiteral(_ string: String) -> String {
1402+
string
1403+
.replacingOccurrences(of: "\\", with: "\\\\")
1404+
.replacingOccurrences(of: "\"", with: "\\\"")
1405+
}
1406+
13901407
/// Helper method to append JSDoc comments for parameters with default values
13911408
private func appendJSDocIfNeeded(for parameters: [Parameter], to lines: inout [String]) {
13921409
let jsDocLines = DefaultValueUtils.formatJSDoc(for: parameters)
@@ -2151,14 +2168,17 @@ extension BridgeJSLink {
21512168
}
21522169

21532170
func callPropertyGetter(name: String, returnType: BridgeType) throws -> String? {
2171+
let escapedName = BridgeJSLink.escapeForJavaScriptStringLiteral(name)
2172+
let accessExpr =
2173+
"\(JSGlueVariableScope.reservedSwift).memory.getObject(self)[\"\(escapedName)\"]"
21542174
if context == .exportSwift, returnType.usesSideChannelForOptionalReturn() {
21552175
guard case .optional(let wrappedType) = returnType else {
21562176
fatalError("usesSideChannelForOptionalReturn returned true for non-optional type")
21572177
}
21582178

21592179
let resultVar = scope.variable("ret")
21602180
body.write(
2161-
"let \(resultVar) = \(JSGlueVariableScope.reservedSwift).memory.getObject(self).\(name);"
2181+
"let \(resultVar) = \(accessExpr);"
21622182
)
21632183

21642184
let fragment = try IntrinsicJSFragment.protocolPropertyOptionalToSideChannel(wrappedType: wrappedType)
@@ -2168,14 +2188,15 @@ extension BridgeJSLink {
21682188
}
21692189

21702190
return try call(
2171-
callExpr: "\(JSGlueVariableScope.reservedSwift).memory.getObject(self).\(name)",
2191+
callExpr: accessExpr,
21722192
returnType: returnType
21732193
)
21742194
}
21752195

21762196
func callPropertySetter(name: String, returnType: BridgeType) {
2197+
let escapedName = BridgeJSLink.escapeForJavaScriptStringLiteral(name)
21772198
let call =
2178-
"\(JSGlueVariableScope.reservedSwift).memory.getObject(self).\(name) = \(parameterForwardings.joined(separator: ", "))"
2199+
"\(JSGlueVariableScope.reservedSwift).memory.getObject(self)[\"\(escapedName)\"] = \(parameterForwardings.joined(separator: ", "))"
21792200
body.write("\(call);")
21802201
}
21812202

@@ -2185,7 +2206,8 @@ extension BridgeJSLink {
21852206
}
21862207

21872208
let loweringFragment = try IntrinsicJSFragment.lowerReturn(type: returnType, context: context)
2188-
let expr = "imports[\"\(name)\"]"
2209+
let escapedName = BridgeJSLink.escapeForJavaScriptStringLiteral(name)
2210+
let expr = "imports[\"\(escapedName)\"]"
21892211

21902212
let returnExpr: String?
21912213
if loweringFragment.parameters.count == 0 {
@@ -2948,14 +2970,15 @@ extension BridgeJSLink {
29482970
getter: ImportedGetterSkeleton
29492971
) throws {
29502972
let thunkBuilder = ImportedThunkBuilder()
2951-
let returnExpr = try thunkBuilder.getImportProperty(name: getter.name, returnType: getter.type)
2973+
let jsName = getter.jsName ?? getter.name
2974+
let returnExpr = try thunkBuilder.getImportProperty(name: jsName, returnType: getter.type)
29522975
let abiName = getter.abiName(context: nil)
29532976
let funcLines = thunkBuilder.renderFunction(
29542977
name: abiName,
29552978
returnExpr: returnExpr,
29562979
returnType: getter.type
29572980
)
2958-
importObjectBuilder.appendDts(["readonly \(getter.name): \(getter.type.tsType);"])
2981+
importObjectBuilder.appendDts(["readonly \(renderTSPropertyName(jsName)): \(getter.type.tsType);"])
29592982
importObjectBuilder.assignToImportObject(name: abiName, function: funcLines)
29602983
}
29612984

@@ -2976,7 +2999,10 @@ extension BridgeJSLink {
29762999
getter: getter,
29773000
abiName: getterAbiName,
29783001
emitCall: { thunkBuilder in
2979-
return try thunkBuilder.callPropertyGetter(name: getter.name, returnType: getter.type)
3002+
return try thunkBuilder.callPropertyGetter(
3003+
name: getter.jsName ?? getter.name,
3004+
returnType: getter.type
3005+
)
29803006
}
29813007
)
29823008
importObjectBuilder.assignToImportObject(name: getterAbiName, function: js)
@@ -2992,7 +3018,7 @@ extension BridgeJSLink {
29923018
try thunkBuilder.liftParameter(
29933019
param: Parameter(label: nil, name: "newValue", type: setter.type)
29943020
)
2995-
thunkBuilder.callPropertySetter(name: setter.name, returnType: setter.type)
3021+
thunkBuilder.callPropertySetter(name: setter.jsName ?? setter.name, returnType: setter.type)
29963022
return nil
29973023
}
29983024
)

Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,18 +626,22 @@ public struct ImportedConstructorSkeleton: Codable {
626626

627627
public struct ImportedGetterSkeleton: Codable {
628628
public let name: String
629+
/// The JavaScript property name to read from, if different from `name`.
630+
public let jsName: String?
629631
public let type: BridgeType
630632
public let documentation: String?
631633
/// Name of the getter function if it's a separate function (from @JSGetter)
632634
public let functionName: String?
633635

634636
public init(
635637
name: String,
638+
jsName: String? = nil,
636639
type: BridgeType,
637640
documentation: String? = nil,
638641
functionName: String? = nil
639642
) {
640643
self.name = name
644+
self.jsName = jsName
641645
self.type = type
642646
self.documentation = documentation
643647
self.functionName = functionName
@@ -661,18 +665,22 @@ public struct ImportedGetterSkeleton: Codable {
661665

662666
public struct ImportedSetterSkeleton: Codable {
663667
public let name: String
668+
/// The JavaScript property name to write to, if different from `name`.
669+
public let jsName: String?
664670
public let type: BridgeType
665671
public let documentation: String?
666672
/// Name of the setter function if it's a separate function (from @JSSetter)
667673
public let functionName: String?
668674

669675
public init(
670676
name: String,
677+
jsName: String? = nil,
671678
type: BridgeType,
672679
documentation: String? = nil,
673680
functionName: String? = nil
674681
) {
675682
self.name = name
683+
self.jsName = jsName
676684
self.type = type
677685
self.documentation = documentation
678686
self.functionName = functionName

Plugins/BridgeJS/Sources/TS2Swift/JavaScript/src/processor.js

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -390,21 +390,28 @@ export class TypeProcessor {
390390

391391
/**
392392
* @param {ts.PropertyDeclaration | ts.PropertySignature} node
393-
* @returns {{ name: string, type: string, isReadonly: boolean, documentation: string | undefined } | null}
393+
* @returns {{ jsName: string, swiftName: string, type: string, isReadonly: boolean, documentation: string | undefined } | null}
394394
*/
395395
visitPropertyDecl(node) {
396396
if (!node.name) return null;
397-
398-
const propertyName = node.name.getText();
399-
if (!isValidSwiftDeclName(propertyName)) {
397+
/** @type {string | null} */
398+
let jsName = null;
399+
if (ts.isIdentifier(node.name)) {
400+
jsName = node.name.text;
401+
} else if (ts.isStringLiteral(node.name) || ts.isNumericLiteral(node.name)) {
402+
jsName = node.name.text;
403+
} else {
404+
// Computed property names like `[Symbol.iterator]` are not supported yet.
400405
return null;
401406
}
402407

408+
const swiftName = isValidSwiftDeclName(jsName) ? jsName : makeValidSwiftIdentifier(jsName, { emptyFallback: "_" });
409+
403410
const type = this.checker.getTypeAtLocation(node)
404411
const swiftType = this.visitType(type, node);
405412
const isReadonly = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ReadonlyKeyword) ?? false;
406413
const documentation = this.getFullJSDocText(node);
407-
return { name: propertyName, type: swiftType, isReadonly, documentation };
414+
return { jsName, swiftName, type: swiftType, isReadonly, documentation };
408415
}
409416

410417
/**
@@ -626,17 +633,21 @@ export class TypeProcessor {
626633
if (!property) return;
627634

628635
const type = property.type;
629-
const name = this.renderIdentifier(property.name);
636+
const swiftName = this.renderIdentifier(property.swiftName);
637+
const needsJSGetterName = property.jsName !== property.swiftName;
638+
const escapedJSName = property.jsName.replaceAll("\\", "\\\\").replaceAll("\"", "\\\\\"");
639+
const getterAnnotation = needsJSGetterName ? `@JSGetter(jsName: "${escapedJSName}")` : "@JSGetter";
630640

631641
// Always render getter
632-
this.swiftLines.push(` @JSGetter var ${name}: ${type}`);
642+
this.swiftLines.push(` ${getterAnnotation} var ${swiftName}: ${type}`);
633643

634644
// Render setter if not readonly
635645
if (!property.isReadonly) {
636-
const capitalizedName = property.name.charAt(0).toUpperCase() + property.name.slice(1);
637-
const needsJSNameField = property.name.charAt(0) != capitalizedName.charAt(0).toLowerCase();
638-
const setterName = `set${capitalizedName}`;
639-
const annotation = needsJSNameField ? `@JSSetter(jsName: "${property.name}")` : "@JSSetter";
646+
const capitalizedSwiftName = property.swiftName.charAt(0).toUpperCase() + property.swiftName.slice(1);
647+
const derivedPropertyName = property.swiftName.charAt(0).toLowerCase() + property.swiftName.slice(1);
648+
const needsJSNameField = property.jsName !== derivedPropertyName;
649+
const setterName = `set${capitalizedSwiftName}`;
650+
const annotation = needsJSNameField ? `@JSSetter(jsName: "${escapedJSName}")` : "@JSSetter";
640651
this.swiftLines.push(` ${annotation} func ${this.renderIdentifier(setterName)}(_ value: ${type}) ${this.renderEffects({ isAsync: false })}`);
641652
}
642653
}

Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/InvalidPropertyNames.Import.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ export interface ArrayBufferLike {
1111
export interface WeirdNaming {
1212
as(): void;
1313
normalProperty: string;
14+
"property-with-dashes": number;
15+
"123invalidStart": boolean;
16+
"property with spaces": string;
17+
"@specialChar": number;
18+
constructor: string;
1419
for: string;
1520
Any: string;
1621
}

0 commit comments

Comments
 (0)