From b0d6b3f578927b720cbe691cc488e427b6dc4b0f Mon Sep 17 00:00:00 2001 From: William Taylor Date: Fri, 14 Aug 2026 15:47:36 +1000 Subject: [PATCH] BridgeJS: Emit diagnostics from extensions properly --- .../BridgeJSCore/SwiftToSkeleton.swift | 38 ++++++++++++++++--- .../BridgeJSToolTests/DiagnosticsTests.swift | 38 +++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift index f37bfb822..937ec5c41 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift @@ -222,15 +222,14 @@ public final class SwiftToSkeleton { validatedJavaScriptModulePaths.insert(path) } - let exportErrors = exportCollector.errors.filter { $0.severity == .error } let importErrorsFatal = importCollector.errors.filter { $0.severity == .error && !$0.message.contains("Unsupported type '") } - let fileWarnings = (exportCollector.errors + importCollector.errors).filter { $0.severity == .warning } + let fileWarnings = importCollector.errors.filter { $0.severity == .warning } warnings.append(contentsOf: fileWarnings.map { (file: inputFilePath, diagnostic: $0) }) - if !exportErrors.isEmpty || !importErrorsFatal.isEmpty { + if !importErrorsFatal.isEmpty { perSourceErrors.append( - (inputFilePath: inputFilePath, errors: exportErrors + importErrorsFatal) + (inputFilePath: inputFilePath, errors: importErrorsFatal) ) } @@ -249,6 +248,18 @@ public final class SwiftToSkeleton { source.resolveDeferredExtensions(against: exportCollectors) } + // We have to collect diagnostics after all deferred extensions are resolved, since they could generate some. + for ((_, inputFilePath), exportCollector) in zip(sourceFiles, exportCollectors) { + let exportErrors = exportCollector.errors.filter { $0.severity == .error } + let fileWarnings = exportCollector.errors.filter { $0.severity == .warning } + warnings.append(contentsOf: fileWarnings.map { (file: inputFilePath, diagnostic: $0) }) + if !exportErrors.isEmpty { + perSourceErrors.append( + (inputFilePath: inputFilePath, errors: exportErrors) + ) + } + } + for collector in exportCollectors { collector.finalize(&exported) } @@ -858,6 +869,17 @@ extension AttributeListSyntax { } } +private final class JSAttributeFinder: SyntaxVisitor { + private(set) var found = false + + override func visit(_ node: AttributeSyntax) -> SyntaxVisitorContinueKind { + if node.attributeNameText == "JS" { + found = true + } + return .skipChildren + } +} + private final class ExportSwiftAPICollector: SyntaxAnyVisitor { var exportedFunctions: [ExportedFunction] = [] /// The names of the exported classes, in the order they were written in the source file @@ -1910,7 +1932,7 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor { break } } - if !resolved { + if !resolved, containsJSAnnotatedDeclaration(ext.memberBlock.members) { diagnose( node: ext.extendedType, message: "Unsupported type '\(ext.extendedType.trimmedDescription)'.", @@ -1920,6 +1942,12 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor { } } + private func containsJSAnnotatedDeclaration(_ members: MemberBlockItemListSyntax) -> Bool { + let finder = JSAttributeFinder(viewMode: .sourceAccurate) + finder.walk(members) + return finder.found + } + /// Walks extension members under the matching type’s state, returning whether the type was found. /// /// Note: The lookup scans dictionaries keyed by `makeKey(name:namespace:)`, matching only by diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift index 5abdf8fb2..4f45a9880 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift @@ -26,6 +26,44 @@ import Testing } } + @Test + func extensionOfUnknownTypeWithJSMemberProducesDiagnostic() throws { + let source = """ + extension Unknown { + @JS func bridged() -> Int { 42 } + } + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("Unsupported type 'Unknown'")) + } + + @Test + func extensionWithoutJSMembersIsIgnored() throws { + let source = """ + extension String { + func helper() -> Int { 42 } + } + """ + #expect(moduleDiagnostics(source: source) == nil) + } + + @Test + func invalidJSMemberInsideExtensionProducesDiagnostic() throws { + let source = """ + @JS class Host { + @JS init() {} + } + + extension Host { + @JS struct Bad { + var field = 1 + } + } + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("Struct field must have explicit type annotation")) + } + @Test func missingJavaScriptModuleProducesDiagnostic() throws { let source = """