From 9589496efabbc294678183be55f176460d0b8d8b Mon Sep 17 00:00:00 2001 From: Krzysztof Rodak Date: Mon, 16 Feb 2026 11:49:07 +0100 Subject: [PATCH 1/2] BridgeJS: Add additionalSourceDirs config for cross-plugin file discovery --- .../BridgeJSBuildPlugin.swift | 58 ++++++++++++++++++- .../BridgeJS/Sources/BridgeJSCore/Misc.swift | 43 +++++++++++++- .../Sources/BridgeJSTool/BridgeJSTool.swift | 11 ++++ 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift b/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift index aec9f3bca..55015d41b 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift @@ -32,6 +32,11 @@ struct BridgeJSBuildPlugin: BuildToolPlugin { inputFiles.append(configFile) } + let additionalDirs = resolveAdditionalSourceDirs(targetDirectory: target.directoryURL) + for dir in additionalDirs { + inputFiles.append(contentsOf: recursivelyCollectSwiftFiles(from: dir)) + } + let inputTSFile = target.directoryURL.appending(path: "bridge-js.d.ts") let tsconfigPath = context.package.directoryURL.appending(path: "tsconfig.json") @@ -47,7 +52,6 @@ struct BridgeJSBuildPlugin: BuildToolPlugin { ] if FileManager.default.fileExists(atPath: inputTSFile.path) { - // Add .d.ts file and tsconfig.json as inputs inputFiles.append(contentsOf: [inputTSFile, tsconfigPath]) arguments.append(contentsOf: [ "--project", @@ -66,4 +70,56 @@ struct BridgeJSBuildPlugin: BuildToolPlugin { ) } } + +private struct PluginConfig: Decodable { + var additionalSourceDirs: [String]? +} + +private func resolveAdditionalSourceDirs(targetDirectory: URL) -> [URL] { + let configFiles = [ + targetDirectory.appending(path: "bridge-js.config.json"), + targetDirectory.appending(path: "bridge-js.config.local.json"), + ] + var dirs: [String] = [] + for file in configFiles { + guard FileManager.default.fileExists(atPath: file.path), + let data = try? Data(contentsOf: file), + let config = try? JSONDecoder().decode(PluginConfig.self, from: data), + let additional = config.additionalSourceDirs + else { continue } + dirs.append(contentsOf: additional) + } + return dirs.compactMap { dir in + let resolved = targetDirectory.appending(path: dir).standardized + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: resolved.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { + return nil + } + return resolved + } +} + +private func recursivelyCollectSwiftFiles(from directory: URL) -> [URL] { + var swiftFiles: [URL] = [] + guard + let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) + else { + return [] + } + for case let fileURL as URL in enumerator { + if fileURL.pathExtension == "swift" { + let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]) + if resourceValues?.isRegularFile == true { + swiftFiles.append(fileURL) + } + } + } + return swiftFiles.sorted { $0.path < $1.path } +} #endif diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/Misc.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/Misc.swift index 9db11b14d..2b166d1be 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/Misc.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/Misc.swift @@ -309,6 +309,7 @@ public struct BridgeJSCoreError: Swift.Error, CustomStringConvertible { import struct Foundation.URL import struct Foundation.Data +import struct Foundation.ObjCBool import class Foundation.FileManager import class Foundation.JSONDecoder @@ -328,20 +329,33 @@ public struct BridgeJSConfig: Codable { /// Default: `false` public var exposeToGlobal: Bool - public init(tools: [String: String]? = nil, exposeToGlobal: Bool = false) { + /// Additional directories containing Swift source files to scan for + /// `@JS` annotations. + /// + /// Paths are resolved relative to the target directory. This is useful + /// when Swift files with `@JS` annotations are generated by another + /// build plugin whose outputs aren't included in `target.sourceFiles`. + /// + /// Default: `nil` (no additional directories) + public var additionalSourceDirs: [String]? + + public init(tools: [String: String]? = nil, exposeToGlobal: Bool = false, additionalSourceDirs: [String]? = nil) { self.tools = tools self.exposeToGlobal = exposeToGlobal + self.additionalSourceDirs = additionalSourceDirs } enum CodingKeys: String, CodingKey { case tools case exposeToGlobal + case additionalSourceDirs } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) tools = try container.decodeIfPresent([String: String].self, forKey: .tools) exposeToGlobal = try container.decodeIfPresent(Bool.self, forKey: .exposeToGlobal) ?? false + additionalSourceDirs = try container.decodeIfPresent([String].self, forKey: .additionalSourceDirs) } /// Load the configuration file from the SwiftPM package target directory. @@ -380,11 +394,36 @@ public struct BridgeJSConfig: Codable { return try JSONDecoder().decode(BridgeJSConfig.self, from: data) } + /// Resolve additional source directories relative to a base URL. + /// + /// Returns absolute URLs for each configured additional source directory. + /// Directories that don't exist are silently skipped. + public func resolveAdditionalSourceDirs(relativeTo baseURL: URL) -> [URL] { + guard let dirs = additionalSourceDirs else { return [] } + return dirs.compactMap { dir in + let resolved = baseURL.appending(path: dir).standardized + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: resolved.path, isDirectory: &isDirectory), + isDirectory.boolValue + else { + return nil + } + return resolved + } + } + /// Merge the current configuration with the overrides. func merging(overrides: BridgeJSConfig) -> BridgeJSConfig { + let mergedDirs: [String]? = { + let base = self.additionalSourceDirs ?? [] + let extra = overrides.additionalSourceDirs ?? [] + let combined = base + extra + return combined.isEmpty ? nil : combined + }() return BridgeJSConfig( tools: (tools ?? [:]).merging(overrides.tools ?? [:], uniquingKeysWith: { $1 }), - exposeToGlobal: overrides.exposeToGlobal + exposeToGlobal: overrides.exposeToGlobal, + additionalSourceDirs: mergedDirs ) } } diff --git a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift index 3b784b732..a699dc9c8 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift @@ -146,6 +146,17 @@ import BridgeJSUtilities var inputFiles = withSpan("Collecting Swift files") { return inputSwiftFiles(targetDirectory: targetDirectory, positionalArguments: positionalArguments) } + + let additionalDirs = config.resolveAdditionalSourceDirs(relativeTo: targetDirectory) + for dir in additionalDirs { + let additionalFiles = recursivelyCollectSwiftFiles(from: dir).map(\.path) + .filter { !inputFiles.contains($0) } + if !additionalFiles.isEmpty { + progress.print("Found \(additionalFiles.count) additional Swift files in \(dir.lastPathComponent)") + } + inputFiles.append(contentsOf: additionalFiles) + } + // BridgeJS.Macros.swift contains imported declarations (@JSFunction, @JSClass, etc.) that need // to be processed by SwiftToSkeleton to populate the imported skeleton. The command plugin // filters out Generated/ files, so we explicitly add it here after generation. From 53f69d4522c0aa971aeef9e901607b48a3a92441 Mon Sep 17 00:00:00 2001 From: Krzysztof Rodak Date: Mon, 16 Feb 2026 12:57:43 +0100 Subject: [PATCH 2/2] BridgeJS: Test different approach for cross-plugin file discovery --- .../BridgeJSBuildPlugin.swift | 65 +++---------------- .../BridgeJS/Sources/BridgeJSCore/Misc.swift | 43 +----------- .../Sources/BridgeJSTool/BridgeJSTool.swift | 10 --- 3 files changed, 11 insertions(+), 107 deletions(-) diff --git a/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift b/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift index 55015d41b..3cb6dc860 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift @@ -32,10 +32,14 @@ struct BridgeJSBuildPlugin: BuildToolPlugin { inputFiles.append(configFile) } - let additionalDirs = resolveAdditionalSourceDirs(targetDirectory: target.directoryURL) - for dir in additionalDirs { - inputFiles.append(contentsOf: recursivelyCollectSwiftFiles(from: dir)) + // Include Swift files generated by other plugins applied to this + // target (available in tools-version 6.0+). This lets BridgeJS + // process @JS annotations in files produced by earlier plugins + // without requiring any extra configuration. + let pluginGeneratedSwiftFiles = target.pluginGeneratedSources.filter { + $0.pathExtension == "swift" } + inputFiles.append(contentsOf: pluginGeneratedSwiftFiles) let inputTSFile = target.directoryURL.appending(path: "bridge-js.d.ts") let tsconfigPath = context.package.directoryURL.appending(path: "tsconfig.json") @@ -59,7 +63,8 @@ struct BridgeJSBuildPlugin: BuildToolPlugin { ]) } - arguments.append(contentsOf: inputSwiftFiles.map(\.path)) + let allSwiftFiles = inputSwiftFiles + pluginGeneratedSwiftFiles + arguments.append(contentsOf: allSwiftFiles.map(\.path)) return .buildCommand( displayName: "Generate BridgeJS code", @@ -70,56 +75,4 @@ struct BridgeJSBuildPlugin: BuildToolPlugin { ) } } - -private struct PluginConfig: Decodable { - var additionalSourceDirs: [String]? -} - -private func resolveAdditionalSourceDirs(targetDirectory: URL) -> [URL] { - let configFiles = [ - targetDirectory.appending(path: "bridge-js.config.json"), - targetDirectory.appending(path: "bridge-js.config.local.json"), - ] - var dirs: [String] = [] - for file in configFiles { - guard FileManager.default.fileExists(atPath: file.path), - let data = try? Data(contentsOf: file), - let config = try? JSONDecoder().decode(PluginConfig.self, from: data), - let additional = config.additionalSourceDirs - else { continue } - dirs.append(contentsOf: additional) - } - return dirs.compactMap { dir in - let resolved = targetDirectory.appending(path: dir).standardized - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: resolved.path, isDirectory: &isDirectory), - isDirectory.boolValue - else { - return nil - } - return resolved - } -} - -private func recursivelyCollectSwiftFiles(from directory: URL) -> [URL] { - var swiftFiles: [URL] = [] - guard - let enumerator = FileManager.default.enumerator( - at: directory, - includingPropertiesForKeys: [.isRegularFileKey], - options: [.skipsHiddenFiles] - ) - else { - return [] - } - for case let fileURL as URL in enumerator { - if fileURL.pathExtension == "swift" { - let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]) - if resourceValues?.isRegularFile == true { - swiftFiles.append(fileURL) - } - } - } - return swiftFiles.sorted { $0.path < $1.path } -} #endif diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/Misc.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/Misc.swift index 2b166d1be..9db11b14d 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/Misc.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/Misc.swift @@ -309,7 +309,6 @@ public struct BridgeJSCoreError: Swift.Error, CustomStringConvertible { import struct Foundation.URL import struct Foundation.Data -import struct Foundation.ObjCBool import class Foundation.FileManager import class Foundation.JSONDecoder @@ -329,33 +328,20 @@ public struct BridgeJSConfig: Codable { /// Default: `false` public var exposeToGlobal: Bool - /// Additional directories containing Swift source files to scan for - /// `@JS` annotations. - /// - /// Paths are resolved relative to the target directory. This is useful - /// when Swift files with `@JS` annotations are generated by another - /// build plugin whose outputs aren't included in `target.sourceFiles`. - /// - /// Default: `nil` (no additional directories) - public var additionalSourceDirs: [String]? - - public init(tools: [String: String]? = nil, exposeToGlobal: Bool = false, additionalSourceDirs: [String]? = nil) { + public init(tools: [String: String]? = nil, exposeToGlobal: Bool = false) { self.tools = tools self.exposeToGlobal = exposeToGlobal - self.additionalSourceDirs = additionalSourceDirs } enum CodingKeys: String, CodingKey { case tools case exposeToGlobal - case additionalSourceDirs } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) tools = try container.decodeIfPresent([String: String].self, forKey: .tools) exposeToGlobal = try container.decodeIfPresent(Bool.self, forKey: .exposeToGlobal) ?? false - additionalSourceDirs = try container.decodeIfPresent([String].self, forKey: .additionalSourceDirs) } /// Load the configuration file from the SwiftPM package target directory. @@ -394,36 +380,11 @@ public struct BridgeJSConfig: Codable { return try JSONDecoder().decode(BridgeJSConfig.self, from: data) } - /// Resolve additional source directories relative to a base URL. - /// - /// Returns absolute URLs for each configured additional source directory. - /// Directories that don't exist are silently skipped. - public func resolveAdditionalSourceDirs(relativeTo baseURL: URL) -> [URL] { - guard let dirs = additionalSourceDirs else { return [] } - return dirs.compactMap { dir in - let resolved = baseURL.appending(path: dir).standardized - var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: resolved.path, isDirectory: &isDirectory), - isDirectory.boolValue - else { - return nil - } - return resolved - } - } - /// Merge the current configuration with the overrides. func merging(overrides: BridgeJSConfig) -> BridgeJSConfig { - let mergedDirs: [String]? = { - let base = self.additionalSourceDirs ?? [] - let extra = overrides.additionalSourceDirs ?? [] - let combined = base + extra - return combined.isEmpty ? nil : combined - }() return BridgeJSConfig( tools: (tools ?? [:]).merging(overrides.tools ?? [:], uniquingKeysWith: { $1 }), - exposeToGlobal: overrides.exposeToGlobal, - additionalSourceDirs: mergedDirs + exposeToGlobal: overrides.exposeToGlobal ) } } diff --git a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift index a699dc9c8..f7adbfb8e 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift @@ -147,16 +147,6 @@ import BridgeJSUtilities return inputSwiftFiles(targetDirectory: targetDirectory, positionalArguments: positionalArguments) } - let additionalDirs = config.resolveAdditionalSourceDirs(relativeTo: targetDirectory) - for dir in additionalDirs { - let additionalFiles = recursivelyCollectSwiftFiles(from: dir).map(\.path) - .filter { !inputFiles.contains($0) } - if !additionalFiles.isEmpty { - progress.print("Found \(additionalFiles.count) additional Swift files in \(dir.lastPathComponent)") - } - inputFiles.append(contentsOf: additionalFiles) - } - // BridgeJS.Macros.swift contains imported declarations (@JSFunction, @JSClass, etc.) that need // to be processed by SwiftToSkeleton to populate the imported skeleton. The command plugin // filters out Generated/ files, so we explicitly add it here after generation.