A Bazel port of ExampleTuistIntegration, demonstrating SafeDI's
cross-module .safedi artifact handoff in a Bazel-native way. The
Swift source code (types, mocks, previews) is byte-for-byte identical
to the Tuist example — the differences are entirely in the build
system.
Supported host: macOS arm64 only (matches the
rules_apple macOS
toolchain). The SafeDI rules themselves are platform-agnostic.
- Two
swift_librarytargets (//Subproject:Subproject,//ExampleBazelIntegration:ExampleBazelIntegration) that compile separately. safedi_compilerule —load("@safedi//bazel:safedi.bzl", "safedi_compile"). One rule, one SafeDITool invocation per module, two outputs:<rule>.safedi— module-info artifact for cross-module consumers (carried via the rule'sSafeDIInfoprovider).<rule>.swift— combined dependency-tree + mocks + mock configuration source (the rule'sDefaultInfofiles; just include the label in aswift_library.srcs). SafeDITool's per-root / per-mock file split is concatenated via--combined-outputinto one statically-declared output —outsdoesn't need a hand-maintained list regardless of how many@Instantiable(isRoot:)/generateMock: truetypes the module declares.
- Cross-module type resolution via
.safedi. The host target reaches the subproject viadeps = ["//Subproject:Subproject_safedi"]and never re-parses its sources. - SafeDI consumed via
bazel_dep. MODULE.bazel declaresbazel_dep(name = "safedi", ...). In this repo the dep is resolved vialocal_path_overrideto the workspace root so the example tracks the working copy; downstream consumers would get SafeDI from the Bazel Central Registry instead. SafeDIToolbuilt from source by Bazel. No prebuilt artifact fetching — the tool is just anotherswift_binarytarget (@safedi//Sources/SafeDITool:SafeDITool). Cached across runs.
cd Examples/ExampleBazelIntegration
bazelisk build //...First build compiles swift-syntax + SafeDIMacros under the hood (~few minutes). Subsequent builds are incremental.
In a real downstream consumer, you'd edit bazel_dep(name = "safedi", version = "…")
in your MODULE.bazel. In this example, the version is effectively
whatever's checked out under ../.. (via local_path_override), so
no bump needed — just git pull the outer repo.
| Tuist | Bazel | |
|---|---|---|
| Codegen entry point | SafeDI.preCompileScript(module:dependencies:) Tuist plugin helper |
safedi_compile(srcs, deps) rule |
| Cross-module handoff | Subproject.safedi in $(BUILT_PRODUCTS_DIR) consumed via script-phase input |
safedi_compile's SafeDIInfo provider; downstream rules list the producer label in deps |
| Input enumeration | FileListGlob in Project.swift |
glob() in BUILD.bazel |
| Output enumeration | SafeDITool scan at tuist generate time → .generated(…) per-file entries |
Single concatenated output file per safedi_compile target — no per-file enumeration needed |
| SafeDITool acquisition | tuist install pulls via SafeDI's prebuilt trait |
Built from source by Bazel; cached across runs |
| Generated code location | $(DERIVED_FILE_DIR) (Xcode build sandbox) |
bazel-bin/… (Bazel action sandbox) |
Both end up at the same shape: generated Swift is a build artifact,
never in the source tree; cross-module type info flows through a
.safedi artifact.
Bazel rule outputs must be known at analysis time (Starlark can't run
subprocesses or read file contents then). SafeDI's output set
depends on what's inside each Swift file — how many
@Instantiable(isRoot:) / generateMock: true declarations it
finds — which analysis can't see. Hand-maintaining the output list
in BUILD.bazel would force every author to update their build file
on every annotation change, so safedi_compile uses SafeDITool's
--combined-output mode to emit a single declared output regardless
of source contents. The concatenated file is still valid Swift
(SafeDI's generated files are all top-level declarations) and
downstream swift_library.srcs treats it like any other source.