Skip to content

Commit 2e36a76

Browse files
committed
BridgeJS: Allow extensions to contain types
1 parent c3ac9da commit 2e36a76

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
@@ -536,12 +536,12 @@ public final class SwiftToSkeleton {
536536

537537
if let typeDecl = typeDeclResolver.resolve(type) {
538538
if typeDecl.is(ProtocolDeclSyntax.self) {
539-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: typeDecl, itemName: typeDecl.name.text)
539+
let swiftCallName = computeSwiftCallName(for: typeDecl, itemName: typeDecl.name.text)
540540
return .swiftProtocol(swiftCallName)
541541
}
542542

543543
if let enumDecl = typeDecl.as(EnumDeclSyntax.self) {
544-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: enumDecl, itemName: enumDecl.name.text)
544+
let swiftCallName = computeSwiftCallName(for: enumDecl, itemName: enumDecl.name.text)
545545
if let jsAttribute = enumDecl.attributes.firstJSAttribute,
546546
let aliasTarget = extractAliasTarget(from: jsAttribute)
547547
{
@@ -580,7 +580,7 @@ public final class SwiftToSkeleton {
580580
}
581581

582582
if let structDecl = typeDecl.as(StructDeclSyntax.self) {
583-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(
583+
let swiftCallName = computeSwiftCallName(
584584
for: structDecl,
585585
itemName: structDecl.name.text
586586
)
@@ -598,7 +598,7 @@ public final class SwiftToSkeleton {
598598
guard typeDecl.is(ClassDeclSyntax.self) || typeDecl.is(ActorDeclSyntax.self) else {
599599
return nil
600600
}
601-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: typeDecl, itemName: typeDecl.name.text)
601+
let swiftCallName = computeSwiftCallName(for: typeDecl, itemName: typeDecl.name.text)
602602

603603
// A type annotated with @JSClass is a JavaScript object wrapper (imported),
604604
// even if it is declared as a Swift class.
@@ -638,7 +638,7 @@ public final class SwiftToSkeleton {
638638
private func resolveExternal(for type: TypeSyntax, errors: inout [DiagnosticError]) -> BridgeType? {
639639
guard
640640
!externalModuleIndex.isEmpty,
641-
var components = typeDeclResolver.qualifiedComponents(from: type)
641+
var components = type.qualifiedComponents
642642
else {
643643
return nil
644644
}
@@ -777,27 +777,50 @@ public final class SwiftToSkeleton {
777777
return nil
778778
}
779779

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

786-
while let parent = currentNode {
787-
if let enumDecl = parent.as(EnumDeclSyntax.self),
810+
for declaration in enclosingDeclarations(of: node) {
811+
if let enumDecl = declaration.as(EnumDeclSyntax.self),
788812
enumDecl.attributes.hasJSAttribute()
789813
{
790814
swiftPath.insert(enumDecl.name.text, at: 0)
791-
} else if let structDecl = parent.as(StructDeclSyntax.self),
815+
} else if let structDecl = declaration.as(StructDeclSyntax.self),
792816
structDecl.attributes.hasJSAttribute()
793817
{
794818
swiftPath.insert(structDecl.name.text, at: 0)
795-
} else if let classDecl = parent.as(ClassDeclSyntax.self),
819+
} else if let classDecl = declaration.as(ClassDeclSyntax.self),
796820
classDecl.attributes.hasJSAttribute()
797821
{
798822
swiftPath.insert(classDecl.name.text, at: 0)
799823
}
800-
currentNode = parent.parent
801824
}
802825

803826
if swiftPath.isEmpty {
@@ -1883,7 +1906,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
18831906
resolvedNamespace: namespaceResult.namespace,
18841907
parentTypeNamespace: computeParentTypeNamespace(for: node)
18851908
)
1886-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: node, itemName: name)
1909+
let swiftCallName = parent.computeSwiftCallName(for: node, itemName: name)
18871910
let explicitAccessControl = computeExplicitAtLeastInternalAccessControl(
18881911
for: node,
18891912
message: "Class visibility must be at least internal"
@@ -1949,25 +1972,23 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
19491972
}
19501973

19511974
/// Walks extension members under the matching type’s state, returning whether the type was found.
1952-
///
1953-
/// Note: The lookup scans dictionaries keyed by `makeKey(name:namespace:)`, matching only by
1954-
/// plain name. If two types share a name but differ by namespace, `.first(where:)` picks
1955-
/// whichever comes first. This is acceptable today since namespace collisions are unlikely,
1956-
/// but may need refinement if namespace-qualified extension resolution is added.
19571975
func resolveExtension(_ ext: ExtensionDeclSyntax) -> Bool {
1958-
let name = ext.extendedType.trimmedDescription
1976+
guard let extendedDecl = parent.typeDeclResolver.resolve(ext.extendedType) else {
1977+
return false
1978+
}
1979+
let swiftCallName = parent.computeSwiftCallName(for: extendedDecl, itemName: extendedDecl.name.text)
19591980
let state: State
1960-
if let entry = exportedClassByName.first(where: { $0.value.name == name }) {
1961-
state = .classBody(name: name, key: entry.key)
1962-
} else if let entry = exportedStructByName.first(where: { $0.value.name == name }) {
1963-
state = .structBody(name: name, key: entry.key)
1964-
} else if let entry = exportedEnumByName.first(where: { $0.value.name == name }) {
1965-
state = .enumBody(name: name, key: entry.key)
1966-
} else if exportedProtocolByName.values.contains(where: { $0.name == name }) {
1981+
if let entry = exportedClassByName.first(where: { $0.value.swiftCallName == swiftCallName }) {
1982+
state = .classBody(name: entry.value.name, key: entry.key)
1983+
} else if let entry = exportedStructByName.first(where: { $0.value.swiftCallName == swiftCallName }) {
1984+
state = .structBody(name: entry.value.name, key: entry.key)
1985+
} else if let entry = exportedEnumByName.first(where: { $0.value.swiftCallName == swiftCallName }) {
1986+
state = .enumBody(name: entry.value.name, key: entry.key)
1987+
} else if exportedProtocolByName.values.contains(where: { $0.name == swiftCallName }) {
19671988
diagnose(
19681989
node: ext.extendedType,
19691990
message: "Protocol extensions are not supported by BridgeJS.",
1970-
hint: "You cannot extend `@JS` protocol '\(name)' with additional members"
1991+
hint: "You cannot extend `@JS` protocol '\(swiftCallName)' with additional members"
19711992
)
19721993
return true
19731994
} else {
@@ -1986,7 +2007,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
19862007
jsAttribute: AttributeSyntax,
19872008
aliasTarget: TypeSyntax
19882009
) {
1989-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: node, itemName: node.name.text)
2010+
let swiftCallName = parent.computeSwiftCallName(for: node, itemName: node.name.text)
19902011
if extractNamespace(from: jsAttribute) != nil {
19912012
errors.append(
19922013
DiagnosticError(
@@ -2051,7 +2072,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
20512072
parentTypeNamespace: computeParentTypeNamespace(for: node)
20522073
)
20532074
let emitStyle = extractEnumStyle(from: jsAttribute) ?? .const
2054-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: node, itemName: name)
2075+
let swiftCallName = parent.computeSwiftCallName(for: node, itemName: name)
20552076
let explicitAccessControl = computeExplicitAtLeastInternalAccessControl(
20562077
for: node,
20572078
message: "Enum visibility must be at least internal"
@@ -2237,7 +2258,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
22372258
resolvedNamespace: namespaceResult.namespace,
22382259
parentTypeNamespace: computeParentTypeNamespace(for: node)
22392260
)
2240-
let swiftCallName = SwiftToSkeleton.computeSwiftCallName(for: node, itemName: name)
2261+
let swiftCallName = parent.computeSwiftCallName(for: node, itemName: name)
22412262
let explicitAccessControl = computeExplicitAtLeastInternalAccessControl(
22422263
for: node,
22432264
message: "Struct visibility must be at least internal"
@@ -2552,10 +2573,9 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
25522573
/// Method allows for explicit namespace for top level enum, it will be used as base namespace and will concat enum name
25532574
private func computeNamespace(for node: some SyntaxProtocol) -> [String]? {
25542575
var namespace: [String] = []
2555-
var currentNode: Syntax? = node.parent
25562576

2557-
while let parent = currentNode {
2558-
if let enumDecl = parent.as(EnumDeclSyntax.self),
2577+
for declaration in parent.enclosingDeclarations(of: node) {
2578+
if let enumDecl = declaration.as(EnumDeclSyntax.self),
25592579
enumDecl.attributes.hasJSAttribute()
25602580
{
25612581
let isNamespaceEnum = !enumDecl.memberBlock.members.contains { member in
@@ -2572,27 +2592,24 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
25722592
}
25732593
}
25742594
}
2575-
currentNode = parent.parent
25762595
}
25772596

25782597
return namespace.isEmpty ? nil : namespace
25792598
}
25802599

25812600
private func computeParentTypeNamespace(for node: some SyntaxProtocol) -> [String]? {
25822601
var path: [String] = []
2583-
var currentNode: Syntax? = node.parent
25842602

2585-
while let parent = currentNode {
2586-
if let structDecl = parent.as(StructDeclSyntax.self),
2603+
for declaration in parent.enclosingDeclarations(of: node) {
2604+
if let structDecl = declaration.as(StructDeclSyntax.self),
25872605
structDecl.attributes.hasJSAttribute()
25882606
{
25892607
path.insert(structDecl.name.text, at: 0)
2590-
} else if let classDecl = parent.as(ClassDeclSyntax.self),
2608+
} else if let classDecl = declaration.as(ClassDeclSyntax.self),
25912609
classDecl.attributes.hasJSAttribute()
25922610
{
25932611
path.insert(classDecl.name.text, at: 0)
25942612
}
2595-
currentNode = parent.parent
25962613
}
25972614

25982615
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)