dead-code-pruner: Automated Dead Code Cleanup After Boolean Constant Folding
Large projects inevitably accumulate boolean flags that are “always true” or “always false” — BuildConfig.IS_PRODUCTION, FeatureFlags.isLegacyMode(), various runtime switches. Once a flag’s value is fixed, the code it guards becomes dead code.
A few if(false) blocks don’t affect compilation, but hundreds scattered across dozens of modules are a different story: IDE indexing slows down, code search becomes noisy, newcomers are misled by defunct logic, and APK size bloats unnecessarily.
Manually fixing hundreds of occurrences isn’t practical, and IDE Inspections only handle the simplest if(true) cases. I built a Python tool for this — dead-code-pruner.
What It Does
Give it a config file specifying which expressions are always true or false:
replacements: - pattern: "BuildConfig.IS_PRODUCTION" value: true - pattern: "FeatureFlags.isLegacyMode" value: falseThe tool runs a 6-step pipeline. Each step’s output may create new simplification opportunities for the next, so the entire pipeline loops until convergence — stopping when a round produces no file changes.
Why Iterative Convergence Is Needed
Consider this code:
if (BuildConfig.IS_PRODUCTION) { showProduction();} else { if (isLegacy()) { handleLegacy(); }}Phase 1’s step1 replaces BuildConfig.IS_PRODUCTION with true, and step4 expands the if(true) block and removes the else block. Round complete.
But after handleLegacy() is removed, the isLegacy() method may become caller-free. Phase 3’s step6 scans and finds it’s a dead method, removing its definition. And isLegacy() might internally call other methods… cascading effects.
Running the pipeline only once would miss these cascade-generated dead code paths.
The 6 Steps Explained
step1: Constant Substitution
Reads the config file and replaces matching expressions with boolean literals. All substitution operations share a tokenizer that splits source code into “code segments” and “non-code segments” (comments, string literals), only modifying code segments. Without this protection, comments like // BuildConfig.IS_PRODUCTION would also be modified.
step2: Simple Boolean Simplification
Handles unary and binary boolean operations. The tool registers a complete pattern table internally:
| Pattern | Result |
|---|---|
!true | false |
!false | true |
true == true | true |
false == false | true |
true == false | false |
false == true | false |
true != false | true |
false != true | true |
true != true | false |
false != false | false |
It also handles redundant parentheses: (true) → true, (false) → false, but only in safe contexts — it won’t strip the parentheses in if(true) (syntax requirement) or in function calls like foo(true). The tool checks the token preceding the parenthesis: if/while/for → don’t strip; return/throw/operator/line start → safe to strip.
step3: Compound Boolean Simplification
Handles short-circuit operations and ternary expressions with correct operator precedence. Covered patterns:
| Pattern | Result | Notes |
|---|---|---|
false && EXPR | false | Short-circuit, EXPR entirely removed |
EXPR && false | false | Reverse short-circuit |
true && EXPR | EXPR | Identity elimination |
EXPR && true | EXPR | Same, including preceding comment lines |
true || EXPR | true | Short-circuit |
EXPR || true | true | Reverse short-circuit |
false || EXPR | EXPR | Identity elimination |
EXPR || false | EXPR | Same |
true ? X : Y | X | Ternary elimination |
false ? X : Y | Y | Ternary elimination |
false + "" | "false" | String concatenation folding |
true + "" | "true" | Same |
There’s an easy-to-miss precedence issue: true || A && B should simplify to true, not true || A leaving B behind. Because && has higher precedence than ||, A && B is a single operand of || and should be eliminated entirely. The tool treats && expressions as atomic units when processing ||.
Nested ternary expressions like condition ? (innerCond ? A : B) : C require tracking parenthesis depth and ternary nesting depth to find the correct : position — you can’t simply find the first colon.
Each pattern match also avoids ==/!= contexts — the true in x == true is a comparison operand, not a short-circuit left-hand side.
step4: If-Block Elimination
Covered scenarios:
| Input | Output |
|---|---|
if (true) { A } | A (block contents expanded) |
if (true) { A } else { B } | A |
if (true) { A } else if (X) { B } else { C } | A (entire else-if chain removed) |
if (false) { A } | Removed |
if (false) { A } else { B } | B |
if (false) { A } else if (X) { B } else { C } | if (X) { B } else { C } |
if (false) return X; | Removed (single-line, no braces) |
Dead code deletion must not cross switch/case labels. Java’s cases lack explicit closing, and deleting too much would swallow adjacent case code.
step5: Constant-Return Method Inlining
Scans all method definitions for methods that only contain return true; or return false;, replacing call sites with the constant value:
// Before inliningprivate boolean isLocal() { return false; }if (isLocal()) { ... }
// After inliningif (false) { ... } // Cleaned up by step4 in the next roundThere’s a subtle bug: when shouldBlock(); is a standalone statement, inlining turns it into false; — not a valid Java statement. The solution is to scan the file after inlining and remove all standalone true;/false; lines.
step6: Dead Method Cleanup
Finds empty void methods and constant-return boolean methods, removing both definitions and call sites.
This step requires building an in-memory reference index. A project might have tens of thousands of source files; running a global grep for each method’s references would take prohibitively long. The approach is to traverse all files once to build a method name → reference location inverted index, with subsequent lookups in memory. Complexity drops from O(N×M) to O(N+M). In practice, this turned “can’t finish” into 40-second completion.
Safety boundaries:
- Exact parameter count matching —
render()andrender(dialog)are different methods - Non-private methods: inline calls only, don’t delete definitions — protected/package-private methods may be
@Overriden by subclasses; static analysis can’t determine safety - Skip interface methods, abstract methods,
@Overridemethods — builds class hierarchy to exclude framework-constrained methods - Chained call protection —
method().subscribe()chains won’t break from removing void calls
Usage
git clone https://github.com/OldJii/dead-code-pruner.git
# Place a config in the project rootcp dead-code-pruner/pruner.example.yaml pruner.yaml# Edit pruner.yaml
# Previewpython3 dead-code-pruner/prune.py . --dry-run
# Executepython3 dead-code-pruner/prune.py .
# Verify compilation./gradlew compileDebugJavaWithJavacYou can run a single phase (--phase 1/2/3) or execute individual steps (python3 step3_compound_boolean.py .). Zero external dependencies, Python 3.8+. YAML config requires PyYAML; use JSON format if you don’t want to install it.
In Practice
I used this tool for a complete cleanup on a medium-to-large Android project with hundreds of runtime boolean checks. The config file needed just one line: pattern: "BuildConfig.IS_PRODUCTION", value: true. After running the three-phase pipeline — boolean simplification → method inlining → dead method cleanup — everything was handled automatically.
Afterward, resource cleanup was done with Android’s shrinkResources and Android Studio’s Remove Unused Resources. Watch out for getIdentifier() dynamic resource references — shrinkResources can’t detect these reference relationships, so matching patterns need to be added to keep.xml as a whitelist.
The final APK went from 125 MB to 113 MB — roughly 12 MB reduction (nearly 10%). DEX shrank by 6 MB (source-level cleanup + R8 optimization positive feedback loop), resources by 5 MB.
Ongoing Maintenance
Cleanup isn’t a one-time event. Every main branch merge can introduce new conditional code. The tool is designed for repeated execution — run python3 prune.py . after merges to automatically clean incremental changes, no business logic understanding needed.
Limitations
- Targets Java/Kotlin source files, based on pattern matching rather than semantic analysis
- Cannot handle intermediate variable assignments (
boolean x = BuildConfig.FOO; if (x) ...) - step6 dead method detection is deliberately conservative: only removes truly empty-body or single-constant-return methods
Conservative is intentional — leaving empty shell methods causes no bugs; deleting the wrong ones will.
Design Choices
Why not use AI / IDE? I evaluated both Cursor file-by-file editing and AS Inspections. AI’s context window can’t cover the complete reference chain of tens of thousands of files, and results vary each time. IDE only handles the simplest if(true) — nested ternaries, short-circuit operations, and cascading dead code are all ignored. Script behavior is deterministic — same input, same output; fix the script if it’s wrong and re-run.
Why 6 separate files? Each step can be run and debugged independently. When troubleshooting, you can run just step3 to see compound boolean simplification results without running the full pipeline.
Why subprocess instead of import? Isolation. Each step has its own file scanning logic and state; process isolation prevents shared-state bugs. The full pipeline runs in ~40 seconds, making process startup overhead negligible.