diff --git a/Sources/ScipioKit/BuildOptions.swift b/Sources/ScipioKit/BuildOptions.swift index 0eee5af2..327dc244 100644 --- a/Sources/ScipioKit/BuildOptions.swift +++ b/Sources/ScipioKit/BuildOptions.swift @@ -12,6 +12,7 @@ struct BuildOptions: Hashable, Codable, Sendable { extraFlags: ExtraFlags?, extraBuildParameters: ExtraBuildParameters?, enableLibraryEvolution: Bool, + keepPublicHeadersStructure: Bool, customFrameworkModuleMapContents: Data? ) { self.buildConfiguration = buildConfiguration @@ -21,6 +22,7 @@ struct BuildOptions: Hashable, Codable, Sendable { self.extraFlags = extraFlags self.extraBuildParameters = extraBuildParameters self.enableLibraryEvolution = enableLibraryEvolution + self.keepPublicHeadersStructure = keepPublicHeadersStructure self.customFrameworkModuleMapContents = customFrameworkModuleMapContents } @@ -31,6 +33,7 @@ struct BuildOptions: Hashable, Codable, Sendable { let extraFlags: ExtraFlags? let extraBuildParameters: ExtraBuildParameters? let enableLibraryEvolution: Bool + let keepPublicHeadersStructure: Bool /// A custom framework modulemap contents /// - Note: It have to store the actual file contents rather than its path, /// because the cache key should change when the file contents change. diff --git a/Sources/ScipioKit/Producer/PIF/FrameworkBundleAssembler.swift b/Sources/ScipioKit/Producer/PIF/FrameworkBundleAssembler.swift index 69fd33e8..3141616b 100644 --- a/Sources/ScipioKit/Producer/PIF/FrameworkBundleAssembler.swift +++ b/Sources/ScipioKit/Producer/PIF/FrameworkBundleAssembler.swift @@ -5,6 +5,7 @@ import TSCBasic /// This assembler just relocates framework components into the framework structure struct FrameworkBundleAssembler { private let frameworkComponents: FrameworkComponents + private let keepPublicHeadersStructure: Bool private let outputDirectory: AbsolutePath private let fileSystem: any FileSystem @@ -12,8 +13,14 @@ struct FrameworkBundleAssembler { outputDirectory.appending(component: "\(frameworkComponents.frameworkName).framework") } - init(frameworkComponents: FrameworkComponents, outputDirectory: AbsolutePath, fileSystem: some FileSystem) { + init( + frameworkComponents: FrameworkComponents, + keepPublicHeadersStructure: Bool, + outputDirectory: AbsolutePath, + fileSystem: some FileSystem + ) { self.frameworkComponents = frameworkComponents + self.keepPublicHeadersStructure = keepPublicHeadersStructure self.outputDirectory = outputDirectory self.fileSystem = fileSystem } @@ -73,11 +80,46 @@ struct FrameworkBundleAssembler { try fileSystem.createDirectory(headerDir) for header in headers { - try fileSystem.copy( - from: header, - to: headerDir.appending(component: header.basename) + if keepPublicHeadersStructure, let includeDir = frameworkComponents.includeDir { + try copyHeaderKeepingStructure( + header: header, + includeDir: includeDir, + into: headerDir + ) + } else { + try fileSystem.copy( + from: header, + to: headerDir.appending(component: header.basename) + ) + } + } + } + + private func copyHeaderKeepingStructure( + header: AbsolutePath, + includeDir: AbsolutePath, + into headerDir: AbsolutePath + ) throws { + let subdirectoryComponents: [String] = if header.dirname.hasPrefix(includeDir.pathString) { + header.dirname.dropFirst(includeDir.pathString.count) + .split(separator: "/") + .map(String.init) + } else { + [] + } + + if !subdirectoryComponents.isEmpty { + try fileSystem.createDirectory( + headerDir.appending(components: subdirectoryComponents), + recursive: true ) } + try fileSystem.copy( + from: header, + to: headerDir + .appending(components: subdirectoryComponents) + .appending(component: header.basename) + ) } private func copyModules() throws { diff --git a/Sources/ScipioKit/Producer/PIF/FrameworkComponentsCollector.swift b/Sources/ScipioKit/Producer/PIF/FrameworkComponentsCollector.swift index 41c405ee..52a7ccb9 100644 --- a/Sources/ScipioKit/Producer/PIF/FrameworkComponentsCollector.swift +++ b/Sources/ScipioKit/Producer/PIF/FrameworkComponentsCollector.swift @@ -8,6 +8,7 @@ struct FrameworkComponents { var binaryPath: AbsolutePath var infoPlistPath: AbsolutePath var swiftModulesPath: AbsolutePath? + var includeDir: AbsolutePath? var publicHeaderPaths: Set? var bridgingHeaderPath: AbsolutePath? var modulemapPath: AbsolutePath? @@ -85,6 +86,7 @@ struct FrameworkComponentsCollector { binaryPath: binaryPath, infoPlistPath: infoPlistPath, swiftModulesPath: swiftModulesPath, + includeDir: (buildProduct.target.underlying as? ScipioClangModule)?.includeDir.scipioAbsolutePath, publicHeaderPaths: publicHeaders, bridgingHeaderPath: bridgingHeaderPath, modulemapPath: frameworkModuleMapPath, @@ -113,7 +115,7 @@ struct FrameworkComponentsCollector { let frameworkModuleMapPath = try modulemapGenerator.generate( resolvedTarget: buildProduct.target, sdk: sdk, - buildConfiguration: buildOptions.buildConfiguration + keepPublicHeadersStructure: buildOptions.keepPublicHeadersStructure ) return frameworkModuleMapPath } diff --git a/Sources/ScipioKit/Producer/PIF/ModuleMapGenerator.swift b/Sources/ScipioKit/Producer/PIF/FrameworkModuleMapGenerator.swift similarity index 79% rename from Sources/ScipioKit/Producer/PIF/ModuleMapGenerator.swift rename to Sources/ScipioKit/Producer/PIF/FrameworkModuleMapGenerator.swift index 23b91a62..b7060f7f 100644 --- a/Sources/ScipioKit/Producer/PIF/ModuleMapGenerator.swift +++ b/Sources/ScipioKit/Producer/PIF/FrameworkModuleMapGenerator.swift @@ -8,7 +8,7 @@ struct FrameworkModuleMapGenerator { private struct Context { var resolvedTarget: ScipioResolvedModule var sdk: SDK - var configuration: BuildConfiguration + var keepPublicHeadersStructure: Bool } private var packageLocator: any PackageLocator @@ -30,8 +30,16 @@ struct FrameworkModuleMapGenerator { self.fileSystem = fileSystem } - func generate(resolvedTarget: ScipioResolvedModule, sdk: SDK, buildConfiguration: BuildConfiguration) throws -> AbsolutePath? { - let context = Context(resolvedTarget: resolvedTarget, sdk: sdk, configuration: buildConfiguration) + func generate( + resolvedTarget: ScipioResolvedModule, + sdk: SDK, + keepPublicHeadersStructure: Bool + ) throws -> AbsolutePath? { + let context = Context( + resolvedTarget: resolvedTarget, + sdk: sdk, + keepPublicHeadersStructure: keepPublicHeadersStructure + ) if let clangTarget = resolvedTarget.underlying as? ScipioClangModule { switch clangTarget.moduleMapType { @@ -66,7 +74,13 @@ struct FrameworkModuleMapGenerator { .joined() case .umbrellaDirectory(let directoryPath): let headers = try walkDirectoryContents(of: directoryPath.scipioAbsolutePath) - let declarations = headers.map { " header \"\($0)\"" } + let declarations = headers.map { header in + generateHeaderEntry( + for: header, + of: directoryPath.scipioAbsolutePath, + keepPublicHeadersStructure: context.keepPublicHeadersStructure + ) + } return ([ "framework module \(context.resolvedTarget.c99name) {", @@ -92,17 +106,38 @@ struct FrameworkModuleMapGenerator { } } - private func walkDirectoryContents(of directoryPath: AbsolutePath) throws -> Set { + private func walkDirectoryContents(of directoryPath: AbsolutePath) throws -> Set { try fileSystem.getDirectoryContents(directoryPath).reduce(into: Set()) { headers, file in let path = directoryPath.appending(component: file) if fileSystem.isDirectory(path) { headers.formUnion(try walkDirectoryContents(of: path)) } else if file.hasSuffix(".h") { - headers.insert(file) + headers.insert(path) } } } + private func generateHeaderEntry( + for header: AbsolutePath, + of directoryPath: AbsolutePath, + keepPublicHeadersStructure: Bool + ) -> String { + if keepPublicHeadersStructure { + let subdirectoryComponents: [String] = if header.dirname.hasPrefix(directoryPath.pathString) { + header.dirname.dropFirst(directoryPath.pathString.count) + .split(separator: "/") + .map(String.init) + } else { + [] + } + + let path = (subdirectoryComponents + [header.basename]).joined(separator: "/") + return " header \"\(path)\"" + } else { + return " header \"\(header.basename)\"" + } + } + private func generateLinkSection(context: Context) -> [String] { context.resolvedTarget.dependencies .compactMap(\.module?.c99name) diff --git a/Sources/ScipioKit/Producer/PIF/XCBuildClient.swift b/Sources/ScipioKit/Producer/PIF/XCBuildClient.swift index 599dd70d..21e32aed 100644 --- a/Sources/ScipioKit/Producer/PIF/XCBuildClient.swift +++ b/Sources/ScipioKit/Producer/PIF/XCBuildClient.swift @@ -90,6 +90,7 @@ struct XCBuildClient { let assembler = FrameworkBundleAssembler( frameworkComponents: components, + keepPublicHeadersStructure: buildOptions.keepPublicHeadersStructure, outputDirectory: frameworkOutputDir, fileSystem: fileSystem ) diff --git a/Sources/ScipioKit/Runner.swift b/Sources/ScipioKit/Runner.swift index 3a8e4a41..d3451403 100644 --- a/Sources/ScipioKit/Runner.swift +++ b/Sources/ScipioKit/Runner.swift @@ -126,6 +126,9 @@ extension Runner { public var extraFlags: ExtraFlags? public var extraBuildParameters: [String: String]? public var enableLibraryEvolution: Bool + /// For clang target, whether to keep subdirectories in publicHeadersPath or not when copying public headers to Headers directory. + /// If this is false, public headers are copied to Headers directory flattened (default). + public var keepPublicHeadersStructure: Bool /// An option indicates use custom modulemaps for distributionb public var frameworkModuleMapGenerationPolicy: FrameworkModuleMapGenerationPolicy @@ -138,6 +141,7 @@ extension Runner { extraFlags: ExtraFlags? = nil, extraBuildParameters: [String: String]? = nil, enableLibraryEvolution: Bool = false, + keepPublicHeadersStructure: Bool = false, frameworkModuleMapGenerationPolicy: FrameworkModuleMapGenerationPolicy = .autoGenerated ) { self.buildConfiguration = buildConfiguration @@ -148,6 +152,7 @@ extension Runner { self.extraFlags = extraFlags self.extraBuildParameters = extraBuildParameters self.enableLibraryEvolution = enableLibraryEvolution + self.keepPublicHeadersStructure = keepPublicHeadersStructure self.frameworkModuleMapGenerationPolicy = frameworkModuleMapGenerationPolicy } } @@ -160,6 +165,9 @@ extension Runner { public var extraFlags: ExtraFlags? public var extraBuildParameters: [String: String]? public var enableLibraryEvolution: Bool? + /// For clang target, whether to keep subdirectories in publicHeadersPath or not when copying public headers to Headers directory. + /// If this is false or nil, public headers are copied to Headers directory flattened (default). + public var keepPublicHeadersStructure: Bool? public var frameworkModuleMapGenerationPolicy: FrameworkModuleMapGenerationPolicy? public init( @@ -171,6 +179,7 @@ extension Runner { extraFlags: ExtraFlags? = nil, extraBuildParameters: [String: String]? = nil, enableLibraryEvolution: Bool? = nil, + keepPublicHeadersStructure: Bool? = nil, frameworkModuleMapGenerationPolicy: FrameworkModuleMapGenerationPolicy? = nil ) { self.buildConfiguration = buildConfiguration @@ -181,6 +190,7 @@ extension Runner { self.extraBuildParameters = extraBuildParameters self.extraFlags = extraFlags self.enableLibraryEvolution = enableLibraryEvolution + self.keepPublicHeadersStructure = keepPublicHeadersStructure self.frameworkModuleMapGenerationPolicy = frameworkModuleMapGenerationPolicy } } @@ -295,6 +305,7 @@ extension Runner.Options.BuildOptions { extraFlags: extraFlags, extraBuildParameters: extraBuildParameters, enableLibraryEvolution: enableLibraryEvolution, + keepPublicHeadersStructure: keepPublicHeadersStructure, customFrameworkModuleMapContents: customFrameworkModuleMapContents ) } @@ -339,7 +350,14 @@ extension Runner.Options.BuildOptions { extraFlags: mergedExtraFlags, extraBuildParameters: mergedExtraBuildParameters, enableLibraryEvolution: fetch(\.enableLibraryEvolution, by: \.enableLibraryEvolution), - frameworkModuleMapGenerationPolicy: fetch(\.frameworkModuleMapGenerationPolicy, by: \.frameworkModuleMapGenerationPolicy) + keepPublicHeadersStructure: fetch( + \.keepPublicHeadersStructure, + by: \.keepPublicHeadersStructure + ), + frameworkModuleMapGenerationPolicy: fetch( + \.frameworkModuleMapGenerationPolicy, + by: \.frameworkModuleMapGenerationPolicy + ) ) } } diff --git a/Tests/ScipioKitTests/CacheSystemTests.swift b/Tests/ScipioKitTests/CacheSystemTests.swift index 83b1064a..ace30d9a 100644 --- a/Tests/ScipioKitTests/CacheSystemTests.swift +++ b/Tests/ScipioKitTests/CacheSystemTests.swift @@ -18,19 +18,22 @@ final class CacheSystemTests: XCTestCase { """ func testEncodeCacheKey() throws { - let cacheKey = SwiftPMCacheKey(targetName: "MyTarget", - pin: .revision("111111111"), - buildOptions: .init(buildConfiguration: .release, - isDebugSymbolsEmbedded: false, - frameworkType: .dynamic, - sdks: [.iOS], - extraFlags: .init(swiftFlags: ["-D", "SOME_FLAG"]), - extraBuildParameters: ["SWIFT_OPTIMIZATION_LEVEL": "-Osize"], - enableLibraryEvolution: true, - customFrameworkModuleMapContents: Data(customModuleMap.utf8) - ), - clangVersion: "clang-1400.0.29.102", - xcodeVersion: .init(xcodeVersion: "15.4", xcodeBuildVersion: "15F31d") + let cacheKey = SwiftPMCacheKey( + targetName: "MyTarget", + pin: .revision("111111111"), + buildOptions: .init( + buildConfiguration: .release, + isDebugSymbolsEmbedded: false, + frameworkType: .dynamic, + sdks: [.iOS], + extraFlags: .init(swiftFlags: ["-D", "SOME_FLAG"]), + extraBuildParameters: ["SWIFT_OPTIMIZATION_LEVEL": "-Osize"], + enableLibraryEvolution: true, + keepPublicHeadersStructure: false, + customFrameworkModuleMapContents: Data(customModuleMap.utf8) + ), + clangVersion: "clang-1400.0.29.102", + xcodeVersion: .init(xcodeVersion: "15.4", xcodeBuildVersion: "15F31d") ) let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys, .prettyPrinted] @@ -55,6 +58,7 @@ final class CacheSystemTests: XCTestCase { }, "frameworkType" : "dynamic", "isDebugSymbolsEmbedded" : false, + "keepPublicHeadersStructure" : false, "sdks" : [ "iOS" ] @@ -113,6 +117,7 @@ final class CacheSystemTests: XCTestCase { extraFlags: nil, extraBuildParameters: nil, enableLibraryEvolution: false, + keepPublicHeadersStructure: false, customFrameworkModuleMapContents: nil ) ) diff --git a/Tests/ScipioKitTests/FrameworkBundleAssemblerTests.swift b/Tests/ScipioKitTests/FrameworkBundleAssemblerTests.swift new file mode 100644 index 00000000..6563684e --- /dev/null +++ b/Tests/ScipioKitTests/FrameworkBundleAssemblerTests.swift @@ -0,0 +1,65 @@ +import Foundation +@testable import ScipioKit +import Testing +import TSCBasic + +private let fixturesPath = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Resources") + .appendingPathComponent("Fixtures") + +struct FrameworkBundleAssemblerTests { + let fileSystem = localFileSystem + let temporaryDirectory: AbsolutePath + + init() throws { + self.temporaryDirectory = try fileSystem + .tempDirectory + .appending(components: "FrameworkBundleAssemblerTests") + } + + @Test + func copyHeaders_keepPublicHeadersStructure_is_false() throws { + let outputDirectory = temporaryDirectory.appending(component: #function) + defer { try? fileSystem.removeFileTree(outputDirectory) } + + try assembleFramework(keepPublicHeadersStructure: false, outputDirectory: outputDirectory) + + let frameworkHeadersPath = outputDirectory.appending(components: "Foo.framework", "Headers") + #expect(Set(try fileSystem.getDirectoryContents(frameworkHeadersPath)) == ["foo.h", "bar.h"]) + } + + @Test + func copyHeaders_keepPublicHeadersStructure_is_true() throws { + let outputDirectory = temporaryDirectory.appending(component: #function) + defer { try? fileSystem.removeFileTree(outputDirectory) } + + try assembleFramework(keepPublicHeadersStructure: true, outputDirectory: outputDirectory) + + let frameworkHeadersPath = outputDirectory.appending(components: "Foo.framework", "Headers") + #expect(Set(try fileSystem.getDirectoryContents(frameworkHeadersPath)) == ["foo", "bar"]) + #expect(Set(try fileSystem.getDirectoryContents(frameworkHeadersPath.appending(component: "foo"))) == ["foo.h"]) + #expect(Set(try fileSystem.getDirectoryContents(frameworkHeadersPath.appending(component: "bar"))) == ["bar.h"]) + } + + private func assembleFramework(keepPublicHeadersStructure: Bool, outputDirectory: AbsolutePath) throws { + let fixture = fixturesPath.appendingPathComponent("FrameworkBundleAssemblerTests").absolutePath + let frameworkComponents = FrameworkComponents( + frameworkName: "Foo", + binaryPath: fixture.appending(components: "Foo.framework", "Foo"), + infoPlistPath: fixture.appending(components: "Foo.framework", "Info.plist"), + includeDir: fixture.appending(components: "include"), + publicHeaderPaths: [ + fixture.appending(components: "include", "foo", "foo.h"), + fixture.appending(components: "include", "bar", "bar.h"), + ] + ) + let assembler = FrameworkBundleAssembler( + frameworkComponents: frameworkComponents, + keepPublicHeadersStructure: keepPublicHeadersStructure, + outputDirectory: outputDirectory, + fileSystem: fileSystem + ) + try assembler.assemble() + } +} diff --git a/Tests/ScipioKitTests/FrameworkModuleMapGeneratorTests.swift b/Tests/ScipioKitTests/FrameworkModuleMapGeneratorTests.swift new file mode 100644 index 00000000..5822b2e7 --- /dev/null +++ b/Tests/ScipioKitTests/FrameworkModuleMapGeneratorTests.swift @@ -0,0 +1,92 @@ +import Foundation +@testable import ScipioKit +import Testing +import TSCBasic + +private let fixturesPath = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Resources") + .appendingPathComponent("Fixtures") +private let clangPackageWithUmbrellaDirectoryPath = fixturesPath.appendingPathComponent("ClangPackageWithUmbrellaDirectory") + +private struct PackageLocatorMock: PackageLocator { + let packageDirectory: AbsolutePath +} + +struct FrameworkModuleMapGeneratorTests { + let fileSystem = localFileSystem + let temporaryDirectory: AbsolutePath + + init() throws { + self.temporaryDirectory = try fileSystem + .tempDirectory + .appending(components: "FrameworkModuleMapGeneratorTests") + } + + @Test + func generate_keepPublicHeadersStructure_is_false() throws { + let outputDirectory = temporaryDirectory.appending(component: #function) + defer { try? fileSystem.removeFileTree(outputDirectory) } + + let generatedModuleMapContents = try generateModuleMap( + keepPublicHeadersStructure: false, + outputDirectory: outputDirectory + ) + let expectedModuleMapContents = """ +framework module MyTarget { + header "a.h" + header "add.h" + header "b.h" + header "c.h" + header "my_target.h" + export * +} +""" + #expect(generatedModuleMapContents == expectedModuleMapContents) + } + + @Test + func generate_keepPublicHeadersStructure_is_true() throws { + let outputDirectory = temporaryDirectory.appending(component: #function) + defer { try? fileSystem.removeFileTree(outputDirectory) } + + let generatedModuleMapContents = try generateModuleMap( + keepPublicHeadersStructure: true, + outputDirectory: outputDirectory + ) + let expectedModuleMapContents = """ +framework module MyTarget { + header "a.h" + header "add.h" + header "b.h" + header "my_target.h" + header "subdir/c.h" + export * +} +""" + #expect(generatedModuleMapContents == expectedModuleMapContents) + } + + private func generateModuleMap(keepPublicHeadersStructure: Bool, outputDirectory: AbsolutePath) throws -> String { + let packageLocator = PackageLocatorMock(packageDirectory: outputDirectory) + let generator = FrameworkModuleMapGenerator( + packageLocator: packageLocator, + fileSystem: fileSystem + ) + + let descriptionPackage = try DescriptionPackage( + packageDirectory: clangPackageWithUmbrellaDirectoryPath.absolutePath, + mode: .createPackage, + onlyUseVersionsFromResolvedFile: false + ) + let generatedModuleMapPath = try generator.generate( + resolvedTarget: #require(descriptionPackage.graph.module(for: "MyTarget")), + sdk: SDK.macOS, + keepPublicHeadersStructure: keepPublicHeadersStructure + ) + + let generatedModuleMapData = try Data(contentsOf: #require(generatedModuleMapPath).asURL) + let generatedModuleMapContents = String(decoding: generatedModuleMapData, as: UTF8.self) + return generatedModuleMapContents + } +} diff --git a/Tests/ScipioKitTests/Resources/Fixtures/FrameworkBundleAssemblerTests/Foo.framework/Foo b/Tests/ScipioKitTests/Resources/Fixtures/FrameworkBundleAssemblerTests/Foo.framework/Foo new file mode 100644 index 00000000..e69de29b diff --git a/Tests/ScipioKitTests/Resources/Fixtures/FrameworkBundleAssemblerTests/Foo.framework/Info.plist b/Tests/ScipioKitTests/Resources/Fixtures/FrameworkBundleAssemblerTests/Foo.framework/Info.plist new file mode 100644 index 00000000..e69de29b diff --git a/Tests/ScipioKitTests/Resources/Fixtures/FrameworkBundleAssemblerTests/include/bar/bar.h b/Tests/ScipioKitTests/Resources/Fixtures/FrameworkBundleAssemblerTests/include/bar/bar.h new file mode 100644 index 00000000..e69de29b diff --git a/Tests/ScipioKitTests/Resources/Fixtures/FrameworkBundleAssemblerTests/include/foo/foo.h b/Tests/ScipioKitTests/Resources/Fixtures/FrameworkBundleAssemblerTests/include/foo/foo.h new file mode 100644 index 00000000..e69de29b diff --git a/Tests/ScipioKitTests/RunnerTests.swift b/Tests/ScipioKitTests/RunnerTests.swift index 23c2a169..6414d573 100644 --- a/Tests/ScipioKitTests/RunnerTests.swift +++ b/Tests/ScipioKitTests/RunnerTests.swift @@ -780,6 +780,7 @@ extension BuildOptions { extraFlags: nil, extraBuildParameters: nil, enableLibraryEvolution: true, + keepPublicHeadersStructure: false, customFrameworkModuleMapContents: nil ) }