Skip to content

Commit 9c8cb0b

Browse files
committed
BridgeJS: Allow extensions to contain types
1 parent 3966530 commit 9c8cb0b

23 files changed

Lines changed: 3301 additions & 57 deletions

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 57 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -525,12 +525,12 @@ public final class SwiftToSkeleton {
525525

526526
if let typeDecl = typeDeclResolver.resolve(type) {
527527
if typeDecl.is(ProtocolDeclSyntax.self) {
528-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: typeDecl, itemName: typeDecl.name.text)
528+
let swiftCallName = computeSwiftCallName(for: typeDecl, itemName: typeDecl.name.text)
529529
return .swiftProtocol(swiftCallName)
530530
}
531531

532532
if let enumDecl = typeDecl.as(EnumDeclSyntax.self) {
533-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: enumDecl, itemName: enumDecl.name.text)
533+
let swiftCallName = computeSwiftCallName(for: enumDecl, itemName: enumDecl.name.text)
534534
if let jsAttribute = enumDecl.attributes.firstJSAttribute,
535535
let aliasTarget = extractAliasTarget(from: jsAttribute)
536536
{
@@ -569,7 +569,7 @@ public final class SwiftToSkeleton {
569569
}
570570

571571
if let structDecl = typeDecl.as(StructDeclSyntax.self) {
572-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(
572+
let swiftCallName = computeSwiftCallName(
573573
for: structDecl,
574574
itemName: structDecl.name.text
575575
)
@@ -587,7 +587,7 @@ public final class SwiftToSkeleton {
587587
guard typeDecl.is(ClassDeclSyntax.self) || typeDecl.is(ActorDeclSyntax.self) else {
588588
return nil
589589
}
590-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: typeDecl, itemName: typeDecl.name.text)
590+
let swiftCallName = computeSwiftCallName(for: typeDecl, itemName: typeDecl.name.text)
591591

592592
// A type annotated with @JSClass is a JavaScript object wrapper (imported),
593593
// even if it is declared as a Swift class.
@@ -627,7 +627,7 @@ public final class SwiftToSkeleton {
627627
private func resolveExternal(for type: TypeSyntax, errors: inout [DiagnosticError]) -> BridgeType? {
628628
guard
629629
!externalModuleIndex.isEmpty,
630-
var components = typeDeclResolver.qualifiedComponents(from: type)
630+
var components = type.qualifiedComponents
631631
else {
632632
return nil
633633
}
@@ -766,27 +766,50 @@ public final class SwiftToSkeleton {
766766
return nil
767767
}
768768

769-
/// Computes the full Swift call name by walking up the AST hierarchy to find all parent enums
769+
/// This currently doesn’t work correctly for extensions on types defined in other modules,
770+
/// which is fine for now since we don’t support extending @JS types from other modules.
771+
/// This will need updating when we do.
772+
fileprivate func enclosingDeclarations(of node: some SyntaxProtocol) -> [Syntax] {
773+
var declarations: [Syntax] = []
774+
var visitedExtendedTypes: Set<SyntaxIdentifier> = []
775+
var currentNode: Syntax? = Syntax(node).parent
776+
777+
while let parent = currentNode {
778+
if let extensionDecl = parent.as(ExtensionDeclSyntax.self) {
779+
if let extendedDecl = typeDeclResolver.resolve(extensionDecl.extendedType),
780+
visitedExtendedTypes.insert(extendedDecl.id).inserted
781+
{
782+
declarations.append(Syntax(extendedDecl))
783+
currentNode = Syntax(extendedDecl).parent
784+
} else {
785+
currentNode = parent.parent
786+
}
787+
} else {
788+
declarations.append(parent)
789+
currentNode = parent.parent
790+
}
791+
}
792+
return declarations
793+
}
794+
770795
/// This generates the qualified name needed for Swift code generation (e.g., "Networking.API.HTTPServer")
771-
fileprivate static func computeSwiftCallName(for node: some SyntaxProtocol, itemName: String) -> String {
796+
fileprivate func computeSwiftCallName(for node: some SyntaxProtocol, itemName: String) -> String {
772797
var swiftPath: [String] = []
773-
var currentNode: Syntax? = node.parent
774798

775-
while let parent = currentNode {
776-
if let enumDecl = parent.as(EnumDeclSyntax.self),
799+
for declaration in enclosingDeclarations(of: node) {
800+
if let enumDecl = declaration.as(EnumDeclSyntax.self),
777801
enumDecl.attributes.hasJSAttribute()
778802
{
779803
swiftPath.insert(enumDecl.name.text, at: 0)
780-
} else if let structDecl = parent.as(StructDeclSyntax.self),
804+
} else if let structDecl = declaration.as(StructDeclSyntax.self),
781805
structDecl.attributes.hasJSAttribute()
782806
{
783807
swiftPath.insert(structDecl.name.text, at: 0)
784-
} else if let classDecl = parent.as(ClassDeclSyntax.self),
808+
} else if let classDecl = declaration.as(ClassDeclSyntax.self),
785809
classDecl.attributes.hasJSAttribute()
786810
{
787811
swiftPath.insert(classDecl.name.text, at: 0)
788812
}
789-
currentNode = parent.parent
790813
}
791814

792815
if swiftPath.isEmpty {
@@ -1861,7 +1884,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
18611884
resolvedNamespace: namespaceResult.namespace,
18621885
parentTypeNamespace: computeParentTypeNamespace(for: node)
18631886
)
1864-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: node, itemName: name)
1887+
let swiftCallName = parent.computeSwiftCallName(for: node, itemName: name)
18651888
let explicitAccessControl = computeExplicitAtLeastInternalAccessControl(
18661889
for: node,
18671890
message: "Class visibility must be at least internal"
@@ -1921,25 +1944,23 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
19211944
}
19221945

19231946
/// Walks extension members under the matching type’s state, returning whether the type was found.
1924-
///
1925-
/// Note: The lookup scans dictionaries keyed by `makeKey(name:namespace:)`, matching only by
1926-
/// plain name. If two types share a name but differ by namespace, `.first(where:)` picks
1927-
/// whichever comes first. This is acceptable today since namespace collisions are unlikely,
1928-
/// but may need refinement if namespace-qualified extension resolution is added.
19291947
func resolveExtension(_ ext: ExtensionDeclSyntax) -> Bool {
1930-
let name = ext.extendedType.trimmedDescription
1948+
guard let extendedDecl = parent.typeDeclResolver.resolve(ext.extendedType) else {
1949+
return false
1950+
}
1951+
let swiftCallName = parent.computeSwiftCallName(for: extendedDecl, itemName: extendedDecl.name.text)
19311952
let state: State
1932-
if let entry = exportedClassByName.first(where: { $0.value.name == name }) {
1933-
state = .classBody(name: name, key: entry.key)
1934-
} else if let entry = exportedStructByName.first(where: { $0.value.name == name }) {
1935-
state = .structBody(name: name, key: entry.key)
1936-
} else if let entry = exportedEnumByName.first(where: { $0.value.name == name }) {
1937-
state = .enumBody(name: name, key: entry.key)
1938-
} else if exportedProtocolByName.values.contains(where: { $0.name == name }) {
1953+
if let entry = exportedClassByName.first(where: { $0.value.swiftCallName == swiftCallName }) {
1954+
state = .classBody(name: entry.value.name, key: entry.key)
1955+
} else if let entry = exportedStructByName.first(where: { $0.value.swiftCallName == swiftCallName }) {
1956+
state = .structBody(name: entry.value.name, key: entry.key)
1957+
} else if let entry = exportedEnumByName.first(where: { $0.value.swiftCallName == swiftCallName }) {
1958+
state = .enumBody(name: entry.value.name, key: entry.key)
1959+
} else if exportedProtocolByName.values.contains(where: { $0.name == swiftCallName }) {
19391960
diagnose(
19401961
node: ext.extendedType,
19411962
message: "Protocol extensions are not supported by BridgeJS.",
1942-
hint: "You cannot extend `@JS` protocol '\(name)' with additional members"
1963+
hint: "You cannot extend `@JS` protocol '\(swiftCallName)' with additional members"
19431964
)
19441965
return true
19451966
} else {
@@ -1958,7 +1979,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
19581979
jsAttribute: AttributeSyntax,
19591980
aliasTarget: TypeSyntax
19601981
) {
1961-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: node, itemName: node.name.text)
1982+
let swiftCallName = parent.computeSwiftCallName(for: node, itemName: node.name.text)
19621983
if extractNamespace(from: jsAttribute) != nil {
19631984
errors.append(
19641985
DiagnosticError(
@@ -2023,7 +2044,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
20232044
parentTypeNamespace: computeParentTypeNamespace(for: node)
20242045
)
20252046
let emitStyle = extractEnumStyle(from: jsAttribute) ?? .const
2026-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: node, itemName: name)
2047+
let swiftCallName = parent.computeSwiftCallName(for: node, itemName: name)
20272048
let explicitAccessControl = computeExplicitAtLeastInternalAccessControl(
20282049
for: node,
20292050
message: "Enum visibility must be at least internal"
@@ -2209,7 +2230,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
22092230
resolvedNamespace: namespaceResult.namespace,
22102231
parentTypeNamespace: computeParentTypeNamespace(for: node)
22112232
)
2212-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: node, itemName: name)
2233+
let swiftCallName = parent.computeSwiftCallName(for: node, itemName: name)
22132234
let explicitAccessControl = computeExplicitAtLeastInternalAccessControl(
22142235
for: node,
22152236
message: "Struct visibility must be at least internal"
@@ -2524,10 +2545,9 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
25242545
/// Method allows for explicit namespace for top level enum, it will be used as base namespace and will concat enum name
25252546
private func computeNamespace(for node: some SyntaxProtocol) -> [String]? {
25262547
var namespace: [String] = []
2527-
var currentNode: Syntax? = node.parent
25282548

2529-
while let parent = currentNode {
2530-
if let enumDecl = parent.as(EnumDeclSyntax.self),
2549+
for declaration in parent.enclosingDeclarations(of: node) {
2550+
if let enumDecl = declaration.as(EnumDeclSyntax.self),
25312551
enumDecl.attributes.hasJSAttribute()
25322552
{
25332553
let isNamespaceEnum = !enumDecl.memberBlock.members.contains { member in
@@ -2544,27 +2564,24 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
25442564
}
25452565
}
25462566
}
2547-
currentNode = parent.parent
25482567
}
25492568

25502569
return namespace.isEmpty ? nil : namespace
25512570
}
25522571

25532572
private func computeParentTypeNamespace(for node: some SyntaxProtocol) -> [String]? {
25542573
var path: [String] = []
2555-
var currentNode: Syntax? = node.parent
25562574

2557-
while let parent = currentNode {
2558-
if let structDecl = parent.as(StructDeclSyntax.self),
2575+
for declaration in parent.enclosingDeclarations(of: node) {
2576+
if let structDecl = declaration.as(StructDeclSyntax.self),
25592577
structDecl.attributes.hasJSAttribute()
25602578
{
25612579
path.insert(structDecl.name.text, at: 0)
2562-
} else if let classDecl = parent.as(ClassDeclSyntax.self),
2580+
} else if let classDecl = declaration.as(ClassDeclSyntax.self),
25632581
classDecl.attributes.hasJSAttribute()
25642582
{
25652583
path.insert(classDecl.name.text, at: 0)
25662584
}
2567-
currentNode = parent.parent
25682585
}
25692586

25702587
return path.isEmpty ? nil : path

Plugins/BridgeJS/Sources/BridgeJSCore/TypeDeclResolver.swift

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ class TypeDeclResolver {
1616

1717
private class TypeDeclCollector: SyntaxVisitor {
1818
let resolver: TypeDeclResolver
19-
var scope: [TypeDecl] = []
20-
var rootTypeDecls: [TypeDecl] = []
19+
var scope: [String] = []
2120

2221
init(resolver: TypeDeclResolver) {
2322
self.resolver = resolver
@@ -26,17 +25,14 @@ class TypeDeclResolver {
2625

2726
func visitNominalDecl(_ node: TypeDecl) -> SyntaxVisitorContinueKind {
2827
let name = node.name.text
29-
let qualifiedName = scope.map(\.name.text) + [name]
28+
let qualifiedName = scope + [name]
3029
resolver.typeDeclByQualifiedName[qualifiedName] = node
31-
scope.append(node)
30+
scope.append(name)
3231
return .visitChildren
3332
}
3433

3534
func visitPostNominalDecl() {
36-
let type = scope.removeLast()
37-
if scope.isEmpty {
38-
rootTypeDecls.append(type)
39-
}
35+
scope.removeLast()
4036
}
4137

4238
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
@@ -72,10 +68,21 @@ class TypeDeclResolver {
7268

7369
override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind {
7470
let name = node.name.text
75-
let qualifiedName = scope.map(\.name.text) + [name]
71+
let qualifiedName = scope + [name]
7672
resolver.typeAliasByQualifiedName[qualifiedName] = node
7773
return .skipChildren
7874
}
75+
76+
override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind {
77+
guard let components = node.memberScopeComponents else {
78+
return .skipChildren
79+
}
80+
scope.append(contentsOf: components)
81+
return .visitChildren
82+
}
83+
override func visitPost(_ node: ExtensionDeclSyntax) {
84+
scope.removeLast(node.memberScopeComponents?.count ?? 0)
85+
}
7986
}
8087

8188
/// Collects type declarations from a parsed Swift source file
@@ -91,6 +98,10 @@ class TypeDeclResolver {
9198
while let parent = context.parent {
9299
if let parent = parent.asProtocol(NamedDeclSyntax.self), parent.isProtocol(DeclGroupSyntax.self) {
93100
innerToOuter.append(parent.name.text)
101+
} else if let extensionDecl = parent.as(ExtensionDeclSyntax.self),
102+
let components = extensionDecl.memberScopeComponents
103+
{
104+
innerToOuter.append(contentsOf: components.reversed())
94105
}
95106
context = parent
96107
}
@@ -106,7 +117,7 @@ class TypeDeclResolver {
106117
/// Search for the type declaration from the innermost scope to the outermost scope
107118
for i in (0...scope.count).reversed() {
108119
let qualifiedName = Array(scope[0..<i] + [name])
109-
if typeDeclByQualifiedName[qualifiedName] != nil {
120+
if typeDeclByQualifiedName[qualifiedName] != nil || typeAliasByQualifiedName[qualifiedName] != nil {
110121
return qualifiedName
111122
}
112123
}
@@ -132,15 +143,15 @@ class TypeDeclResolver {
132143
///
133144
/// Resolution strategy:
134145
/// 1. If the node is IdentifierTypeSyntax, call `lookupType(for:)` which attempts scope-aware qualification via `tryQualify`.
135-
/// 2. Otherwise, attempt to build a fully qualified name with `qualifiedComponents(from:)` and look it up with `lookupType(fullyQualified:)`.
146+
/// 2. Otherwise, attempt to build a fully qualified name with `qualifiedComponents` and look it up with `lookupType(fullyQualified:)`.
136147
///
137148
/// - Parameter type: The SwiftSyntax node representing a type appearance in source code.
138149
/// - Returns: The nominal declaration (enum/class/actor/struct) if found, otherwise nil.
139150
func resolve(_ type: TypeSyntax) -> TypeDecl? {
140151
if let id = type.as(IdentifierTypeSyntax.self) {
141152
return lookupType(for: id)
142153
}
143-
if let components = qualifiedComponents(from: type) {
154+
if let components = type.qualifiedComponents {
144155
return lookupType(fullyQualified: components)
145156
}
146157
return nil
@@ -155,20 +166,29 @@ class TypeDeclResolver {
155166
let qualifiedName = tryQualify(type: id)
156167
return typeAliasByQualifiedName[qualifiedName]
157168
}
158-
if let components = qualifiedComponents(from: type) {
169+
if let components = type.qualifiedComponents {
159170
return typeAliasByQualifiedName[components]
160171
}
161172
return nil
162173
}
163174

164-
func qualifiedComponents(from type: TypeSyntax) -> QualifiedName? {
165-
if let m = type.as(MemberTypeSyntax.self) {
166-
guard let base = qualifiedComponents(from: TypeSyntax(m.baseType)) else { return nil }
175+
}
176+
177+
extension TypeSyntax {
178+
var qualifiedComponents: TypeDeclResolver.QualifiedName? {
179+
if let m = self.as(MemberTypeSyntax.self) {
180+
guard let base = TypeSyntax(m.baseType).qualifiedComponents else { return nil }
167181
return base + [m.name.text]
168-
} else if let id = type.as(IdentifierTypeSyntax.self) {
182+
} else if let id = self.as(IdentifierTypeSyntax.self) {
169183
return [id.name.text]
170184
} else {
171185
return nil
172186
}
173187
}
174188
}
189+
190+
extension ExtensionDeclSyntax {
191+
var memberScopeComponents: TypeDeclResolver.QualifiedName? {
192+
extendedType.qualifiedComponents
193+
}
194+
}

Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,28 @@ import Testing
336336
try snapshotCodegen(skeleton: skeleton, name: "CrossFileExtension")
337337
}
338338

339+
@Test
340+
func codegenCrossFileNestedTypeExtension() throws {
341+
let swiftAPI = SwiftToSkeleton(
342+
progress: .silent,
343+
moduleName: "TestModule",
344+
exposeToGlobal: false,
345+
externalModuleIndex: .empty
346+
)
347+
let classURL = Self.multifileInputsDirectory.appendingPathComponent("CrossFileNestedTypeClass.swift")
348+
swiftAPI.addSourceFile(
349+
Parser.parse(source: try String(contentsOf: classURL, encoding: .utf8)),
350+
inputFilePath: "CrossFileNestedTypeClass.swift"
351+
)
352+
let extensionURL = Self.multifileInputsDirectory.appendingPathComponent("CrossFileNestedTypeExtension.swift")
353+
swiftAPI.addSourceFile(
354+
Parser.parse(source: try String(contentsOf: extensionURL, encoding: .utf8)),
355+
inputFilePath: "CrossFileNestedTypeExtension.swift"
356+
)
357+
let skeleton = try swiftAPI.finalize()
358+
try snapshotCodegen(skeleton: skeleton, name: "CrossFileNestedTypeExtension")
359+
}
360+
339361
@Test
340362
func codegenSkipsEmptySkeletons() throws {
341363
let swiftAPI = SwiftToSkeleton(

0 commit comments

Comments
 (0)