Conditional Compilation On Project Build Flags
ID |
swift.conditional_compilation |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
low |
Remediation Effort |
medium |
Resource |
Code Smell |
Language |
Swift |
Tags |
code-style, maintainability |
Description
Reports an #if / #elseif directive whose condition tests a project-specific build
flag. Platform and toolchain conditions — os(…), arch(…), swift(…),
compiler(…), canImport(…), targetEnvironment(…) — are never reported, and the
conventional configuration flags are allow-listed.
Rationale
#if MY_FEATURE_FLAG
func experimental() {
resetEverything()
}
#endif
A project-specific build flag splits the source into variants that are never all compiled together. Whichever branch the current build settings do not select is not type-checked, not covered by tests, and not seen by the reader who only knows the default configuration: it can reference a symbol that was renamed months ago and nothing will say so until somebody flips the flag. The flag itself lives in the build system, far from the code it silently changes, so what a file does can no longer be read from the file.
The cost grows multiplicatively. Two independent flags describe four builds, only one of which anybody runs; a bug reproducible in one of the other three has no test that could have caught it.
This is not an argument against #if as such. Conditional compilation is the correct —
and often the only — way to write code that spans platforms, SDK versions or Swift
language versions. The distinction the rule draws is between a condition that describes
where the code is being compiled, which the compiler and the reader can both evaluate,
and a flag the project invented, which neither can.
Notes
The trigger is narrowed twice so that idiomatic code never fires:
-
Platform and toolchain conditions are never reported, structurally rather than by configuration:
os(…),arch(…),swift(…),compiler(…),canImport(…)andtargetEnvironment(…)are recognised as platform conditions, and identifiers inside one — the module name of acanImport(…), for instance — are skipped. No property can switch this exemption off by accident. -
Conventional flags are allow-listed through
allowedFlags.DEBUGandRELEASEare idiomatic Swift for build-configuration differences;SWIFT_PACKAGEis defined by the package manager itself rather than by the project.
A block is reported once, at the first clause carrying such a flag. #elseif clauses are
checked as well as the leading #if, and the #if form that guards switch cases is
covered too.
Configuration
-
allowedFlags(default[DEBUG, RELEASE, SWIFT_PACKAGE]) — build-flag names that are never reported, matched exactly (Swift build flags are case-sensitive). Add the flags your project has agreed to keep; anything not listed is reported.
Remediation
Prefer a runtime decision the compiler can always check, and the tests can exercise in both states:
enum FeatureFlags {
static var experimentalEnabled: Bool {
return ProcessInfo.processInfo.environment["EXPERIMENTAL"] != nil
}
}
func experimental() {
guard FeatureFlags.experimentalEnabled else { return }
resetEverything()
}
Both branches now compile in every build, so a rename cannot rot the disabled path, and a test can run the code with the flag on and off.
When the difference genuinely belongs at build time — a platform-specific API, an SDK that only exists on one OS — say so with a platform condition, which this rule never reports:
#if canImport(UIKit)
import UIKit
#endif
When the flag must stay, keep it in the allow-list rather than leaving it unreviewed, so the decision is recorded once instead of being re-litigated at each finding.