diff --git a/CHANGELOG.md b/CHANGELOG.md index ad0bdec5..3dfe4822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Add Carthage static framework dependencies support. [#688](https://github.com/yonaskolb/XcodeGen/pull/688) @giginet - Added `xcodegen dump` command [#710](https://github.com/yonaskolb/XcodeGen/pull/710) @yonaskolb - Added `--no-env` option to disable environment variables expansion [#704](https://github.com/yonaskolb/XcodeGen/pull/704) @rcari +- Added custom group support for target sources [#621](https://github.com/yonaskolb/XcodeGen/pull/621) @sroebert @rcari #### Fixed - Improved variable expansion runtime [#704](https://github.com/yonaskolb/XcodeGen/pull/704) @rcari diff --git a/Docs/ProjectSpec.md b/Docs/ProjectSpec.md index b265edfc..fa8ccc6f 100644 --- a/Docs/ProjectSpec.md +++ b/Docs/ProjectSpec.md @@ -309,6 +309,7 @@ A source can be provided via a string (the path) or an object of the form: - [x] **path**: **String** - The path to the source file or directory. - [ ] **name**: **String** - Can be used to override the name of the source file or directory. By default the last component of the path is used for the name +- [ ] **group**: **String** - Can be used to override the parent group of the source file or directory. By default a group is created at the root with the name of this source file or directory or intermediate groups are created if `createIntermediateGroups` is set to `true`. Multiple groups can be created by separating each one using a `/`. If multiple target sources share the same `group`, they will be put together in the same parent group. - [ ] **compilerFlags**: **[String]** or **String** - A list of compilerFlags to add to files under this specific path provided as a list or a space delimitted 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. diff --git a/Sources/ProjectSpec/TargetSource.swift b/Sources/ProjectSpec/TargetSource.swift index 60d3f669..0a3cdbfe 100644 --- a/Sources/ProjectSpec/TargetSource.swift +++ b/Sources/ProjectSpec/TargetSource.swift @@ -9,6 +9,7 @@ public struct TargetSource: Equatable { public var path: String public var name: String? + public var group: String? public var compilerFlags: [String] public var excludes: [String] public var includes: [String] @@ -124,6 +125,7 @@ public struct TargetSource: Equatable { public init( path: String, name: String? = nil, + group: String? = nil, compilerFlags: [String] = [], excludes: [String] = [], includes: [String] = [], @@ -136,6 +138,7 @@ public struct TargetSource: Equatable { ) { self.path = path self.name = name + self.group = group self.compilerFlags = compilerFlags self.excludes = excludes self.includes = includes @@ -168,6 +171,7 @@ extension TargetSource: JSONObjectConvertible { public init(jsonDictionary: JSONDictionary) throws { path = try jsonDictionary.json(atKeyPath: "path") name = jsonDictionary.json(atKeyPath: "name") + group = jsonDictionary.json(atKeyPath: "group") let maybeCompilerFlagsString: String? = jsonDictionary.json(atKeyPath: "compilerFlags") let maybeCompilerFlagsArray: [String]? = jsonDictionary.json(atKeyPath: "compilerFlags") @@ -198,6 +202,7 @@ extension TargetSource: JSONEncodable { "excludes": excludes, "includes": includes, "name": name, + "group": group, "headerVisibility": headerVisibility?.rawValue, "type": type?.rawValue, "buildPhase": buildPhase?.toJSONValue(), diff --git a/Sources/XcodeGenKit/SourceGenerator.swift b/Sources/XcodeGenKit/SourceGenerator.swift index b7659d2e..3a409b53 100644 --- a/Sources/XcodeGenKit/SourceGenerator.swift +++ b/Sources/XcodeGenKit/SourceGenerator.swift @@ -143,6 +143,7 @@ class SourceGenerator { path: parentPath, mergingChildren: [fileReference], createIntermediateGroups: createIntermediateGroups, + hasCustomParent: false, isBaseGroup: true ) @@ -270,7 +271,7 @@ class SourceGenerator { /// Create a group or return an existing one at the path. /// Any merged children are added to a new group or merged into an existing one. - private func getGroup(path: Path, name: String? = nil, mergingChildren children: [PBXFileElement], createIntermediateGroups: Bool, isBaseGroup: Bool) -> PBXGroup { + private func getGroup(path: Path, name: String? = nil, mergingChildren children: [PBXFileElement], createIntermediateGroups: Bool, hasCustomParent: Bool, isBaseGroup: Bool) -> PBXGroup { let groupReference: PBXGroup if let cachedGroup = groupsByPath[path] { @@ -280,6 +281,7 @@ class SourceGenerator { // Check equality by path and sourceTree because XcodeProj.PBXObject.== is very slow. if !cachedGroupChildren.contains(where: { $0.name == child.name && $0.path == child.path && $0.sourceTree == child.sourceTree }) { cachedGroupChildren.append(child) + child.parent = cachedGroup } } cachedGroup.children = cachedGroupChildren @@ -293,10 +295,11 @@ class SourceGenerator { let isRootPath = (isBaseGroup && isOutOfBasePath) || path.parent() == project.basePath // is a top level group in the project - let isTopLevelGroup = (isBaseGroup && !createIntermediateGroups) || isRootPath + let isTopLevelGroup = !hasCustomParent && ((isBaseGroup && !createIntermediateGroups) || isRootPath) let groupName = name ?? path.lastComponent - let groupPath = resolveGroupPath(path, isTopLevelGroup: isTopLevelGroup) + + let groupPath = resolveGroupPath(path, isTopLevelGroup: hasCustomParent || isTopLevelGroup) let group = PBXGroup( children: children, @@ -387,11 +390,20 @@ class SourceGenerator { } /// creates all the source files and groups they belong to for a given targetSource - private func getGroupSources(targetType: PBXProductType, targetSource: TargetSource, path: Path, isBaseGroup: Bool, excludePaths: Set, includePaths: Set) - throws -> (sourceFiles: [SourceFile], groups: [PBXGroup]) { + private func getGroupSources( + targetType: PBXProductType, + targetSource: TargetSource, + path: Path, + isBaseGroup: Bool, + hasCustomParent: Bool, + excludePaths: Set, + includePaths: Set + ) throws -> (sourceFiles: [SourceFile], groups: [PBXGroup]) { let children = try getSourceChildren(targetSource: targetSource, dirPath: path, excludePaths: excludePaths, includePaths: includePaths) + let createIntermediateGroups = targetSource.createIntermediateGroups ?? project.options.createIntermediateGroups + let directories = children .filter { $0.isDirectory && $0.extension == nil && $0.extension != "lproj" } @@ -408,12 +420,16 @@ class SourceGenerator { var groups: [PBXGroup] = [] for path in directories { - let subGroups = try getGroupSources(targetType: targetType, - targetSource: targetSource, - path: path, - isBaseGroup: false, - excludePaths: excludePaths, - includePaths: includePaths) + + let subGroups = try getGroupSources( + targetType: targetType, + targetSource: targetSource, + path: path, + isBaseGroup: false, + hasCustomParent: false, + excludePaths: excludePaths, + includePaths: includePaths + ) guard !subGroups.sourceFiles.isEmpty || project.options.generateEmptyDirectories else { continue @@ -499,12 +515,11 @@ class SourceGenerator { } } - let createIntermediateGroups = targetSource.createIntermediateGroups ?? project.options.createIntermediateGroups - let group = getGroup( path: path, mergingChildren: groupChildren, createIntermediateGroups: createIntermediateGroups, + hasCustomParent: hasCustomParent, isBaseGroup: isBaseGroup ) if createIntermediateGroups { @@ -524,6 +539,10 @@ class SourceGenerator { let includePaths = getSourceMatches(targetSource: targetSource, patterns: targetSource.includes) let type = targetSource.type ?? (path.isFile || path.extension != nil ? .file : .group) + + let customParentGroups = (targetSource.group ?? "").split(separator: "/").map{ String($0) } + let hasCustomParent = !customParentGroups.isEmpty + let createIntermediateGroups = targetSource.createIntermediateGroups ?? project.options.createIntermediateGroups var sourceFiles: [SourceFile] = [] @@ -540,7 +559,7 @@ class SourceGenerator { lastKnownFileType: "folder" ) - if !createIntermediateGroups || path.parent() == project.basePath { + if !(createIntermediateGroups || hasCustomParent) || path.parent() == project.basePath { rootGroups.insert(fileReference) } @@ -561,12 +580,21 @@ class SourceGenerator { let sourceFile = generateSourceFile(targetType: targetType, targetSource: targetSource, path: path) - if parentPath == project.basePath { + if hasCustomParent { + sourcePath = path + sourceReference = fileReference + } else if parentPath == project.basePath { sourcePath = path sourceReference = fileReference rootGroups.insert(fileReference) } else { - let parentGroup = getGroup(path: parentPath, mergingChildren: [fileReference], createIntermediateGroups: createIntermediateGroups, isBaseGroup: true) + let parentGroup = getGroup( + path: parentPath, + mergingChildren: [fileReference], + createIntermediateGroups: createIntermediateGroups, + hasCustomParent: hasCustomParent, + isBaseGroup: true + ) sourcePath = parentPath sourceReference = parentGroup } @@ -577,12 +605,17 @@ class SourceGenerator { // This group is missing, so if's optional just return an empty array return [] } - let (groupSourceFiles, groups) = try getGroupSources(targetType: targetType, - targetSource: targetSource, - path: path, - isBaseGroup: true, - excludePaths: excludePaths, - includePaths: includePaths) + + let (groupSourceFiles, groups) = try getGroupSources( + targetType: targetType, + targetSource: targetSource, + path: path, + isBaseGroup: true, + hasCustomParent: hasCustomParent, + excludePaths: excludePaths, + includePaths: includePaths + ) + let group = groups.first! if let name = targetSource.name { group.name = name @@ -592,13 +625,44 @@ class SourceGenerator { sourceReference = group } - if createIntermediateGroups { + if hasCustomParent { + createParentGroups(customParentGroups, for: sourceReference) + try makePathRelative(for: sourceReference, at: path) + } else if createIntermediateGroups { createIntermediaGroups(for: sourceReference, at: sourcePath) } return sourceFiles } + private func createParentGroups(_ parentGroups: [String], for fileElement: PBXFileElement) { + guard let parentName = parentGroups.last else { + return + } + + let parentPath = project.basePath + Path(parentGroups.joined(separator: "/")) + let parentPathExists = parentPath.exists + let parentGroupAlreadyExists = groupsByPath[parentPath] != nil + + let parentGroup = getGroup( + path: parentPath, + mergingChildren: [fileElement], + createIntermediateGroups: false, + hasCustomParent: false, + isBaseGroup: parentGroups.count == 1 + ) + + // As this path is a custom group, remove the path reference + if !parentPathExists { + parentGroup.name = String(parentName) + parentGroup.path = nil + } + + if !parentGroupAlreadyExists { + createParentGroups(parentGroups.dropLast(), for: parentGroup) + } + } + // Add groups for all parents recursively private func createIntermediaGroups(for fileElement: PBXFileElement, at path: Path) { @@ -609,13 +673,45 @@ class SourceGenerator { } let hasParentGroup = groupsByPath[parentPath] != nil - let parentGroup = getGroup(path: parentPath, mergingChildren: [fileElement], createIntermediateGroups: true, isBaseGroup: false) + let parentGroup = getGroup( + path: parentPath, + mergingChildren: [fileElement], + createIntermediateGroups: true, + hasCustomParent: false, + isBaseGroup: false + ) if !hasParentGroup { createIntermediaGroups(for: parentGroup, at: parentPath) } } + // Make the fileElement path and name relative to its parents aggregated paths + private func makePathRelative(for fileElement: PBXFileElement, at path: Path) throws { + // This makes the fileElement path relative to its parent and not to the project. Xcode then rebuilds the actual + // path for the file based on the hierarchy this fileElement lives in. + var paths: [String] = [] + var element: PBXFileElement = fileElement + while true { + guard let parent = element.parent else { break } + + if let path = parent.path { + paths.insert(path, at: 0) + } + + element = parent + } + + let completePath = project.basePath + Path(paths.joined(separator: "/")) + let relativePath = try path.relativePath(from: completePath) + let relativePathString = relativePath.string + + if relativePathString != fileElement.path { + fileElement.path = relativePathString + fileElement.name = relativePath.lastComponent + } + } + private func findCurrentCoreDataModelVersionPath(using versionedModels: [Path]) -> Path? { // Find and parse the current version model stored in the .xccurrentversion file guard diff --git a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift index 1e903ab3..49458675 100644 --- a/Tests/XcodeGenKitTests/SourceGeneratorTests.swift +++ b/Tests/XcodeGenKitTests/SourceGeneratorTests.swift @@ -470,6 +470,44 @@ class SourceGeneratorTests: XCTestCase { try pbxProj.expectFile(paths: ["Sources/B", "b.swift"], names: ["B", "b.swift"], buildPhase: .sources) } + $0.it("generates custom groups") { + + let directories = """ + - Sources: + - a.swift + - A: + - b.swift + - F: + - G: + - h.swift + - i.swift + - B: + - b.swift + - C: + - c.swift + """ + try createDirectories(directories) + + let target = Target(name: "Test", type: .application, platform: .iOS, sources: [ + TargetSource(path: "Sources/a.swift", group: "CustomGroup1"), + TargetSource(path: "Sources/A/b.swift", group: "CustomGroup1"), + TargetSource(path: "Sources/F/G/h.swift", group: "CustomGroup1"), + TargetSource(path: "Sources/B", group: "CustomGroup2", createIntermediateGroups: false), + TargetSource(path: "Sources/F/G/i.swift", group: "Sources/F/G/CustomGroup3"), + ]) + + let options = SpecOptions(createIntermediateGroups: true) + let project = Project(basePath: directoryPath, name: "Test", targets: [target], options: options) + + let pbxProj = try project.generatePbxProj() + try pbxProj.expectFile(paths: ["CustomGroup1", "Sources/a.swift"], names: ["CustomGroup1", "a.swift"], buildPhase: .sources) + try pbxProj.expectFile(paths: ["CustomGroup1", "Sources/A/b.swift"], names: ["CustomGroup1", "b.swift"], buildPhase: .sources) + try pbxProj.expectFile(paths: ["CustomGroup1", "Sources/F/G/h.swift"], names: ["CustomGroup1", "h.swift"], buildPhase: .sources) + try pbxProj.expectFile(paths: ["Sources", "F", "G", "CustomGroup3", "i.swift"], names: ["Sources", "F", "G", "CustomGroup3", "i.swift"], buildPhase: .sources) + try pbxProj.expectFile(paths: ["CustomGroup2", "Sources/B", "b.swift"], names: ["CustomGroup2", "B", "b.swift"], buildPhase: .sources) + try pbxProj.expectFile(paths: ["CustomGroup2", "Sources/B", "C", "c.swift"], names: ["CustomGroup2", "B", "C", "c.swift"], buildPhase: .sources) + } + $0.it("generates folder references") { let directories = """ Sources: @@ -953,18 +991,22 @@ extension PBXProj { } private func getFileReference(group: PBXGroup, paths: [String], names: [String]) -> PBXFileReference? { + guard !paths.isEmpty else { + return nil + } - guard !paths.isEmpty else { return nil } let path = paths.first! let name = names.first! let restOfPath = Array(paths.dropFirst()) let restOfName = Array(names.dropFirst()) if restOfPath.isEmpty { let fileReferences: [PBXFileReference] = group.children.compactMap { $0 as? PBXFileReference } - return fileReferences.first { $0.path == path && $0.nameOrPath == name } + return fileReferences.first { ($0.path == nil || $0.path == path) && $0.nameOrPath == name } } else { let groups = group.children.compactMap { $0 as? PBXGroup } - guard let group = groups.first(where: { $0.path == path && $0.nameOrPath == name }) else { return nil } + guard let group = groups.first(where: { ($0.path == nil || $0.path == path) && $0.nameOrPath == name }) else { + return nil + } return getFileReference(group: group, paths: restOfPath, names: restOfName) } }