RFC: Custom Code Generators for Tuist Targets

Summary

This RFC proposes codeGenerators: on a Tuist target: a declaration that a command produces some of the target’s sources from a declared set of inputs. Tuist runs the command, attaches its output directory to the target as a buildable folder, and folds the inputs into the target’s cache key.

The decision everything else follows from is that generated output is never content-hashed. The hash comes from the declared inputs: plus the command text, which is the policy ForeignBuildHasher already implements for foreignBuild xcframeworks. That is what lets tuist hash, tuist graph, and tuist inspect be correct without running a single generator, and what lets generation be deferred until after focus, tree-shaking, and cache substitution.

A second primitive covers generators whose output spans several targets and so cannot be attached to only one.

Motivation

Developers who generate Swift sources today have two options.

An Xcode build-phase script. The generator runs at build time, so before the first build there are no symbols; autocomplete, jump-to-definition, and diagnostics are unavailable on any line touching generated code. This is the main cost. Tuist does hash a script’s declared inputPaths by content, so caching is not broken, and a tool’s version can be approximated by listing a lockfile.

A bespoke CLI or script run before tuist generate. This is the shape I see at scale. A large app I work with, roughly 800 targets, has a Swift CLI generating resource accessors, localized strings, analytics events, snapshot-test scaffolding, deeplink routes, and GraphQL types via Apollo codegen. Two costs follow from Tuist not knowing about it:

  1. It has to roll its own caching. Every generator carries a hand-written hash file so it can skip work, duplicating machinery Tuist already has and keeping it correct independently.
  2. It has to model the project itself. Several generators need to know which targets exist and where their sources are. Either the tool derives its own graph from directory layout, in parallel with Tuist’s, or it asks Tuist for the graph and generation becomes two passes.

There is also a smaller ergonomic cost: tuist has to be wrapped in another command that runs first, which is a step every engineer and CI job has to know about.

The generators themselves are fine. Both workarounds exist because Tuist has no way to say: these files are produced by this command, from these inputs.

Current State

Most of the mechanism already ships, which is what allows this proposal to be small. RFC: External Build System Dependencies landed foreignBuild, and with it:

  • Target.ForeignBuild.Input: .file, .folder, .glob, and .script, whose stdout is captured. Reused here verbatim.
  • Inputs-only hashing. ForeignBuildHasher folds those inputs and the script text into the target hash and never touches the produced artifact, because the artifact does not exist on a fresh clone and its bytes are a function of the inputs anyway.
  • Selection gating. ForeignBuildSideEffectGraphMapper runs after cache substitution and tree-shaking, so a pruned or cache-hit target never executes its script.
  • Product-less targets, generated as PBXAggregateTarget.

Tuist also already attaches generated sources with nothing declared in any manifest: SynthesizedResourceInterfaceProjectMapper renders resource accessors into Derived/Sources and appends them to target.sources. Resource synthesizers are the existing answer to this problem class. Their templates are already overridable, per-project or from a plugin; what is fixed is the set of parsers, so they cover the file types SwiftGen knows about and nothing else. What follows is a generalization of that mechanism to an open class of generators, and it is close enough that .assets() and its siblings could plausibly be reimplemented on top of it.

Prior Art

SwiftPM Build Tool Plugins (SE-0303)

A plugin is declared as a package target of type plugin, containing Swift source with a @main type conforming to BuildToolPlugin. The plugin does not do the work; it returns Command values naming a tool resolved via context.tool(named:), where the tool is an executableTarget or a prebuilt binaryTarget. Arbitrary shell scripts were considered and rejected as “more subtle and less clear” than a Swift API, though a plugin can wrap one.

The directly relevant part is prebuildCommand: instead of enumerating outputs it takes an outputFilesDirectory, and SwiftPM treats whatever files are present afterward as sources. That exists precisely because tools like SwiftGen produce a file set determined by input contents rather than input names. The tradeoff SE-0303 accepts is that prebuild commands run on every build and are expected to cache aggressively themselves. What does not transfer is requiring the generator to be a SwiftPM target at all, since teams already own SwiftGen, Apollo, and Sourcery invocations wired into mise and shell. Reference: SE-0303.

Bazel genrule

The genrule rule takes srcs, outs, and cmd. The action’s cache key comes from srcs, cmd, and the resolved toolchain, never from outs. That is what makes remote caching sound and lets Bazel decide an action is up to date without running it. In exchange outs must be declared exactly, and writing an undeclared file fails the action. Reference: Bazel genrule.

The inputs-over-outputs hashing principle is worth adopting wholesale, because it decouples “compute the hash” from “run the tool.” The rest of Bazel’s action key does not come along, and that is worth being clear about: Bazel also folds in the resolved toolchain and a controlled environment, and enforces the declaration with a sandbox that fails an action for reading or writing outside it. This proposal has none of that, so its keys are sound only to the extent that a generator’s declaration is honest, where Bazel’s are sound by construction.

The exhaustive outs is the other part that does not transfer, because it buys something this design does not need: Bazel enumerates outputs so other actions can depend on individual generated files, and so the sandbox can catch undeclared writes. Here the consumer is always the owning target and the unit of consumption is the whole directory, so a declared output directory carries the same information. SE-0303 reached the same conclusion with prebuildCommand.

Proposed Solution

Per-target generators

A target declares generators that produce its own sources:

.target(
    name: "AccountUI",
    destinations: .iOS,
    product: .staticFramework,
    bundleId: "dev.tuist.example.accountui",
    buildableFolders: ["Sources/AccountUI"],
    codeGenerators: [
        .script(
            name: "assets",
            script: """
            mise x -- swiftgen run xcassets \
              --templatePath "$TUIST_ROOT_DIR/templates/Assets.stencil" \
              --output "$TUIST_OUTPUT_DIR/TuistAssets+$TUIST_TARGET_NAME.swift" \
              "$TUIST_PROJECT_DIR/Sources/AccountUI/Resources"
            """,
            inputs: [
                .folder("Sources/AccountUI/Resources"),
                .file("//templates/Assets.stencil"),
                .script("mise x -- swiftgen --version"),
            ]
        ),
    ]
)

Output goes to Derived/CodeGen/Targets/{target-name}/{generator-name}/, which Tuist attaches implicitly as a buildable folder. No output path appears in any manifest. It is fully determined by the target name and the generator’s name:, so there is nothing to declare and nothing that can drift. name: therefore doubles as a label and a path component, which is what keeps two generators on one target from colliding.

Those path components need only be unique within a project, not across the workspace, because Derived/ is rooted at the project directory rather than the repository root. Two projects may each define a Utilities target, and their output lands in Projects/Foo/Derived/… and Projects/Bar/Derived/… respectively. Target names are unique within a project, so {target-name}/{generator-name} is unambiguous.

The environment provides TUIST_OUTPUT_DIR, TUIST_TARGET_NAME, TUIST_PROJECT_DIR, and TUIST_ROOT_DIR. The last two are both needed because a manifest can name paths relative to either. Notably Derived/ never appears, which leaves Tuist free to change that layout later.

Resolved inputs are passed as a file, not as an environment variable: TUIST_INPUTS_FILE points at JSON describing each declared input.

[
  { "type": "folder", "path": "/…/Sources/AccountUI/Resources" },
  { "type": "file",   "path": "/…/templates/Assets.stencil" }
]

A single variable holding hundreds of newline-separated paths would be fragile: it competes with the process argument and environment limit, and it forces a quoting convention on paths that may contain spaces. A file also lets each entry keep its type, which matters because .script inputs are not paths at all. They contribute stdout to the hash and are therefore absent from this list.

Note the example does not feed this file to swiftgen, because inputs and command arguments serve different purposes: inputs declare what the generator depends on, for hashing, while the command names what it reads. TUIST_INPUTS_FILE is there for generators that want to operate on exactly the declared set, which is the only way to guarantee the files hashed and the files read are the same.

The hashing rule, and what follows from it

A codegen output directory never contributes its file contents to a target hash. The generator’s declared inputs: plus the command text contribute instead.

This carries a contract, and it is the one thing a generator author has to internalize: Tuist assumes a generator’s output is a deterministic function of its command, its declared inputs, and its execution environment. Undeclared dependencies violate that contract and may produce incorrect cache hits. Nothing enforces it in the first version, which is what the sandbox under Future Directions would change.

One consequence is enforceable now, and cheaply: a resolved codegen input and output may not overlap, in either direction, after canonical path resolution. Input inside output is the chaining case discussed under Future Directions. Output inside input is the subtler one, and just as broken: declare .folder("//") or a wide glob, and each run folds the previous run’s generated files into the hash of the run that produces them, so the hash never settles and no source change reliably invalidates.

Globs make this awkward to check, for the same reason they are awkward elsewhere in this proposal: they are expanded eagerly, so on a first run the generated files do not exist and the expansion looks clean. The check therefore has to compare the glob’s root against the output directories rather than only its current matches.

Read-only commands do not run generators. Buildable folders are otherwise content-hashed by reading every resolved file from disk. On tuist hash the generator has not run, so that hash would be taken over an empty or stale directory and would differ from what tuist generate later computes. Excluding the generated folder removes the dependency on disk state, so the hash is the same whether or not output exists. tuist generate, build, and test run generators because Xcode needs real files; tuist hash, cache’s hashing pass, graph, and inspect do not.

Only selected targets generate. Because no hash depends on generated bytes, generation need not precede hashing, only compilation. So it can be deferred past the focus, tree-shaking, and cache-substitution mappers, which is the slot ForeignBuildSideEffectGraphMapper already occupies. In a project with a generator on many targets, tuist generate --focus AccountUI then runs one generator rather than all of them.

A buildable folder rather than sources:. A buildable folder is a PBXFileSystemSynchronizedRootGroup holding only a path, with contents resolved by Xcode at build time, so the .xcodeproj does not have to enumerate what the generator produced. That is what makes deferral above safe, and it leaves room to run generators concurrently with writing the project rather than strictly before it. sources: would require the file list at project-write time and reintroduce the enumeration problem.

Multi-target generators

The per-target primitive covers a generator whose inputs and output both belong to one target, which is most of them. It does not cover a generator configured to emit into several targets from one invocation. Apollo is the case I hit: it can be configured so that schema types and operation types land in separate modules, and while you could run it once per module, each run would reparse the whole schema and rescan every .graphql file, so splitting it is possible but wasteful.

For that shape, a product-less .codegen target:

.codegen(
    name: "GQLCodegen",
    script: #"exec "$TUIST_ROOT_DIR/Codegen/apollo.sh""#,
    inputs: [
        .file("//Codegen/schema.graphqls"),
        .glob("//Projects/**/*.graphql"),
        .script("mise x -- apollo-ios-cli --version"),
    ],
    outputs: [
        .output(target: "GQLSchema"),
        .output(target: "GQLTypes"),
        // Cross-project consumers name their project:
        // .output(project: "//Projects/Networking", target: "GQLTransport"),
    ]
)

This calls a script rather than the tool directly, and that is inherent rather than incidental: apollo-ios-cli generate takes a single JSON configuration naming its own output paths, so a wrapper has to reconcile that configuration with the directories Tuist assigned. Any multi-output generator needs equivalent glue unless it happens to accept one output path per output.

GQLSchema and GQLTypes are ordinary targets that declare nothing about codegen, neither the output path nor the dependency. Output goes to Derived/CodeGen/Producers/{producer-name}/{consumer-name}/ inside the consumer’s project, which is both where the buildable folder is attached and what keeps two same-named consumers in different projects apart. Declaring outputs: is the single source of truth, yielding that path, the buildable folder attached to each consumer, and the build-ordering edge. The producer states the mapping because only it knows its fan-out; with several outputs Tuist cannot infer which directory belongs to which consumer.

A bare target name identifies a consumer in the producer’s own project, where names are unique; .output(project:target:) names one elsewhere. The distinction matters because a target name alone is not a workspace-wide identity.

Because there are several output directories, the generator is told about them through a file rather than through one variable per consumer: TUIST_OUTPUTS_FILE points at a JSON array.

[
  { "project": "//Projects/Core", "target": "GQLSchema",
    "path": "/…/Derived/CodeGen/Producers/GQLCodegen/GQLSchema" },
  { "project": "//Projects/Core", "target": "GQLTypes",
    "path": "/…/Derived/CodeGen/Producers/GQLCodegen/GQLTypes" }
]

An array rather than an object keyed by target, because target names are not unique across projects: a producer can legitimately feed Models in two different projects, and one JSON key cannot hold both. Carrying project and target as separate fields also avoids inventing an encoded-identity grammar that every generator would then have to parse.

Deriving a variable name per consumer, such as TUIST_OUTPUT_DIR_GQLSchema, fails for a related reason and a simpler one. Shell identifiers admit only alphanumerics and underscore and cannot lead with a digit, while target names can contain spaces, hyphens, and dots, and can begin with a number. Any normalization that maps them into identifiers also merges distinct names: Foo-Bar, Foo.Bar, and Foo_Bar all collapse to one variable, and 123Core needs a prefix that could collide with a target actually called that.

The injected edge has to land in target.dependencies on the model, not only in graph.dependencies: the content hasher walks the former, so a graph-only edge would order the build correctly while leaving consumers’ hashes unmoved when the schema changed. This is the reason the primitive touches the dependency model at all, and the cost of doing so is in Trade-offs.

Relationship to .assets()

I reimplemented Tuist’s .assets() synthesizer with an external tool and compared the generated output. The declaration shape matches, including per-catalog namespacing when a target has more than one catalog; the remaining differences are template detail. A case Tuist already handles internally is therefore expressible through the public primitive, which is the bar for the mechanism being general rather than shaped around the generators I happened to need. The built-in remains preferable where it fits, since it needs no tool installed and its template is maintained by Tuist.

Non-Goals

  • Not a replacement for resource synthesizers in this RFC. Built-ins stay as they are. Whether they should eventually be reimplemented on this primitive, or deprecated in its favour, is worth deciding separately; see Open Questions.
  • No codegen on package-resolved targets. TargetType distinguishes .local from .remote, where remote means resolved and pulled by SwiftPM. Nothing would stop a manifest naming such a target today, which is why it needs to be rejected explicitly: Tuist does not own those manifests, their sources live under a checkouts directory, and their Derived/ is not ours to write into.

Trade-offs

Advantages

  • A generator’s inputs become manifest data, so they land in the target hash with no side files and no second project model.
  • A generator’s own version and templates can be hashed via .script and .file inputs.
  • Per-target scoping lets one module adopt a generator without the rest of the graph changing.
  • Generators run only for targets surviving focus, tree-shaking, and cache substitution.
  • No wrapper command: tuist generate on a fresh clone produces a complete project in one pass.

Disadvantages

  • A hand-edit of generated code is invisible to the cache, and the next generate overwrites it. This mirrors foreignBuild, whose hash is foreignBuild-{name}-{script}-{inputsHash} and never the artifact, so editing a built xcframework in place is equally invisible. Accepted deliberately, but “do not patch generated output” becomes a documentation obligation rather than an enforced invariant.
  • The purity contract is unenforced. A generator that reads an undeclared file, or is non-deterministic, gets an incorrect cache hit and nothing detects it. Resource synthesizers avoid this by hashing the bytes they just rendered; this design trades it for not shelling out on tuist hash. Passing resolved inputs narrows the gap, since a generator can consume exactly the declared set, but a command is free to ignore them.
  • A target’s file set is no longer readable from its manifest. Tuist generally avoids that implicitness, though it already makes this exception for resource synthesizers.
  • The multi-target primitive creates a dependency edge no manifest declared, which is new behavior and means the dependency graph is no longer fully described by manifests.

Alternatives Considered

A pre-build TargetScript on each consumer

Scripts belong to a single target, so a generator serving several consumers runs once per consumer. And the script runs at build time, so generated symbols are unavailable until after the first build.

Declaring sources: [.generated(...)] on consumers

Tuist already has a source type for files that do not exist at generation time. It is deliberately narrow: a .generated() entry must be a specific path, glob patterns are rejected outright, and each entry maps to exactly one source file, hashed by its path rather than its contents. That works for a generator with a fixed output list, and not for one whose file set depends on input contents. An Apollo run emits one type per schema type and per operation, so the set changes when a .graphql file is added and cannot be enumerated in the manifest. A directory of generated files, with contents resolved later, is the shape that covers both.

A plain buildable folder, with no generated marker

This compiles, since Xcode resolves a synchronized folder’s contents at build time. But the folder’s resolved files are globbed at manifest-map time and content-hashed, so the hash reflects the previous run’s output and tuist hash hashes an empty directory. Ruled out on cache correctness, and the reason a marker is needed.

Requiring consumers to declare the dependency on a .codegen target

This needs no new machinery, since a manifest-declared edge is in target.dependencies by construction, and it keeps the graph fully described by manifests. Rejected because correctness would then depend on two declarations agreeing, and the two ways they can disagree are not equally safe. If a consumer declares the dependency and the producer omits it from outputs:, no folder is attached and compilation fails on missing symbols. If the producer lists a consumer that omits the dependency, the folder is attached and the sources compile, but nothing orders codegen before that target’s build. Worth keeping as the fallback if an injected edge is unacceptable, since a build-ordering problem surfaces as a failed build rather than a wrong artifact.

Rollout

Reused as-is: ForeignBuild.Input and its path resolution, ForeignBuildHasher’s inputs-only policy, the post-tree-shaking mapper slot, buildable folder generation, and the extensible set of preserved Derived/ subdirectories.

New work for the per-target primitive, which is shippable on its own:

  1. Add codeGenerators: and .script(name:script:inputs:) to ProjectDescription, along with the environment contract and implicit attachment of the output folder.
  2. A generated marker on BuildableFolder plus a skip for it in target content hashing. Tuist sets the marker when it attaches the folder, so there is no new manifest surface and hand-declared folders are unaffected.
  3. Preserve Derived/CodeGen across generates, paired with a prune of subdirectories no longer matching a target and generator. Preservation is what makes the input-hash skip meaningful; the prune is needed because preserved directories are otherwise never cleaned, so a deleted target’s output would linger and be compiled again if the name were reused.
  4. A graph mapper that invokes generators directly rather than returning side-effect descriptors, which load-only callers discard.
  5. Validation. Errors: duplicate generator names on one target, an unknown .output(target:), two producers claiming one consumer, codegen on a remote target, an input and output that overlap (below), and a non-zero exit. Warning: an empty output directory, which usually means a mis-scoped input but can legitimately happen.

Then the multi-target primitive: Target.codegen(...) and .output(project:target:), the mapper that injects the consumer edge, requiring all output directories to exist before skipping, and making isAggregate cover codegen targets in the XcodeGraph package. It is currently foreignBuild != nil, so a codegen aggregate would take the native-target branch and force-unwrap a product reference that does not exist.

Cycle detection needs no new machinery: CircularDependencyLinter.lintWorkspace(workspace:projects:) is a self-contained pass over the models, so it can run again after the edge-injecting mapper rather than only before it.

Future Directions

Chaining generators. One generator consuming another’s output is not supported initially. Both primitives run in a stage with no declared ordering between targets, so which generator runs first is unspecified; that makes chaining a race, and one whose hash looks healthy, since the consumer’s declared input would be the producer’s output directory and a cold run would hash an empty directory. Adding it means deriving an ordering from the outputs: and inputs: already declared, then running generators in dependency order rather than concurrently.

Sandboxing. A generator can currently do anything, as foreignBuild and TargetScript can today. An opt-in sandbox restricting writes to the output directory and reads to declared inputs would turn “the declared inputs are the real inputs” from a convention into an invariant, which is the main correctness gap in this proposal. SE-0303 sandboxes plugins by default and is a reasonable model.

Open Questions

Should a codegen .glob input matching nothing be an error? Globs are expanded to .file entries at manifest-map time and never survive as patterns; zero matches currently warns. That is right for foreignBuild, where the input list is an incrementality hint and an empty one degrades to “always re-run”. For codegen the input list is the cache key, so a mistyped pattern hashes nothing and nothing invalidates again. Erroring is cheap. Keeping the pattern symbolic and re-expanding at hash time would also fix the same behavior in buildable-folder exclusions, at the cost of changing a published package.

Should the built-in resource synthesizers eventually move onto this primitive? They solve the same problem for a fixed set of parsers, and this generalizes it. Reimplementing them on top would remove a parallel mechanism, at the cost of making a zero-configuration feature depend on installed tools; deprecating them outright is the more aggressive version. Neither is proposed here, but the answer shapes how much the API should accommodate them.

How should the XcodeGraph change be sequenced? isAggregate lives in a separate published package, so the multi-target primitive cannot land in the CLI alone. Is a coordinated release acceptable, or is there a CLI-side way to express a product-less codegen target?

References