fix(espidf): a header-only library broke every ESP32 build

Installing ArduinoJson made the project stop compiling, with an error that
never mentioned ArduinoJson:

    CMake Error at user_libs/user_libs_all/CMakeLists.txt:15
      (target_compile_definitions):
      target_compile_definitions may only set INTERFACE properties on
      INTERFACE targets
    -- Configuring incomplete, errors occurred!

ArduinoJson ships headers only. With no sources, idf_component_register()
builds the merged user-libs component as an INTERFACE library, and PRIVATE
scope on an INTERFACE target is a hard CMake error - so the configure step
died before a single file was compiled. The user saw only a failing build
and reported it as "ArduinoJson can't be found even after it has been
installed" (#124).

Those definitions map alloca/memcpy_P/memcmp_P for library C sources that
use them undeclared; with no sources there is nothing to define, so they are
emitted only when the component actually compiles something. A library that
carries its own .cpp is unchanged - which is why LiquidCrystal_I2C (#257)
worked and hid this.
This commit is contained in:
David Montero Crespo 2026-08-03 07:26:01 +02:00
parent 6877099e78
commit 0d9a26d951
2 changed files with 108 additions and 9 deletions

View File

@ -1491,6 +1491,32 @@ class ESPIDFCompiler:
extra_requires += f' {_comp}'
break
# LovyanGFX (the engine inside M5GFX) uses alloca()/memcpy_P/memcmp_P
# without a prior declaration — its C utility files (lgfx_pngle.c, qoi …)
# call the PROGMEM aliases but never include pgmspace.h, so the flat
# IDF-component merge trips over an implicit declaration. Map them to
# always-available equivalents (identical to pgmspace.h, so redefinition
# is benign) so C++ graphics libs compile as a merged component.
#
# ONLY when this component actually compiles something. With no SRCS,
# idf_component_register() builds an INTERFACE library, and PRIVATE (or
# PUBLIC) on an INTERFACE target is a hard CMake error:
# target_compile_definitions may only set INTERFACE properties on
# INTERFACE targets
# which killed the whole configure step before a single file was
# compiled. That is the state a HEADER-ONLY library leaves us in —
# ArduinoJson is the common one — and it is why installing it made every
# ESP32 build fail while a library with its own .cpp (LiquidCrystal_I2C)
# was fine. The definitions exist for this component's own C sources, so
# with no sources there is nothing to define.
defs_block = ''
if cpp_files:
defs_block = (
'# PROGMEM aliases for library C sources that use them undeclared.\n'
'target_compile_definitions(${COMPONENT_LIB} PRIVATE'
' alloca=__builtin_alloca memcpy_P=memcpy memcmp_P=memcmp)\n'
)
cmake_content = (
'# Auto-generated by Velxio — all user libraries merged into one component.\n'
'# Directory structure preserved for libraries like ArduinoJson with src/ layout.\n'
@ -1499,15 +1525,7 @@ class ESPIDFCompiler:
f' {include_dirs_line}\n'
f' REQUIRES {arduino_comp_name}{extra_requires}\n'
')\n'
'# LovyanGFX (the engine inside M5GFX) uses alloca()/memcpy_P/\n'
'# memcmp_P without a prior declaration — its C utility files\n'
'# (lgfx_pngle.c, qoi …) call the PROGMEM aliases but never include\n'
'# pgmspace.h, so the flat IDF-component merge trips over an implicit\n'
'# declaration. Map them to always-available equivalents (identical\n'
'# to pgmspace.h, so redefinition is benign) so C++ graphics libs\n'
'# compile as a merged component.\n'
'target_compile_definitions(${COMPONENT_LIB} PRIVATE'
' alloca=__builtin_alloca memcpy_P=memcpy memcmp_P=memcmp)\n'
f'{defs_block}'
)
(comp_dir / 'CMakeLists.txt').write_text(cmake_content, encoding='utf-8')
logger.info(

View File

@ -500,3 +500,84 @@ class TestDetectExternalIncludesQuotedForm(unittest.TestCase):
with self.subTest(code=code):
self.assertIn('M5Cardputer.h',
self.comp._detect_external_includes(code))
# ── Test: header-only libraries (ArduinoJson) ────────────────────────────────
class TestHeaderOnlyLibrary(unittest.TestCase):
"""
A library with no .c/.cpp of its own must still produce a CMakeLists.txt
ESP-IDF can configure.
With no SRCS, idf_component_register() builds the component as an INTERFACE
library, and target_compile_definitions(... PRIVATE ...) on an INTERFACE
target is a hard CMake error:
target_compile_definitions may only set INTERFACE properties on
INTERFACE targets
That killed the configure step outright, so installing ArduinoJson broke
every ESP32 build in the project — reported as "ArduinoJson can't be found
even after it has been installed" (issue #124), because the user only ever
saw the build fail. A library carrying its own .cpp was unaffected, which is
why LiquidCrystal_I2C (#257) looked fine.
"""
def setUp(self):
self.comp = make_compiler()
self.tmp = tempfile.mkdtemp()
self.arduino_libs = Path(self.tmp) / 'arduino_libs'
self.esp32_libs = Path(self.tmp) / 'esp32_libs'
self.user_libs = Path(self.tmp) / 'user_libs'
for d in (self.arduino_libs, self.esp32_libs, self.user_libs):
d.mkdir()
# ArduinoJson ships headers only, under src/.
make_library(self.arduino_libs, 'ArduinoJson',
headers=['ArduinoJson.h'], sources=[], use_src_subdir=True)
# A conventional library, for the contrast case.
make_library(self.arduino_libs, 'LiquidCrystal_I2C',
headers=['LiquidCrystal_I2C.h'],
sources=['LiquidCrystal_I2C.cpp'])
def tearDown(self):
shutil.rmtree(self.tmp)
def _cmake_for(self, headers: list[str]) -> str:
names, _ = self.comp._resolve_library_components(
headers, self.arduino_libs, self.esp32_libs, 'arduino-esp32', self.user_libs
)
self.assertEqual(names, ['user_libs_all'])
return (self.user_libs / 'user_libs_all' / 'CMakeLists.txt').read_text(encoding='utf-8')
def test_header_only_library_emits_no_private_scope(self):
cmake = self._cmake_for(['ArduinoJson.h'])
self.assertNotIn('SRCS', cmake, 'header-only component must register with no SRCS')
self.assertNotIn(
'PRIVATE', cmake,
'PRIVATE on a source-less (INTERFACE) component is a CMake configure error',
)
def test_header_only_library_still_exposes_its_header(self):
cmake = self._cmake_for(['ArduinoJson.h'])
self.assertIn('idf_component_register', cmake)
# The library's src/ layout is preserved, so the header lands at
# src/ArduinoJson.h and "src" has to be on the include path for the
# sketch's #include <ArduinoJson.h> to resolve.
merged = self.user_libs / 'user_libs_all'
self.assertTrue(
(merged / 'src' / 'ArduinoJson.h').exists(),
'ArduinoJson.h not copied — the sketch would fail on "No such file"',
)
self.assertIn('INCLUDE_DIRS "." "src"', cmake)
def test_library_with_sources_keeps_the_progmem_definitions(self):
cmake = self._cmake_for(['LiquidCrystal_I2C.h'])
self.assertIn('SRCS', cmake)
self.assertIn('target_compile_definitions(${COMPONENT_LIB} PRIVATE', cmake)
self.assertIn('memcpy_P=memcpy', cmake)
def test_mixed_libraries_keep_the_definitions(self):
"""One library with sources is enough to make the component compile code."""
cmake = self._cmake_for(['ArduinoJson.h', 'LiquidCrystal_I2C.h'])
self.assertIn('SRCS', cmake)
self.assertIn('PRIVATE', cmake)