diff --git a/CHANGELOG.md b/CHANGELOG.md index bc18bf44..f1e4d35e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Next Version +- Adds an `explicitFolders` property to `TargetSource` that is passed through to `PBXFileSystemSynchronizedRootGroup`, to turn entire subfolders into Resources. #1596 @macguru +- Allow synced folders to be sorted using `groupOrdering`. #1596 @macguru +- Fixed synced folders ignoring `createIntermediateGroups=YES` and always beging created at the root level. #1596 @macguru +- Fix membership exceptions not working for nested synced folders with intermediate groups enabled. #1596 @macguru + ## 2.44.1 ### Fixed diff --git a/Docs/ProjectSpec.md b/Docs/ProjectSpec.md index c1a6259c..3f091bfd 100644 --- a/Docs/ProjectSpec.md +++ b/Docs/ProjectSpec.md @@ -520,6 +520,7 @@ A source can be provided via a string (the path) or an object of the form: - [ ] **compilerFlags**: **[String]** or **String** - A list of compilerFlags to add to files under this specific path provided as a list or a space delimited string. Defaults to empty. - [ ] **excludes**: **[String]** - A list of [global patterns](https://en.wikipedia.org/wiki/Glob_(programming)) representing the files to exclude. These rules are relative to `path` and _not the directory where `project.yml` resides_. XcodeGen uses Bash 4's Glob behaviors where globstar (**) is enabled. - [ ] **includes**: **[String]** - A list of global patterns in the same format as `excludes` representing the files to include. These rules are relative to `path` and _not the directory where `project.yml` resides_. If **excludes** is present and file conflicts with **includes**, **excludes** will override the **includes** behavior. +- [ ] **explicitFolders**: **[String]** - Only valid for `syncedFolder` type. A list of global patterns in the same format as `excludes` to child folders that Xcode should treat as folder references. - [ ] **destinationFilters**: **[[Supported Destinations](#supported-destinations)]** - List of supported platform destinations the files should filter to. Defaults to all supported destinations. - [ ] **inferDestinationFiltersByPath**: **Bool** - This is a convenience filter that helps you to filter the files if their paths match these patterns `**//*` or `*_.swift`. Note, if you use `destinationFilters` this flag will be ignored. - [ ] **createIntermediateGroups**: **Bool** - This overrides the value in [Options](#options). diff --git a/Sources/ProjectSpec/TargetSource.swift b/Sources/ProjectSpec/TargetSource.swift index f5932c83..6cfe7e7c 100644 --- a/Sources/ProjectSpec/TargetSource.swift +++ b/Sources/ProjectSpec/TargetSource.swift @@ -16,6 +16,7 @@ public struct TargetSource: Equatable { public var compilerFlags: [String] public var excludes: [String] public var includes: [String] + public var explicitFolders: [String] public var type: SourceType? public var optional: Bool public var buildPhase: BuildPhaseSpec? @@ -47,6 +48,7 @@ public struct TargetSource: Equatable { compilerFlags: [String] = [], excludes: [String] = [], includes: [String] = [], + explicitFolders: [String] = [], type: SourceType? = nil, optional: Bool = optionalDefault, buildPhase: BuildPhaseSpec? = nil, @@ -63,6 +65,7 @@ public struct TargetSource: Equatable { self.compilerFlags = compilerFlags self.excludes = excludes self.includes = includes + self.explicitFolders = explicitFolders self.type = type self.optional = optional self.buildPhase = buildPhase @@ -106,6 +109,7 @@ extension TargetSource: JSONObjectConvertible { headerVisibility = jsonDictionary.json(atKeyPath: "headerVisibility") excludes = jsonDictionary.json(atKeyPath: "excludes") ?? [] includes = jsonDictionary.json(atKeyPath: "includes") ?? [] + explicitFolders = jsonDictionary.json(atKeyPath: "explicitFolders") ?? [] type = jsonDictionary.json(atKeyPath: "type") optional = jsonDictionary.json(atKeyPath: "optional") ?? TargetSource.optionalDefault @@ -133,6 +137,7 @@ extension TargetSource: JSONEncodable { "compilerFlags": compilerFlags, "excludes": excludes, "includes": includes, + "explicitFolders": explicitFolders, "name": name, "group": group, "headerVisibility": headerVisibility?.rawValue, diff --git a/Sources/XcodeGenKit/PBXProjGenerator.swift b/Sources/XcodeGenKit/PBXProjGenerator.swift index b8ab9d37..0c3a99d7 100644 --- a/Sources/XcodeGenKit/PBXProjGenerator.swift +++ b/Sources/XcodeGenKit/PBXProjGenerator.swift @@ -1459,29 +1459,30 @@ public class PBXProjGenerator { } // add fileSystemSynchronizedGroups - let synchronizedRootGroups = sourceFiles.compactMap { $0.fileReference as? PBXFileSystemSynchronizedRootGroup } + let synchronizedRootGroups: [PBXFileSystemSynchronizedRootGroup] = sourceFiles.compactMap { sourceFile in + guard let syncedGroup = sourceFile.fileReference as? PBXFileSystemSynchronizedRootGroup else { return nil } + + configureMembershipExceptions( + for: syncedGroup, + path: sourceFile.path, + target: target, + targetObject: targetObject, + infoPlistFiles: infoPlistFiles + ) + return syncedGroup + } if !synchronizedRootGroups.isEmpty { - for syncedGroup in synchronizedRootGroups { - configureMembershipExceptions( - for: syncedGroup, - target: target, - targetObject: targetObject, - infoPlistFiles: infoPlistFiles - ) - } targetObject.fileSystemSynchronizedGroups = synchronizedRootGroups } } private func configureMembershipExceptions( for syncedGroup: PBXFileSystemSynchronizedRootGroup, + path syncedPath: Path, target: Target, targetObject: PBXTarget, infoPlistFiles: [Config: String] ) { - guard let syncedGroupPath = syncedGroup.path else { return } - let syncedPath = (project.basePath + Path(syncedGroupPath)).normalize() - guard let targetSource = target.sources.first(where: { (project.basePath + $0.path).normalize() == syncedPath }) else { return } @@ -1692,13 +1693,13 @@ extension Platform { } extension PBXFileElement { - /// - returns: `true` if the element is a group or a folder reference. Likely an SPM package. + /// - returns: `true` if the element is a group, a folder reference (likely an SPM package), or a synced folder. var isGroupOrFolder: Bool { - self is PBXGroup || (self as? PBXFileReference)?.lastKnownFileType == "folder" + self is PBXGroup || self is PBXFileSystemSynchronizedRootGroup || (self as? PBXFileReference)?.lastKnownFileType == "folder" } public func getSortOrder(groupSortPosition: SpecOptions.GroupSortPosition) -> Int { - if type(of: self).isa == "PBXGroup" { + if self is PBXGroup || self is PBXFileSystemSynchronizedRootGroup { switch groupSortPosition { case .top: return -1 case .bottom: return 1 diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index 06486f11..82e100ef 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -399,6 +399,20 @@ class SourceGenerator { ) } + /// Expands glob patterns in `explicitFolders` relative to the synced root path. + private func resolveExplicitFolders(targetSource: TargetSource) -> [String] { + let rootSourcePath = project.basePath + targetSource.path + + return targetSource.explicitFolders.flatMap { pattern in + let matches = Glob(pattern: "\(rootSourcePath)/\(pattern)") + .map { Path($0) } + .filter { $0.isDirectory } + .compactMap { try? $0.relativePath(from: rootSourcePath).string } + .sorted() + return matches.isEmpty ? [pattern] : matches + } + } + /// Checks whether the path is not in any default or TargetSource excludes func isIncludedPath(_ path: Path, excludePaths: Set, includePaths: SortedArray?) -> Bool { return !defaultExcludedFiles.contains(where: { path.lastComponent == $0 }) @@ -695,6 +709,7 @@ class SourceGenerator { case .syncedFolder: let relativePath = (try? path.relativePath(from: project.basePath)) ?? path + let resolvedExplicitFolders = resolveExplicitFolders(targetSource: targetSource) let syncedRootGroup = PBXFileSystemSynchronizedRootGroup( sourceTree: .group, @@ -702,13 +717,14 @@ class SourceGenerator { name: targetSource.name, explicitFileTypes: [:], exceptions: [], - explicitFolders: [] + explicitFolders: resolvedExplicitFolders ) addObject(syncedRootGroup) sourceReference = syncedRootGroup - // TODO: adjust if hasCustomParent == true - rootGroups.insert(syncedRootGroup) + if !(createIntermediateGroups || hasCustomParent) || path.parent() == project.basePath { + rootGroups.insert(syncedRootGroup) + } let sourceFile = generateSourceFile( targetType: targetType, @@ -725,6 +741,7 @@ class SourceGenerator { try makePathRelative(for: sourceReference, at: path) } else if createIntermediateGroups { createIntermediaGroups(for: sourceReference, at: sourcePath) + try makePathRelative(for: sourceReference, at: sourcePath) } return sourceFiles diff --git a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj index eabdb4b1..d49a058e 100644 --- a/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj +++ b/Tests/Fixtures/TestProject/Project.xcodeproj/project.pbxproj @@ -842,6 +842,16 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + A2F1B5386E15A261AC8A4DEE /* SyncedChild */ = { + isa = PBXFileSystemSynchronizedRootGroup; + explicitFileTypes = { + }; + explicitFolders = ( + ); + name = SyncedChild; + path = SyncedChild; + sourceTree = ""; + }; AE2AB2772F70DFFF402AA02B /* SyncedFolder */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -850,6 +860,9 @@ explicitFileTypes = { }; explicitFolders = ( + Resources, + FeatureATests, + FeatureBTests, ); path = SyncedFolder; sourceTree = ""; @@ -1012,6 +1025,12 @@ isa = PBXGroup; children = ( 2F80635127D17ECB7748067B /* FolderWithDot2.0 */, + CE1F06D99242F4223D081F0D /* LaunchScreen.storyboard */, + 9E17D598D98065767A04740F /* Localizable.strings */, + 65C8D6D1DDC1512D396C07B7 /* Localizable.stringsdict */, + 0C6BA0D12467A13EC012C728 /* LocalizedStoryboard.storyboard */, + 814D72C2B921F60B759C2D4B /* Main.storyboard */, + 306796628DD52FA55E833B65 /* Model.xcdatamodeld */, AEBCA8CFF769189C0D52031E /* App_iOS.xctestplan */, F0D48A913C087D049C8EDDD7 /* App.entitlements */, 7F1A2F579A6F79C62DDA0571 /* AppDelegate.swift */, @@ -1020,12 +1039,6 @@ B5C943D39DD7812CAB94B614 /* Documentation.docc */, C9DDE1B06BCC1CDE0ECF1589 /* Info.plist */, AAA49985DFFE797EE8416887 /* inputList.xcfilelist */, - CE1F06D99242F4223D081F0D /* LaunchScreen.storyboard */, - 9E17D598D98065767A04740F /* Localizable.strings */, - 65C8D6D1DDC1512D396C07B7 /* Localizable.stringsdict */, - 0C6BA0D12467A13EC012C728 /* LocalizedStoryboard.storyboard */, - 814D72C2B921F60B759C2D4B /* Main.storyboard */, - 306796628DD52FA55E833B65 /* Model.xcdatamodeld */, BF59AC868D227C92CA8B1B57 /* Model.xcmappingmodel */, C7809CE9FE9852C2AA87ACE5 /* module.modulemap */, 553D289724905857912C7A1D /* outputList.xcfilelist */, @@ -1068,6 +1081,8 @@ BDA839814AF73F01F7710518 /* StaticLibrary_ObjC */, CBDAC144248EE9D3838C6AAA /* StaticLibrary_Swift */, 6E0D17C5B4E6F01B89254309 /* String Catalogs */, + AE2AB2772F70DFFF402AA02B /* SyncedFolder */, + AB527E0D553CE53AF54C39CD /* SyncedParent */, 8CFD8AD4820FAB9265663F92 /* Tool */, 4C7F5EB7D6F3E0E9B426AB4A /* Utilities */, 3FEA12CF227D41EF50E5C2DB /* Vendor */, @@ -1076,7 +1091,6 @@ 2E1E747C7BC434ADB80CC269 /* Headers */, 6B1603BA83AA0C7B94E45168 /* ResourceFolder */, 6BBE762F36D94AB6FFBFE834 /* SomeFile */, - AE2AB2772F70DFFF402AA02B /* SyncedFolder */, 79DC4A1E4D2E0D3A215179BC /* Bundles */, FC1515684236259C50A7747F /* Frameworks */, AC523591AC7BE9275003D2DB /* Products */, @@ -1317,6 +1331,14 @@ path = iMessageApp; sourceTree = ""; }; + AB527E0D553CE53AF54C39CD /* SyncedParent */ = { + isa = PBXGroup; + children = ( + A2F1B5386E15A261AC8A4DEE /* SyncedChild */, + ); + path = SyncedParent; + sourceTree = ""; + }; AC523591AC7BE9275003D2DB /* Products */ = { isa = PBXGroup; children = ( @@ -1368,9 +1390,9 @@ BAE6C12745737019DC9E98BF /* App_watchOS */ = { isa = PBXGroup; children = ( + C872631362DDBAFCE71E5C66 /* Interface.storyboard */, D8A016580A3B8F72B820BFBF /* Assets.xcassets */, FED40A89162E446494DDE7C7 /* Info.plist */, - C872631362DDBAFCE71E5C66 /* Interface.storyboard */, ); path = App_watchOS; sourceTree = ""; @@ -1388,9 +1410,9 @@ BF58996786F85CB77BEE72EF /* iMessageExtension */ = { isa = PBXGroup; children = ( + 753001CDCEAA4C4E1AFF8E87 /* MainInterface.storyboard */, 1BC32A813B80A53962A1F365 /* Assets.xcassets */, 40863AE6202CFCD0529D8438 /* Info.plist */, - 753001CDCEAA4C4E1AFF8E87 /* MainInterface.storyboard */, B198242976C3395E31FE000A /* MessagesViewController.swift */, ); path = iMessageExtension; @@ -1399,12 +1421,12 @@ C81493FAD71E9A9A19E00AD5 /* App_Clip */ = { isa = PBXGroup; children = ( + 79325B44B19B83EC6CEDBCC5 /* LaunchScreen.storyboard */, + 2FC2A8A829CE71B1CF415FF7 /* Main.storyboard */, 23A2F16890ECF2EE3FED72AE /* AppDelegate.swift */, 59DA55A04FA2366B5D0BEEFF /* Assets.xcassets */, 1FA5E208EC184E3030D2A21D /* Clip.entitlements */, 6F165CDD5BCC13AFF50B65E2 /* Info.plist */, - 79325B44B19B83EC6CEDBCC5 /* LaunchScreen.storyboard */, - 2FC2A8A829CE71B1CF415FF7 /* Main.storyboard */, DFE6A6FAAFF701FE729293DE /* ViewController.swift */, ); path = App_Clip; @@ -1449,10 +1471,10 @@ EE78B4FBD0137D1975C47D76 /* App_macOS */ = { isa = PBXGroup; children = ( + 74FBDFA5CB063F6001AD8ACD /* Main.storyboard */, 9528528C989D24FE3E6C533E /* App-Info.plist */, 09B82F603D981398F38D762E /* AppDelegate.swift */, E55F45EACB0F382722D61C8D /* Assets.xcassets */, - 74FBDFA5CB063F6001AD8ACD /* Main.storyboard */, A4C3FE6B986506724DAB5D0F /* ViewController.swift */, ); path = App_macOS; @@ -1714,6 +1736,7 @@ 981D116D40DBA0407D0E0E94 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( + A2F1B5386E15A261AC8A4DEE /* SyncedChild */, AE2AB2772F70DFFF402AA02B /* SyncedFolder */, ); name = App_iOS; diff --git a/Tests/Fixtures/TestProject/SyncedFolder/FeatureATests/__Snapshots__/.gitkeep b/Tests/Fixtures/TestProject/SyncedFolder/FeatureATests/__Snapshots__/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Tests/Fixtures/TestProject/SyncedFolder/FeatureBTests/__Snapshots__/.gitkeep b/Tests/Fixtures/TestProject/SyncedFolder/FeatureBTests/__Snapshots__/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Tests/Fixtures/TestProject/SyncedFolder/Resources/.gitkeep b/Tests/Fixtures/TestProject/SyncedFolder/Resources/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Tests/Fixtures/TestProject/SyncedParent/SyncedChild/SyncedChildFile.swift b/Tests/Fixtures/TestProject/SyncedParent/SyncedChild/SyncedChildFile.swift new file mode 100644 index 00000000..fecc4ab4 --- /dev/null +++ b/Tests/Fixtures/TestProject/SyncedParent/SyncedChild/SyncedChildFile.swift @@ -0,0 +1 @@ +import Foundation diff --git a/Tests/Fixtures/TestProject/project.yml b/Tests/Fixtures/TestProject/project.yml index 599da8e2..dbd4c317 100644 --- a/Tests/Fixtures/TestProject/project.yml +++ b/Tests/Fixtures/TestProject/project.yml @@ -169,6 +169,12 @@ targets: excludes: - ExcludedFile.swift - Info.plist + explicitFolders: + - Resources + - "**/*Tests" + - path: SyncedParent/SyncedChild + type: syncedFolder + createIntermediateGroups: true settings: INFOPLIST_FILE: App_iOS/Info.plist PRODUCT_BUNDLE_IDENTIFIER: com.project.app diff --git a/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift b/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift index 3af4148d..232995c8 100644 --- a/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/PBXProjGeneratorTests.swift @@ -349,6 +349,51 @@ class PBXProjGeneratorTests: XCTestCase { try expect(packages) == ["FeatureA", "FeatureB", "Common"] } + + $0.it("sorts synced folders alongside groups") { + var options = SpecOptions() + options.groupSortPosition = .top + options.groupOrdering = [ + GroupOrdering( + order: [ + "Sources", + "SyncedSources", + "Resources", + ] + ), + ] + + let directories = """ + Resources: + - file.swift + Sources: + - file.swift + SyncedSources: + - file.swift + """ + try createDirectories(directories) + + let target = Target( + name: "Test", + type: .application, + platform: .iOS, + sources: [ + "Sources", + .init(path: "SyncedSources", type: .syncedFolder), + "Resources", + ] + ) + let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: options) + let projGenerator = PBXProjGenerator(project: project) + + let pbxProj = try project.generatePbxProj() + let group = try pbxProj.getMainGroup() + + projGenerator.setupGroupOrdering(group: group) + + let mainGroups = group.children.map { $0.nameOrPath } + try expect(mainGroups) == ["Sources", "SyncedSources", "Resources", "Products"] + } } } diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index 55f896b5..be7eb005 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -102,6 +102,60 @@ class SourceGeneratorTests: XCTestCase { try expect([syncedFolder]) == pbxProj.nativeTargets.first?.fileSystemSynchronizedGroups } + $0.it("generates synced folder with explicitFolders") { + let directories = """ + Sources: + Images: + - image.png + MainSuite: + FeatureATests: + - __Snapshots__: + - snap.png + FeatureBTests: + - __Snapshots__: + - snap.png + NotATest: + - file.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources", explicitFolders: ["Images", "**/*Tests"], type: .syncedFolder) + let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target]) + + let pbxProj = try project.generatePbxProj() + let syncedFolders = try pbxProj.getMainGroup().children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup } + let syncedFolder = try unwrap(syncedFolders.first) + + try expect(syncedFolder.explicitFolders) == ["Images", "MainSuite/FeatureATests", "MainSuite/FeatureBTests"] + } + + $0.it("generates synced folder with createIntermediateGroups") { + let directories = """ + Parent: + Child: + - a.swift + """ + try createDirectories(directories) + + let target = Target(name: "Test", type: .application, platform: .iOS, sources: [.init(path: "Parent/Child", type: .syncedFolder)]) + let options = SpecOptions(createIntermediateGroups: true) + let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: options) + + let pbxProj = try project.generatePbxProj() + let mainGroup = try pbxProj.getMainGroup() + + let rootSyncedFolders = mainGroup.children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup } + try expect(rootSyncedFolders.count) == 0 + + let parentGroup = try unwrap(mainGroup.children.compactMap({ $0 as? PBXGroup }).first(where: { $0.nameOrPath == "Parent" })) + let nestedSyncedFolders = parentGroup.children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup } + let syncedFolder = try unwrap(nestedSyncedFolders.first) + + try expect(syncedFolder.path) == "Child" + try expect([syncedFolder]) == pbxProj.nativeTargets.first?.fileSystemSynchronizedGroups + } + $0.it("respects defaultSourceDirectoryType") { let directories = """ Sources: @@ -149,6 +203,29 @@ class SourceGeneratorTests: XCTestCase { try expect(exceptions.contains("a.swift")) == false } + $0.it("adds membership exceptions for nested synced folder with intermediate groups") { + let directories = """ + Sources: + Nested: + - a.swift + - b.swift + """ + try createDirectories(directories) + + let source = TargetSource(path: "Sources/Nested", excludes: ["b.swift"], type: .syncedFolder) + let target = Target(name: "Test", type: .application, platform: .iOS, sources: [source]) + let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: .init(createIntermediateGroups: true)) + + let pbxProj = try project.generatePbxProj() + let sourcesGroup = try unwrap(try pbxProj.getMainGroup().children.first { $0.nameOrPath == "Sources" } as? PBXGroup) + let syncedFolder = try unwrap(sourcesGroup.children.compactMap { $0 as? PBXFileSystemSynchronizedRootGroup }.first) + + let exceptionSet = try unwrap(syncedFolder.exceptions?.first as? PBXFileSystemSynchronizedBuildFileExceptionSet) + let exceptions = try unwrap(exceptionSet.membershipExceptions) + + try expect(exceptions) == ["b.swift"] + } + $0.it("auto-excludes Info.plist from synced folder membership") { let directories = """ Sources: