fix(espidf): unknown #if must not prune its #else in the include scanner

The dead-branch pruner marked any assumed-live branch as 'taken', so the
#else of an UNKNOWN condition was skipped. GxEPD2 guards its GFX base
class with '#if ENABLE_GxEPD2_GFX (default 0) ... #else #include
<Adafruit_GFX.h>' — the pruned #else silently dropped the Adafruit_GFX
dependency and every GxEPD2 e-paper example failed with 'fatal error:
Adafruit_GFX.h: No such file'. Found by the nightly gallery compile smoke.

'taken' is now set only by PROVABLY-true conditions (all identifiers in
the known-true set), keeping the 2026-08-03 ESPAsyncWebServer behaviour:
provably-dead branches still prune, provably-true branches still kill
their #else.
This commit is contained in:
David Montero 2026-08-15 04:13:59 +02:00
parent 8391cc7fa8
commit 0d2e73ee00
2 changed files with 64 additions and 3 deletions

View File

@ -1623,6 +1623,24 @@ class ESPIDFCompiler:
# Dead only when we recognise EVERY identifier as false-on-ESP32.
return not all(i in self._PP_FALSE for i in idents)
def _pp_branch_is_provably_true(self, expr: str) -> bool:
"""True only when EVERY identifier is a macro known-true on ESP32 —
the only case where the #elif/#else siblings are provably dead.
This must stay distinct from _pp_branch_is_live: an UNKNOWN condition
is treated as live for scanning, but it must NOT mark the branch
chain as taken. GxEPD2 guards its GFX base class as
`#if ENABLE_GxEPD2_GFX ... #elif ... #else #include <Adafruit_GFX.h>`
with ENABLE_GxEPD2_GFX defaulting to 0 — treating the unknown #if as
taken pruned the #else, silently dropped the Adafruit_GFX dependency
and broke every GxEPD2 e-paper example (found by the gallery compile
smoke, 2026-08-15)."""
idents = [
i for i in self._PP_IDENT_RE.findall(expr)
if i not in ('defined', 'ifdef', 'ifndef')
]
return bool(idents) and all(i in self._PP_TRUE for i in idents)
def _detect_external_includes(
self, code: str, own_files: set[str] | None = None
) -> list[str]:
@ -1663,21 +1681,33 @@ class ESPIDFCompiler:
if line.startswith('#'):
dtv = line[1:].lstrip()
if dtv.startswith(('ifdef', 'ifndef', 'if ', 'if(')):
# `taken` gates whether LATER #elif/#else arms are dead.
# It must be set only by PROVABLY-true conditions: an
# unknown #if is scanned as live, but its #else must stay
# live too — the real compiler may well take it (the
# GxEPD2 ENABLE_GxEPD2_GFX default-0 pattern).
if dtv.startswith('ifdef'):
live = self._pp_branch_is_live(dtv[5:])
taken = self._pp_branch_is_provably_true(dtv[5:])
elif dtv.startswith('ifndef'):
# #ifndef X is live unless X is known-defined here.
# #ifndef X is live unless X is known-defined here,
# and provably TRUE when X is known-undefined.
idents = self._PP_IDENT_RE.findall(dtv[6:])
live = not any(i in self._PP_TRUE for i in idents)
taken = bool(idents) and all(i in self._PP_FALSE for i in idents)
else:
live = self._pp_branch_is_live(dtv[2:])
stack.append((live, live))
taken = self._pp_branch_is_provably_true(dtv[2:])
stack.append((live, taken))
continue
if dtv.startswith('elif'):
if stack:
_cur, taken = stack[-1]
live = (not taken) and self._pp_branch_is_live(dtv[4:])
stack[-1] = (live, taken or live)
stack[-1] = (
live,
taken or self._pp_branch_is_provably_true(dtv[4:]),
)
continue
if dtv.startswith('else'):
if stack:

View File

@ -77,6 +77,37 @@ class TestPreprocessorAwareIncludeScan(unittest.TestCase):
'#else\n#include <C.h>\n#endif'),
['B.h'])
def test_else_of_an_unknown_branch_is_live(self):
"""The GxEPD2 shape (found by the gallery compile smoke, 2026-08-15):
#if ENABLE_GxEPD2_GFX // defaults to 0 — unknown to us
#include "GxEPD2_GFX.h"
#elif defined(_GFX_H_)
#define GxEPD2_GFX_BASE_CLASS GFX
#else
#include <Adafruit_GFX.h> // what the compiler really takes
#endif
An UNKNOWN #if must not mark the chain as taken: pruning this #else
dropped Adafruit_GFX from user_libs_all and broke every GxEPD2
e-paper example with 'fatal error: Adafruit_GFX.h: No such file'."""
got = self.scan(
'#if ENABLE_GxEPD2_GFX\n#include "GxEPD2_GFX.h"\n'
'#elif defined(_GFX_H_)\n#define GxEPD2_GFX_BASE_CLASS GFX\n'
'#else\n#include <Adafruit_GFX.h>\n#endif')
self.assertIn('Adafruit_GFX.h', got)
# The unknown arm itself also stays live — over-reporting is the
# accepted cost, hiding a real dependency is not.
self.assertIn('GxEPD2_GFX.h', got)
def test_else_of_a_provably_true_branch_stays_dead(self):
"""The counterweight: when the #if IS provably true on ESP32, its
#else really is dead and must stay pruned."""
self.assertEqual(
self.scan('#if defined(ESP32)\n#include <A.h>\n'
'#else\n#include <B.h>\n#endif'),
['A.h'])
def test_nested_dead_block(self):
self.assertEqual(
self.scan('#ifdef ESP8266\n#ifdef FOO\n#include <Deep.h>\n'