diff --git a/CHANGELOG.md b/CHANGELOG.md index 9944436ad8b..8d045512870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Please, check out guidelines: https://keepachangelog.com/en/1.0.0/ - Allow specifying multiple configurations within project manifests https://github.com/tuist/tuist/pull/451 by @kwridan - Add linting for mismatching build configurations in a workspace https://github.com/tuist/tuist/pull/474 by @kwridan - Support for CocoaPods dependencies https://github.com/tuist/tuist/pull/465 by @pepibumur +- Support custom .xcodeproj name at the model level https://github.com/tuist/tuist/pull/462 by @adamkhazi ### Fixed diff --git a/Sources/ProjectDescription/TemplateString.swift b/Sources/ProjectDescription/TemplateString.swift new file mode 100644 index 00000000000..c7d60a63bdd --- /dev/null +++ b/Sources/ProjectDescription/TemplateString.swift @@ -0,0 +1,56 @@ +import Foundation + +public struct TemplateString: Encodable, Decodable, Equatable { + /// Contains a string that can be interpolated with options. + let rawString: String +} + +extension TemplateString: ExpressibleByStringLiteral { + public init(stringLiteral: String) { + rawString = stringLiteral + } +} + +extension TemplateString: CustomStringConvertible { + public var description: String { + return rawString + } +} + +extension TemplateString: ExpressibleByStringInterpolation { + public init(stringInterpolation: StringInterpolation) { + rawString = stringInterpolation.string + } + + public struct StringInterpolation: StringInterpolationProtocol { + var string: String + + public init(literalCapacity _: Int, interpolationCount _: Int) { + string = String() + } + + public mutating func appendLiteral(_ literal: String) { + string.append(literal) + } + + public mutating func appendInterpolation(_ token: TemplateString.Token) { + string.append(token.rawValue) + } + } +} + +extension TemplateString { + /// Provides a template for existing project properties. + /// + /// - projectName: The name of the project. + public enum Token: String { + case projectName = "${project_name}" + } +} + +public func == (lhs: TemplateString.Token, rhs: TemplateString.Token) -> Bool { + switch (lhs, rhs) { + case (.projectName, .projectName): + return true + } +} diff --git a/Sources/ProjectDescription/TuistConfig.swift b/Sources/ProjectDescription/TuistConfig.swift index ac354bd2cbb..4f320f50a37 100644 --- a/Sources/ProjectDescription/TuistConfig.swift +++ b/Sources/ProjectDescription/TuistConfig.swift @@ -1,22 +1,74 @@ import Foundation /// This model allows to configure Tuist. -public class TuistConfig: Codable { +public class TuistConfig: Encodable, Decodable, Equatable { /// Contains options related to the project generation. /// /// - generateManifestElement: When passed, Tuist generates the projects, targets and schemes to compile the project manifest. - public enum GenerationOption: String, Codable { + /// - xcodeProjectName(TemplateString): When passed, Tuist generates the project with the specific name on disk instead of using the project name. + public enum GenerationOptions: Encodable, Decodable, Equatable { case generateManifest + case xcodeProjectName(TemplateString) } /// Generation options. - public let generationOptions: [GenerationOption] + public let generationOptions: [GenerationOptions] /// Initializes the tuist cofiguration. /// /// - Parameter generationOptions: Generation options. - public init(generationOptions: [GenerationOption]) { + public init(generationOptions: [GenerationOptions]) { self.generationOptions = generationOptions dumpIfNeeded(self) } } + +extension TuistConfig.GenerationOptions { + enum CodingKeys: String, CodingKey { + case generateManifest + case xcodeProjectName + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + if container.allKeys.contains(.generateManifest), try container.decodeNil(forKey: .generateManifest) == false { + self = .generateManifest + return + } + if container.allKeys.contains(.xcodeProjectName), try container.decodeNil(forKey: .xcodeProjectName) == false { + var associatedValues = try container.nestedUnkeyedContainer(forKey: .xcodeProjectName) + let templateProjectName = try associatedValues.decode(TemplateString.self) + self = .xcodeProjectName(templateProjectName) + return + } + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Unknown enum case")) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .generateManifest: + _ = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .generateManifest) + case let .xcodeProjectName(templateProjectName): + var associatedValues = container.nestedUnkeyedContainer(forKey: .xcodeProjectName) + try associatedValues.encode(templateProjectName) + } + } +} + +public func == (lhs: TuistConfig, rhs: TuistConfig) -> Bool { + guard lhs.generationOptions == rhs.generationOptions else { return false } + return true +} + +public func == (lhs: TuistConfig.GenerationOptions, rhs: TuistConfig.GenerationOptions) -> Bool { + switch (lhs, rhs) { + case (.generateManifest, .generateManifest): + return true + case let (.xcodeProjectName(lhs), .xcodeProjectName(rhs)): + return lhs.rawString == rhs.rawString + default: return false + } +} diff --git a/Sources/TuistGenerator/Generator/Generator.swift b/Sources/TuistGenerator/Generator/Generator.swift index 3a75b855561..bd43fed8850 100644 --- a/Sources/TuistGenerator/Generator/Generator.swift +++ b/Sources/TuistGenerator/Generator/Generator.swift @@ -88,7 +88,6 @@ public class Generator: Generating { public func generateProject(at path: AbsolutePath) throws -> AbsolutePath { let (graph, project) = try graphLoader.loadProject(path: path) - let generatedProject = try projectGenerator.generate(project: project, graph: graph, sourceRootPath: path) @@ -100,7 +99,7 @@ public class Generator: Generating { let tuistConfig = try graphLoader.loadTuistConfig(path: path) let (graph, project) = try graphLoader.loadProject(path: path) - let workspace = Workspace(name: project.name, + let workspace = Workspace(name: project.fileName, projects: graph.projectPaths, additionalFiles: workspaceFiles.map(FileElement.file)) @@ -114,7 +113,6 @@ public class Generator: Generating { workspaceFiles: [AbsolutePath]) throws -> AbsolutePath { let (graph, workspace) = try graphLoader.loadWorkspace(path: path) let tuistConfig = try graphLoader.loadTuistConfig(path: path) - let updatedWorkspace = workspace .merging(projects: graph.projectPaths) .adding(files: workspaceFiles) diff --git a/Sources/TuistGenerator/Generator/ProjectGenerator.swift b/Sources/TuistGenerator/Generator/ProjectGenerator.swift index 9ff9e1a30fa..c5e97a3da8a 100644 --- a/Sources/TuistGenerator/Generator/ProjectGenerator.swift +++ b/Sources/TuistGenerator/Generator/ProjectGenerator.swift @@ -87,7 +87,8 @@ final class ProjectGenerator: ProjectGenerating { // Getting the path. let sourceRootPath = sourceRootPath ?? project.path - let xcodeprojPath = sourceRootPath.appending(component: "\(project.name).xcodeproj") + + let xcodeprojPath = sourceRootPath.appending(component: "\(project.fileName).xcodeproj") // Derived files let deleteOldDerivedFiles = try derivedFileGenerator.generate(project: project, sourceRootPath: sourceRootPath) diff --git a/Sources/TuistGenerator/Models/Project.swift b/Sources/TuistGenerator/Models/Project.swift index b0742beae79..65cc6738ced 100644 --- a/Sources/TuistGenerator/Models/Project.swift +++ b/Sources/TuistGenerator/Models/Project.swift @@ -11,6 +11,9 @@ public class Project: Equatable, CustomStringConvertible { /// Project name. public let name: String + /// Project file name. + public let fileName: String + /// Project targets. public let targets: [Target] @@ -40,6 +43,7 @@ public class Project: Equatable, CustomStringConvertible { /// *(Those won't be included in any build phases)* public init(path: AbsolutePath, name: String, + fileName: String? = nil, settings: Settings, filesGroup: ProjectGroup, targets: [Target], @@ -47,6 +51,7 @@ public class Project: Equatable, CustomStringConvertible { additionalFiles: [FileElement] = []) { self.path = path self.name = name + self.fileName = fileName ?? name self.targets = targets self.schemes = schemes self.settings = settings diff --git a/Sources/TuistGenerator/Models/TuistConfig.swift b/Sources/TuistGenerator/Models/TuistConfig.swift index 38ad8a07c43..8678fb04b56 100644 --- a/Sources/TuistGenerator/Models/TuistConfig.swift +++ b/Sources/TuistGenerator/Models/TuistConfig.swift @@ -7,8 +7,9 @@ public class TuistConfig: Equatable, Hashable { /// Contains options related to the project generation. /// /// - generateManifestElement: When passed, Tuist generates the projects, targets and schemes to compile the project manifest. - public enum GenerationOption: String, Codable, Equatable, Hashable { + public enum GenerationOption: Hashable, Equatable { case generateManifest + case xcodeProjectName(String) } /// Generation options. @@ -49,3 +50,13 @@ public class TuistConfig: Equatable, Hashable { return lhs.generationOptions == rhs.generationOptions } } + +public func == (lhs: TuistConfig.GenerationOption, rhs: TuistConfig.GenerationOption) -> Bool { + switch (lhs, rhs) { + case (.generateManifest, .generateManifest): + return true + case let (.xcodeProjectName(lhs), .xcodeProjectName(rhs)): + return lhs == rhs + default: return false + } +} diff --git a/Sources/TuistKit/Generator/GeneratorModelLoader.swift b/Sources/TuistKit/Generator/GeneratorModelLoader.swift index 15e600e0107..613a6f11802 100644 --- a/Sources/TuistKit/Generator/GeneratorModelLoader.swift +++ b/Sources/TuistKit/Generator/GeneratorModelLoader.swift @@ -53,6 +53,7 @@ class GeneratorModelLoader: GeneratorModelLoading { /// - Throws: Error encountered during the loading process (e.g. Missing project) func loadProject(at path: AbsolutePath) throws -> TuistGenerator.Project { let manifest = try manifestLoader.loadProject(at: path) + let tuistConfig = try loadTuistConfig(at: path) try manifestLinter.lint(project: manifest) .printAndThrowIfNeeded(printer: printer) @@ -60,17 +61,10 @@ class GeneratorModelLoader: GeneratorModelLoading { let project = try TuistGenerator.Project.from(manifest: manifest, path: path, fileHandler: fileHandler, - printer: printer) - let tuistConfig = try loadTuistConfig(at: path) + printer: printer, + tuistConfig: tuistConfig) - if let manifestTargetGenerator = manifestTargetGenerator, tuistConfig.generationOptions.contains(.generateManifest) { - let manifestTarget = try manifestTargetGenerator.generateManifestTarget(for: project.name, - at: path) - return project.adding(target: manifestTarget) - - } else { - return project - } + return try enriched(model: project, with: tuistConfig) } func loadWorkspace(at path: AbsolutePath) throws -> TuistGenerator.Workspace { @@ -114,6 +108,42 @@ class GeneratorModelLoader: GeneratorModelLoading { return locateDirectoryTraversingParents(from: from.parentDirectory, path: path, fileHandler: fileHandler) } } + + private func enriched(model: TuistGenerator.Project, + with config: TuistGenerator.TuistConfig) throws -> TuistGenerator.Project { + var enrichedModel = model + + // Manifest target + if let manifestTargetGenerator = manifestTargetGenerator, config.generationOptions.contains(.generateManifest) { + let manifestTarget = try manifestTargetGenerator.generateManifestTarget(for: enrichedModel.name, + at: enrichedModel.path) + enrichedModel = enrichedModel.adding(target: manifestTarget) + } + + // Xcode project file name + let xcodeFileName = xcodeFileNameOverride(from: config, for: model) + enrichedModel = enrichedModel.replacing(fileName: xcodeFileName) + + return enrichedModel + } + + private func xcodeFileNameOverride(from config: TuistGenerator.TuistConfig, + for model: TuistGenerator.Project) -> String? { + var xcodeFileName = config.generationOptions.compactMap { item -> String? in + switch item { + case let .xcodeProjectName(projectName): + return projectName.description + default: + return nil + } + }.first + + let projectNameTemplate = TemplateString.Token.projectName.rawValue + xcodeFileName = xcodeFileName?.replacingOccurrences(of: projectNameTemplate, + with: model.name) + + return xcodeFileName + } } extension TuistGenerator.TuistConfig { @@ -125,11 +155,13 @@ extension TuistGenerator.TuistConfig { } extension TuistGenerator.TuistConfig.GenerationOption { - static func from(manifest: ProjectDescription.TuistConfig.GenerationOption, + static func from(manifest: ProjectDescription.TuistConfig.GenerationOptions, path _: AbsolutePath) throws -> TuistGenerator.TuistConfig.GenerationOption { switch manifest { case .generateManifest: return .generateManifest + case let .xcodeProjectName(templateString): + return .xcodeProjectName(templateString.description) } } } @@ -218,7 +250,8 @@ extension TuistGenerator.Project { static func from(manifest: ProjectDescription.Project, path: AbsolutePath, fileHandler: FileHandling, - printer: Printing) throws -> TuistGenerator.Project { + printer: Printing, + tuistConfig _: TuistGenerator.TuistConfig) throws -> TuistGenerator.Project { let name = manifest.name let settings = manifest.settings.map { TuistGenerator.Settings.from(manifest: $0, path: path) } let targets = try manifest.targets.map { @@ -249,12 +282,24 @@ extension TuistGenerator.Project { func adding(target: TuistGenerator.Target) -> TuistGenerator.Project { return Project(path: path, name: name, + fileName: fileName, settings: settings, filesGroup: filesGroup, targets: targets + [target], schemes: schemes, additionalFiles: additionalFiles) } + + func replacing(fileName: String?) -> TuistGenerator.Project { + return Project(path: path, + name: name, + fileName: fileName, + settings: settings, + filesGroup: filesGroup, + targets: targets, + schemes: schemes, + additionalFiles: additionalFiles) + } } extension TuistGenerator.Target { diff --git a/Tests/ProjectDescriptionTests/TuistConfigTests.swift b/Tests/ProjectDescriptionTests/TuistConfigTests.swift new file mode 100644 index 00000000000..36b4d2eeb7b --- /dev/null +++ b/Tests/ProjectDescriptionTests/TuistConfigTests.swift @@ -0,0 +1,13 @@ +import Foundation +import XCTest +@testable import ProjectDescription + +final class TuistConfigTests: XCTestCase { + func test_tuistconfig_toJSON() throws { + let tuistConfig = TuistConfig(generationOptions: + [.generateManifest, + .xcodeProjectName("someprefix-\(.projectName)")]) + + XCTAssertCodable(tuistConfig) + } +} diff --git a/Tests/TuistGeneratorTests/Generator/ProjectGeneratorTests.swift b/Tests/TuistGeneratorTests/Generator/ProjectGeneratorTests.swift index 6e6d784c77d..b5077a06fd5 100644 --- a/Tests/TuistGeneratorTests/Generator/ProjectGeneratorTests.swift +++ b/Tests/TuistGeneratorTests/Generator/ProjectGeneratorTests.swift @@ -139,4 +139,29 @@ final class ProjectGeneratorTests: XCTestCase { }, "Test target is missing from target attributes.") } + + func test_generate_testUsingFileName() throws { + // Given + let project = Project.test(path: fileHandler.currentPath, + name: "Project", + fileName: "SomeAwesomeName", + targets: []) + try fileHandler.touch(fileHandler.currentPath.appending(component: "Project.swift")) + let target = Target.test() + let cache = GraphLoaderCache() + cache.add(project: project) + let graph = Graph.test(entryPath: fileHandler.currentPath, + cache: cache, + entryNodes: [TargetNode(project: project, + target: target, + dependencies: [])]) + + // When + let got = try subject.generate(project: project, graph: graph) + + // Then + XCTAssertTrue(fileHandler.exists(got.path)) + XCTAssertEqual(got.path.components.last, "SomeAwesomeName.xcodeproj") + XCTAssertEqual(project.name, "Project") + } } diff --git a/Tests/TuistGeneratorTests/Models/ProjectTests.swift b/Tests/TuistGeneratorTests/Models/ProjectTests.swift index 2c163375214..2d626721df7 100644 --- a/Tests/TuistGeneratorTests/Models/ProjectTests.swift +++ b/Tests/TuistGeneratorTests/Models/ProjectTests.swift @@ -5,6 +5,7 @@ import XCTest final class ProjectTests: XCTestCase { func test_sortedTargetsForProjectScheme() { + // Given let framework = Target.test(name: "Framework", product: .framework) let app = Target.test(name: "App", product: .app) let appTests = Target.test(name: "AppTets", product: .unitTests) @@ -20,11 +21,31 @@ final class ProjectTests: XCTestCase { (target: appTests, dependencies: [app]), ]) + // When let got = project.sortedTargetsForProjectScheme(graph: graph) + + // Then XCTAssertEqual(got.count, 4) XCTAssertEqual(got[0], framework) XCTAssertEqual(got[1], app) XCTAssertEqual(got[2], appTests) XCTAssertEqual(got[3], frameworkTests) } + + func test_projectDefaultFileName() { + // Given + let framework = Target.test(name: "Framework", product: .framework) + let app = Target.test(name: "App", product: .app) + let appTests = Target.test(name: "AppTests", product: .unitTests) + let frameworkTests = Target.test(name: "FrameworkTests", product: .unitTests) + + // When + let project = Project.test(name: "SomeProjectName", targets: [ + framework, app, appTests, frameworkTests, + ]) + + // Then + XCTAssertEqual(project.fileName, "SomeProjectName") + XCTAssertEqual(project.name, "SomeProjectName") + } } diff --git a/Tests/TuistGeneratorTests/Models/TestData/Project+TestData.swift b/Tests/TuistGeneratorTests/Models/TestData/Project+TestData.swift index 96e03bef768..93b8a637e7f 100644 --- a/Tests/TuistGeneratorTests/Models/TestData/Project+TestData.swift +++ b/Tests/TuistGeneratorTests/Models/TestData/Project+TestData.swift @@ -5,6 +5,7 @@ import Foundation extension Project { static func test(path: AbsolutePath = AbsolutePath("/test/"), name: String = "Project", + fileName: String? = nil, settings: Settings = Settings.test(), filesGroup: ProjectGroup = .group(name: "Project"), targets: [Target] = [Target.test()], @@ -12,6 +13,7 @@ extension Project { additionalFiles: [FileElement] = []) -> Project { return Project(path: path, name: name, + fileName: fileName, settings: settings, filesGroup: filesGroup, targets: targets, diff --git a/Tests/TuistKitTests/Generator/GeneratorModelLoaderTests.swift b/Tests/TuistKitTests/Generator/GeneratorModelLoaderTests.swift index ddcf67f1dc3..5685ba4ed11 100644 --- a/Tests/TuistKitTests/Generator/GeneratorModelLoaderTests.swift +++ b/Tests/TuistKitTests/Generator/GeneratorModelLoaderTests.swift @@ -165,6 +165,63 @@ class GeneratorModelLoaderTest: XCTestCase { XCTAssertEqual(model.additionalFiles, files.map { .folderReference(path: $0) }) } + func test_loadProject_withCustomName() throws { + // Given + try fileHandler.createFiles([ + "TuistConfig.swift", + ]) + + let manifests = [ + path: ProjectManifest.test(name: "SomeProject", + additionalFiles: [ + .folderReference(path: "Stubs"), + ]), + ] + let configs = [ + path: ProjectDescription.TuistConfig.test(generationOptions: [.xcodeProjectName("one \(.projectName) two")]), + ] + let manifestLoader = createManifestLoader(with: manifests, configs: configs) + let subject = GeneratorModelLoader(fileHandler: fileHandler, + manifestLoader: manifestLoader, + manifestLinter: manifestLinter, + manifestTargetGenerator: manifestTargetGenerator) + + // When + let model = try subject.loadProject(at: path) + + // Then + XCTAssertEqual(model.fileName, "one SomeProject two") + } + + func test_loadProject_withCustomNameDuplicates() throws { + // Given + try fileHandler.createFiles([ + "TuistConfig.swift", + ]) + + let manifests = [ + path: ProjectManifest.test(name: "SomeProject", + additionalFiles: [ + .folderReference(path: "Stubs"), + ]), + ] + let configs = [ + path: ProjectDescription.TuistConfig.test(generationOptions: [.xcodeProjectName("one \(.projectName) two"), + .xcodeProjectName("two \(.projectName) three")]), + ] + let manifestLoader = createManifestLoader(with: manifests, configs: configs) + let subject = GeneratorModelLoader(fileHandler: fileHandler, + manifestLoader: manifestLoader, + manifestLinter: manifestLinter, + manifestTargetGenerator: manifestTargetGenerator) + + // When + let model = try subject.loadProject(at: path) + + // Then + XCTAssertEqual(model.fileName, "one SomeProject two") + } + func test_loadWorkspace() throws { // Given let manifests = [ diff --git a/Tests/TuistKitTests/Generator/TestData/ProjectDescription+TestData.swift b/Tests/TuistKitTests/Generator/TestData/ProjectDescription+TestData.swift index dfbf7822518..df57fca87e3 100644 --- a/Tests/TuistKitTests/Generator/TestData/ProjectDescription+TestData.swift +++ b/Tests/TuistKitTests/Generator/TestData/ProjectDescription+TestData.swift @@ -2,7 +2,7 @@ import Foundation @testable import ProjectDescription extension TuistConfig { - static func test(generationOptions: [TuistConfig.GenerationOption] = []) -> TuistConfig { + static func test(generationOptions: [TuistConfig.GenerationOptions] = []) -> TuistConfig { return TuistConfig(generationOptions: generationOptions) } } diff --git a/docs/usage/tuistconfig.swift.mdx b/docs/usage/tuistconfig.swift.mdx index ed4601f3ef8..008eb37f627 100644 --- a/docs/usage/tuistconfig.swift.mdx +++ b/docs/usage/tuistconfig.swift.mdx @@ -31,7 +31,8 @@ import ProjectDescription let config = TuistConfig( generationOptions: [ - .generateManifest + .generateManifest, + .xcodeProjectName("SomePrefix-\(.projectName)-SomeSuffix") ] ) ``` @@ -63,6 +64,23 @@ Generation options allow customizing the generation of Xcode projects. case: '.generateManifest', description: 'Generate the target, schemes, and file elements to build compile the project manifest files.', + case: '.xcodeProjectName(TemplateString)', + description: + 'Customise the name of the generated .xcodeproj.', + }, + ]} +/> + +## TemplateString + +Allows a string with interpolated properties to be specified. For example, "Prefix-\(.projectname)". + + diff --git a/fixtures/ios_app_with_frameworks/TuistConfig.swift b/fixtures/ios_app_with_frameworks/TuistConfig.swift index 4d6fc4aef80..3c30e4a59b0 100644 --- a/fixtures/ios_app_with_frameworks/TuistConfig.swift +++ b/fixtures/ios_app_with_frameworks/TuistConfig.swift @@ -2,6 +2,7 @@ import ProjectDescription let config = TuistConfig( generationOptions: [ - .generateManifest + .generateManifest, + .xcodeProjectName("AwesomePrefix-\(.projectName)-AwesomeSuffix") ] ) \ No newline at end of file