[core] Make set_cpp_standard work on the native IDF toolchain (#16907)

This commit is contained in:
Jonathan Swoboda
2026-06-10 15:25:03 -04:00
committed by GitHub
parent dafc3560dd
commit 29a79b1373
6 changed files with 129 additions and 12 deletions

View File

@@ -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}

View File

@@ -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))

View File

@@ -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()

View File

@@ -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):

View File

@@ -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

View File

@@ -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