diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py index 9cc7a7ff12..9e11d785c0 100644 --- a/esphome/build_gen/espidf.py +++ b/esphome/build_gen/espidf.py @@ -8,6 +8,17 @@ import esphome.config_validation as cv from esphome.core import CORE from esphome.helpers import mkdir_p, write_file_if_changed +# Replaces the IDF default C++ standard (-std=gnu++2b appended to +# CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via +# cg.set_cpp_standard(). Emitted between include(project.cmake) and project(), +# i.e. after IDF appends its default and before the options are consumed, and +# applies project-wide like PlatformIO build_unflags. +CPP_STANDARD_TEMPLATE = """\ +idf_build_get_property(esphome_cxx_compile_options CXX_COMPILE_OPTIONS) +list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=") +list(APPEND esphome_cxx_compile_options "-std={standard}") +idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")""" + def get_available_components() -> list[str] | None: """Get list of built-in ESP-IDF components from project_description.json. @@ -84,6 +95,12 @@ def get_project_cmakelists(minimal: bool = False) -> str: for flag in project_compile_opts ) + cpp_standard_options = ( + CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard) + if CORE.cpp_standard + else "" + ) + # Per-project list exposed as a CMake variable so converted PIO libs # can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking # project-specific names into their cached CMakeLists. @@ -140,6 +157,8 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src) include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) +{cpp_standard_options} + {extra_compile_options} {managed_components_property} @@ -200,9 +219,6 @@ idf_component_register( REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}} ) -# Apply C++ standard -target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20) - # ESPHome linker options target_link_options(${{COMPONENT_LIB}} PUBLIC {link_opts_str} diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py index 16c1597ccd..a583279ea7 100644 --- a/esphome/build_gen/platformio.py +++ b/esphome/build_gen/platformio.py @@ -33,12 +33,27 @@ def format_ini(data: dict[str, str | list[str]]) -> str: return content +# All -std= variants a platform/framework may set by default, in both the GNU +# and strict dialects; unflagged so the cg.set_cpp_standard() value is the +# only standard left in the build. +CPP_STD_VARIANTS = [ + f"{prefix}{year}" + for year in ("11", "14", "17", "20", "23", "26", "2a", "2b", "2c") + for prefix in ("gnu++", "c++") +] + + def get_ini_content(): CORE.add_platformio_option( "lib_deps", [x.as_lib_dep for x in CORE.platformio_libraries.values()] + ["${common.lib_deps}"], ) + if CORE.cpp_standard: + for variant in CPP_STD_VARIANTS: + if variant != CORE.cpp_standard: + CORE.add_build_unflag(f"-std={variant}") + CORE.add_build_flag(f"-std={CORE.cpp_standard}") # Sort to avoid changing build flags order CORE.add_platformio_option("build_flags", sorted(CORE.build_flags)) diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 90c162fedd..4289cdf3e5 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -593,6 +593,8 @@ class EsphomeCore: self.build_flags: set[str] = set() # A set of build unflags to set in the platformio project self.build_unflags: set[str] = set() + # The C++ language standard for the build (e.g. "gnu++20"), set via cg.set_cpp_standard() + self.cpp_standard: str | None = None # A set of defines to set for the compile process in esphome/core/defines.h self.defines: set[Define] = set() # A map of all platformio options to apply @@ -649,6 +651,7 @@ class EsphomeCore: self.platformio_libraries = {} self.build_flags = set() self.build_unflags = set() + self.cpp_standard = None self.defines = set() self.platformio_options = {} self.loaded_integrations = set() diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 151018baa4..582b8fc74d 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -705,15 +705,8 @@ def add_build_unflag(build_unflag: str) -> None: def set_cpp_standard(standard: str) -> None: - """Set C++ standard with compiler flag `-std={standard}`.""" - CORE.add_build_unflag("-std=gnu++11") - CORE.add_build_unflag("-std=gnu++14") - CORE.add_build_unflag("-std=gnu++17") - CORE.add_build_unflag("-std=gnu++23") - CORE.add_build_unflag("-std=gnu++2a") - CORE.add_build_unflag("-std=gnu++2b") - CORE.add_build_unflag("-std=gnu++2c") - CORE.add_build_flag(f"-std={standard}") + """Set the C++ language standard for the build (e.g. ``gnu++20``).""" + CORE.cpp_standard = standard def add_define(name: str, value: SafeExpType = None): diff --git a/tests/unit_tests/build_gen/test_espidf.py b/tests/unit_tests/build_gen/test_espidf.py index 540dd06731..a5c2719f42 100644 --- a/tests/unit_tests/build_gen/test_espidf.py +++ b/tests/unit_tests/build_gen/test_espidf.py @@ -162,3 +162,53 @@ def test_get_project_cmakelists_emits_managed_components_property( "idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS" " espressif__esp-dsp APPEND)" ) in content + + +def test_get_project_cmakelists_replaces_cpp_standard(tmp_path: Path) -> None: + """cg.set_cpp_standard() replaces the IDF default -std in + CXX_COMPILE_OPTIONS between include(project.cmake) and project().""" + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + patch.object(CORE, "cpp_standard", "gnu++20"), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=True) + + assert ( + "idf_build_get_property(esphome_cxx_compile_options CXX_COMPILE_OPTIONS)" + in content + ) + assert 'list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=")' in content + assert 'list(APPEND esphome_cxx_compile_options "-std=gnu++20")' in content + # The replacement must come after project.cmake (which appends the IDF + # default) and before project() (which consumes the options). + include_pos = content.index("tools/cmake/project.cmake") + replace_pos = content.index("CXX_COMPILE_OPTIONS") + project_pos = content.index("project(test)") + assert include_pos < replace_pos < project_pos + + +def test_get_project_cmakelists_no_cpp_standard(tmp_path: Path) -> None: + with ( + patch("esphome.build_gen.espidf.get_esp32_variant", return_value="ESP32"), + patch.object(CORE, "name", "test"), + patch.object(CORE, "cpp_standard", None), + ): + from esphome.build_gen.espidf import get_project_cmakelists + + content = get_project_cmakelists(minimal=True) + + assert "CXX_COMPILE_OPTIONS" not in content + + +def test_get_component_cmakelists_no_compile_features() -> None: + """The C++ standard is pinned project-wide via CXX_COMPILE_OPTIONS in the + top-level CMakeLists; the src component must not set its own.""" + with patch.object(CORE, "build_flags", set()): + from esphome.build_gen.espidf import get_component_cmakelists + + content = get_component_cmakelists() + + assert "target_compile_features" not in content diff --git a/tests/unit_tests/build_gen/test_platformio.py b/tests/unit_tests/build_gen/test_platformio.py index da0010afa3..2ae3836a25 100644 --- a/tests/unit_tests/build_gen/test_platformio.py +++ b/tests/unit_tests/build_gen/test_platformio.py @@ -160,3 +160,43 @@ def test_write_ini_no_change_when_content_same( call_args = mock_write_file_if_changed.call_args[0] assert call_args[0] == ini_file assert content in call_args[1] + + +@pytest.fixture +def clean_core(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(CORE, "name", "test") + monkeypatch.setattr(CORE, "platformio_options", {}) + monkeypatch.setattr(CORE, "platformio_libraries", {}) + monkeypatch.setattr(CORE, "build_flags", set()) + monkeypatch.setattr(CORE, "build_unflags", set()) + + +def test_get_ini_content_pins_cpp_standard( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """cg.set_cpp_standard() pins -std via build_flags and unflags every other + known standard so the platform/framework default is stripped.""" + monkeypatch.setattr(CORE, "cpp_standard", "gnu++20") + + content = platformio.get_ini_content() + + flags_section = content.split("build_flags =")[1].split("build_unflags =")[0] + unflags_section = content.split("build_unflags =")[1].split("extra_scripts")[0] + assert "-std=gnu++20\n" in flags_section + # Both the GNU and strict dialects of every other standard are stripped. + for year in ("11", "14", "17", "23", "26", "2a", "2b", "2c"): + assert f"-std=gnu++{year}\n" in unflags_section + assert f"-std=c++{year}\n" in unflags_section + assert "-std=c++20\n" in unflags_section + # The selected standard must not unflag itself. + assert "-std=gnu++20\n" not in unflags_section + + +def test_get_ini_content_no_cpp_standard( + clean_core: None, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(CORE, "cpp_standard", None) + + content = platformio.get_ini_content() + + assert "-std=" not in content