paint-brush
Tuist Modularization With Code Generation and How to Get Started With "Helpers"by@ze8c
1,029 reads
1,029 reads

Tuist Modularization With Code Generation and How to Get Started With "Helpers"

by Maksym SytyiJune 16th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

This article is for individuals who are either starting a project without a project file or those who are already in progress but may be facing challenges with maintenance.
featured image - Tuist Modularization With Code Generation and How to Get Started With "Helpers"
Maksym Sytyi HackerNoon profile picture

Who is this article for? It is for individuals who are either starting a project without a project file or those who are already in progress but may be facing challenges with maintenance. It may also appeal to anyone with an interest in the topic


At some point, everyone encounters a situation where a project needs to be divided into modules, each with its own scope and associated elements. There are several methods for dividing and maintaining a project. However, I would like to introduce a helpful tool that has aided me in the past


For those who require an example in the form of code, I will provide a link to the repository that contains an example related to this article. The example can be found at https://github.com/Ze8c/TuistExample/tree/main


So, let's get started with Tuist, following the instructions provided in this guide: https://docs.tuist.io/tutorial/get-started


curl -Ls https://install.tuist.io | bash


Once Tuist is installed, you can generate a project directly from the console


tuist init --platform ios


and make


tuist generate


After initiating the project generation process, the project will automatically open in XCode


Inited project



You'll be able to see the basic project structure and hierarchy. Close XCode and reopen the project for editing


tuist edit


XCode will open with the settings of our project displayed


Project settings



As you can observe, the settings are based on Swift and resemble the Swift Package Manager (SPM), which makes it easier to get started


While Tuist allows for more flexible customization of the project, it often leads to code duplication in most cases. To enhance development speed and maintainability, you can utilize the factory pattern and create a more structured approach. Additionally, using flexible Swift enums to build a factory can be aesthetically pleasing


Important! The project setup is divided into subscopes, which is crucial when creating new files within the project setup


Let's begin by creating a structure for the "ProjectDescriptionHelpers" scope, which will manage certain application settings


public struct AppConfig {
    public let marketingVersion: String
    public let buildVersion: InfoPlist.Value
    public let organizationName: String
    public let bunldePrefix: String
    public let iosTargetVersion: String
    
    public init(
        marketingVersion: String,
        buildVersion: InfoPlist.Value,
        organizationName: String,
        bunldePrefix: String,
        iosTargetVersion: String
    ) {
        self.marketingVersion = marketingVersion
        self.buildVersion = buildVersion
        self.organizationName = organizationName
        self.bunldePrefix = bunldePrefix
        self.iosTargetVersion = iosTargetVersion
    }
}


We'll start by creating a small extension for strings


extension String {
    var capitalizingFirstLetter: String {
        return prefix(1).uppercased() + dropFirst()
    }
}


Next, we'll create scopes within the application. These scopes are folders that logically group modules together. For example, we'll create "flow" and "core" scopes, which will contain components related to flow management and core elements such as our design system


public enum AppScope: String {
    case root
    case core
    case flow
    
    public var folder: String {
        rawValue.capitalizingFirstLetter
    }
}


If the project has external dependencies, we need to add an additional target called "Dependencies" to the project configuration


Add new target



We'll also include a dependency configuration entity to make the process more convenient. This entity will assist us in adding the dependencies to the project


enum AppDependencie {
    case target(AppTarget)
    case external(ExternalDependencie)
}

extension AppDependencie {
    var convert: TargetDependency {
        switch self {
        case let .target(item): return .target(name: item.name)
        case let .external(item): return .external(name: item.name)
        }
    }
}


public enum ExternalDependencie: CaseIterable {
    case kingfisher
}

extension ExternalDependencie {
    public var package: Package {
        switch self {
        case .kingfisher:
            return .remote(
                url: "https://github.com/onevcat/Kingfisher.git",
                requirement: .exact("7.6.2")
            )
        }
    }
    
    var name: String {
        switch self {
        case .kingfisher: return "Kingfisher"
        }
    }
}


Finally, we'll add the external dependencies to the project in scope “Dependencies


let packageManager = SwiftPackageManagerDependencies(dependencies: ExternalDependencie.allCases)

let dependencies = Dependencies(
    swiftPackageManager: packageManager,
    platforms: [.iOS]
)

extension SwiftPackageManagerDependencies {
    init(dependencies: [ExternalDependencie]) {
        self.init(dependencies.map(\.package))
    }
}


Once we have defined the scopes, we'll organize our targets and set up their dependencies. It's important to keep track of dependencies to avoid circular dependencies between modules


public enum AppTarget: String, CaseIterable {
    case main
    case tuistExampleKit
    case tuistExampleUI
}

extension AppTarget {
    public var id: String {
        rawValue
    }
    
    public var name: String {
        id.capitalizingFirstLetter
    }
    
    public var scope: AppScope {
        switch self {
        case .main:
            return .root
            
        case .tuistExampleKit:
            return .core
            
        case .tuistExampleUI:
            return .flow
        }
    }
    
    private var dependencies: [AppDependencie] {
        switch self {

        case .main:
            return [
                .target(.tuistExampleKit),
                .target(.tuistExampleUI)
            ]
            
        case .tuistExampleKit:
            return []
            
        case .tuistExampleUI:
            return [
                .external(.kingfisher),
                .target(.tuistExampleKit)
            ]
        }
    }
}

extension AppTarget {
    public var target: Target {
        switch self {
        case .main:
            return mainTarget(AppSetup.appConfig)
        case .tuistExampleKit, .tuistExampleUI:
            return makeBaseTarget(AppSetup.appConfig)
        }
    }
    
    private func mainTarget(_ config: AppConfig) -> Target {
        Target(
            name: "Main",
            platform: .iOS,
            product: .app,
            bundleId: config.bunldePrefix + "superapp",
            deploymentTarget: .iOS(targetVersion: config.iosTargetVersion, devices: .iphone),
            
            infoPlist: .extendingDefault(with: [
                "CFBundleShortVersionString": InfoPlist.Value(stringLiteral: config.marketingVersion),
                "CFBundleVersion": config.buildVersion,
                "UIMainStoryboardFile": "",
                "UILaunchStoryboardName": "LaunchScreen",
                "CFBundleIconName": "AppIcon",
                "CFBundleDisplayName": "TestName",
                "UISupportedInterfaceOrientations": ["UIInterfaceOrientationPortrait"],
                "UISupportedInterfaceOrientations~ipad": ["UIInterfaceOrientationPortrait", "UIInterfaceOrientationPortraitUpsideDown"],
                "UIUserInterfaceStyle": "Light",
            ]),
            
            sources: ["Targets/Main/Sources/**"],
            resources: ["Targets/Main/Resources/**"],
            dependencies: dependencies.map(\.convert),
            settings: Settings.settings(
                base:
                    SettingsDictionary()
                    .bitcodeEnabled(false)
                    .marketingVersion(config.marketingVersion)
                    .merging(["ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon"])
                ,
                debug: Configuration.debug(name: .debug).settings,
                release: Configuration.release(name: .release).settings,
                defaultSettings: DefaultSettings.essential(excluding: [])
            )
        )
    }
    
    private func makeBaseTarget(_ config: AppConfig) -> Target {
        createFolderIfNeeded()
        
        let pathSource = "Targets/\(scope.folder)/\(name)/Sources"
        let pathResource = "Targets/\(scope.folder)/\(name)/Resources"
        let doesResourceFolderExist = FileManager.default.fileExists(atPath: pathResource, isDirectory: nil)
        
        return Target(
            name: name,
            platform: .iOS,
            product: .framework,
            bundleId: config.bunldePrefix + id,
            deploymentTarget: .iOS(targetVersion: config.iosTargetVersion, devices: .iphone),
            infoPlist: .extendingDefault(with: [:]),
            sources: ["\(pathSource)/**"],
            resources: doesResourceFolderExist ? ["\(pathResource)/**"] : [],
            dependencies: dependencies.map(\.convert),
            settings: Settings.settings(
                base: SettingsDictionary().bitcodeEnabled(false),
                debug: Configuration.debug(name: .debug).settings,
                release: Configuration.release(name: .release).settings,
                defaultSettings: DefaultSettings.essential(excluding: [])
            )
        )
    }
    
    private func createFolderIfNeeded() {
        let sourcesPath = "Targets/\(scope.folder)/\(name)/Sources"
        
        if !FileManager.default.fileExists(atPath: sourcesPath) {
            do {
                try FileManager.default.createDirectory(
                    atPath: sourcesPath,
                    withIntermediateDirectories: true,
                    attributes: nil
                )
                FileManager.default.createFile(atPath: "\(sourcesPath)/Placeholder.swift", contents: nil)
            } catch {
                print("Create dir error", error.localizedDescription)
            }
        }
    }
}


With all the preparations complete, we'll make some slight adjustments to the property that handles the entire project


let project = Project(
    name: "TuistExample",
    organizationName: AppSetup.appConfig.organizationName,
    targets: AppTarget.allCases.map(\.target),
    resourceSynthesizers: [
        .strings(),
        .assets(),
    ]
)


That's all for now. You can close the project configuration, press ctrl + C in the console, and remember to download all external dependencies using the appropriate command before generating the project


tuist fetch


Regenerate the project


tuist generate


Once the project generation is complete, you can enjoy the results.