[esp32] Run clang-tidy via the native ESP-IDF toolchain (#16748)

This commit is contained in:
Jonathan Swoboda
2026-06-04 11:47:42 -04:00
committed by GitHub
parent c765e22622
commit 148a5ba68e
5 changed files with 491 additions and 32 deletions

View File

@@ -1 +1 @@
44db8a62d94c8fba83b95b73938db4377ebacc0adb504881387389f1cd8f2f3a
0550a8ea4182dbc007660de060dd023ce22c865c8e95040a36f3d07a5b354fc6

View File

@@ -0,0 +1,440 @@
"""Generate clang-tidy idedata via the native ESP-IDF toolchain.
Produces idedata for clang-tidy **without an ESPHome YAML config**. Instead of
running codegen on a config, it generates a minimal ESP-IDF CMake project:
* the managed-component dependencies come from ESPHome's own
``idf_component.yml`` (arduinojson, lvgl, mdns, ...);
* the PlatformIO ``lib_deps`` (qr-code, mlx90393, ...) are converted to local
IDF components via the ESPHome PlatformIO->IDF converter;
* the ``main`` component ``REQUIRES`` every target-available builtin IDF
component, so their public include dirs land on the translation unit;
* the repo ``sdkconfig.defaults`` enables sdkconfig-gated components (bt, ...).
then runs ``idf.py reconfigure`` (configure only, no compile) and reads the
resulting ``build/compile_commands.json``. The IDF version is the esp32
component's recommended version.
``ESPHOME_IDF_COMPILE_COMMANDS`` may point at an existing build's
``compile_commands.json`` to skip generation (fast iteration).
"""
from dataclasses import dataclass
import os
from pathlib import Path
TIDY_PROJECT_NAME = "esphome_tidy"
# A do-nothing C++ app: just enough for IDF to configure a valid project. It's
# C++ (not C) so the compile command uses the C++ compiler and flags, matching
# how clang-tidy analyzes ESPHome's C++ sources.
_TIDY_MAIN_CPP = 'extern "C" void app_main() {}\n'
@dataclass(frozen=True)
class _Settings:
"""Per-environment build settings derived from the tidy env name.
The platform defines below are what a real ESPHome build adds via
cg.add_define; defines.h only *consumes* them, so without them
esphome/core/hal.h errors with "not implemented for this platform".
"""
idf_target: str # esp32, esp32s3, ...
variant: str # ESP32, ESP32S3, ...
idf_version: str # ESP-IDF version to build with
target_framework: str # "espidf" or "arduino"
platform_defines: tuple[str, ...]
# Extra idf_component.yml deps the framework needs (e.g. arduino-esp32).
framework_deps: dict[str, dict]
def _settings_for(environment: str) -> _Settings:
"""Derive build settings from a ``<target>-<framework>-tidy`` env name.
Arduino on esp32 is itself a native ESP-IDF build with the
``espressif/arduino-esp32`` component added, so both frameworks use this
path -- only the defines, IDF version, and that one component differ.
"""
from esphome.components.esp32 import (
ARDUINO_FRAMEWORK_VERSION_LOOKUP,
ARDUINO_IDF_VERSION_LOOKUP,
ESP_IDF_FRAMEWORK_VERSION_LOOKUP,
)
parts = environment.split("-")
if len(parts) != 3 or parts[2] != "tidy" or parts[1] not in ("idf", "arduino"):
raise ValueError(
f"Unsupported clang-tidy environment {environment!r}: expected "
"<target>-<framework>-tidy with framework 'idf' or 'arduino' "
"(e.g. esp32-idf-tidy, esp32s3-arduino-tidy)"
)
idf_target, framework, _ = parts
variant = idf_target.upper()
# Defines shared by both frameworks. ESPHOME_LOG_LEVEL must be set up front
# (as the PlatformIO tidy build_flags do) -- otherwise log.h's ``#ifndef``
# sets it to NONE before defines.h redefines it, a macro-redefined warning
# across nearly every source.
common_defines = (
"USE_ESP32",
f"USE_ESP32_VARIANT_{variant}",
"ESPHOME_LOG_LEVEL=ESPHOME_LOG_LEVEL_VERY_VERBOSE",
)
if framework == "arduino":
fw_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
return _Settings(
idf_target=idf_target,
variant=variant,
idf_version=str(ARDUINO_IDF_VERSION_LOOKUP[fw_version]),
target_framework="arduino",
platform_defines=(
*common_defines,
"USE_ARDUINO",
"USE_ESP32_FRAMEWORK_ARDUINO",
),
framework_deps=_arduino_framework_deps(str(fw_version)),
)
return _Settings(
idf_target=idf_target,
variant=variant,
idf_version=str(ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]),
target_framework="espidf",
platform_defines=(
*common_defines,
"USE_ESP_IDF",
"USE_ESP32_FRAMEWORK_ESP_IDF",
),
framework_deps={},
)
def _arduino_framework_deps(version: str) -> dict[str, dict]:
"""Arduino-only managed deps merged on top of esphome/idf_component.yml.
arduino-esp32 provides Arduino.h and the arduino libraries; its version is
the recommended arduino framework version so the tidy build matches what
ESPHome ships.
"""
from esphome.components.esp32 import ARDUINO_ESP32_COMPONENT_NAME
return {ARDUINO_ESP32_COMPONENT_NAME: {"version": version}}
_TOP_CMAKELISTS = """\
# Auto-generated by ESPHome (clang-tidy idedata project)
cmake_minimum_required(VERSION 3.16)
set(IDF_TARGET {idf_target})
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
{compile_options}
project({name})
"""
_MAIN_CMAKELISTS = """\
# Auto-generated by ESPHome (clang-tidy idedata project)
idf_component_register(
SRCS "tidy.cpp"
REQUIRES {requires}
)
"""
def _setup_core(work_dir: Path, settings: _Settings) -> None:
"""Point CORE at the tidy project + IDF version, without any YAML config."""
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION, KEY_VARIANT
import esphome.config_validation as cv
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
from esphome.core import CORE
CORE.name = TIDY_PROJECT_NAME
# config_path's parent is the data dir root: the IDF install lives at
# ``<parent>/.esphome/idf`` -- keep it beside (not inside) the per-run
# project dir so clearing the project doesn't force an IDF re-download.
CORE.config_path = work_dir.parent / "tidy.yaml"
CORE.build_path = work_dir
esp32 = CORE.data.setdefault(KEY_ESP32, {})
esp32[KEY_IDF_VERSION] = cv.Version.parse(settings.idf_version)
esp32[KEY_VARIANT] = settings.variant
# The target framework drives the PlatformIO-library -> IDF-component
# converter and ESPHome's CORE.using_arduino / using_esp_idf helpers.
CORE.data.setdefault(KEY_CORE, {})[KEY_TARGET_PLATFORM] = "esp32"
CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] = settings.target_framework
# Special IDF "components" that are tools/subprojects, not requirable by an app
# (they provide no public includes and break requirement resolution), plus our
# own ``main``.
_NON_REQUIRABLE_COMPONENTS = frozenset(
{"bootloader", "esptool_py", "partition_table", "main"}
)
def _parse_lib_deps(platformio_ini: Path, framework: str):
"""Parse the framework's ``lib_deps`` from platformio.ini into Library specs.
These are the PlatformIO libraries ESPHome components pull in via
``cg.add_library``. The set is framework-specific: the arduino envs add
libs (FastLED, NeoPixelBus, MideaUART, ...) the idf envs don't. We read the
relevant ``[common*]`` sections directly (resolving the env's ``extends``
chain) and skip the ``${...}`` cross-references and non-library entries.
"""
import configparser
from esphome.core import Library
parser = configparser.ConfigParser(interpolation=None, strict=False)
parser.read(platformio_ini)
sections = [("common", "lib_deps_base"), ("common", "lib_deps")]
if framework == "arduino":
sections += [
("common:arduino", "lib_deps"),
("common:esp32-arduino", "lib_deps"),
]
else:
sections += [
("common:idf", "lib_deps"),
("common:esp32-idf", "lib_deps"),
]
tokens: list[str] = []
for section, key in sections:
if parser.has_option(section, key):
tokens += parser.get(section, key).splitlines()
libs: list[Library] = []
seen: set[str] = set()
for token in tokens:
token = token.split(";", 1)[0].strip() # drop trailing ; comment
# Skip blanks, ${...} cross-refs, and +<...> source filters.
if not token or token.startswith(("${", "+<")) or token in seen:
continue
seen.add(token)
if "://" in token or ".git" in token:
libs.append(Library(token, None, token)) # git repository (with #ref)
elif "@" in token:
name, _, version = token.partition("@")
libs.append(Library(name, version))
# A bare name (SPI, Wire, WiFi, Networking, "ESP32 Async UDP", ...) is an
# Arduino framework built-in provided by arduino-esp32, not a convertible
# registry library (no owner/version), so skip it.
return libs
def _convert_pio_libs(
platformio_ini: Path, framework: str
) -> dict[str, dict[str, str]]:
"""Convert the PlatformIO libs to IDF components; return manifest deps.
Returns a mapping suitable for an ``idf_component.yml`` ``dependencies``
block (``{name: {"override_path": <converted component dir>}}``), reusing
ESPHome's own PlatformIO->IDF converter (registry/git resolution, no pio).
The whole library set is resolved as a single batch so a shared transitive
dependency (e.g. esphome/libsodium pulled by both noise-c and esp_wireguard)
is deduplicated to one component instead of clashing override_path entries.
"""
from esphome.espidf.component import generate_idf_components
libraries = _parse_lib_deps(platformio_ini, framework)
deps: dict[str, dict[str, str]] = {}
for component in generate_idf_components(libraries):
deps[component.get_sanitized_name()] = {"override_path": str(component.path)}
return deps
def _arduino_excluded_stubs(work_dir: Path) -> dict[str, dict]:
"""Stub the arduino-bundled IDF components ESPHome doesn't use.
arduino-esp32 declares deps (libsodium, RainMaker, modbus, ...) that ESPHome
replaces with its own library (noise-c) or doesn't use; point each at an
empty override_path component so the IDF manager doesn't resolve/download
them -- notably so ``espressif/libsodium`` doesn't clash with the converted
noise-c's ``libsodium``. Mirrors esp32's ``_write_idf_component_yml``.
Components ESPHome's own idf_component.yml provides (e.g. lan867x for
ethernet) are NOT stubbed -- those are real deps we need, and arduino-esp32
resolves to the same component rather than conflicting.
"""
import yaml
from esphome.components.esp32 import (
ARDUINO_EXCLUDED_IDF_COMPONENTS,
_idf_component_dep_name,
_idf_component_stub_name,
)
esphome_dir = Path(__file__).resolve().parent.parent
base_manifest = yaml.safe_load(
(esphome_dir / "idf_component.yml").read_text(encoding="utf-8")
)
esphome_deps = set(base_manifest.get("dependencies") or {})
stubs_dir = work_dir / "component_stubs"
stubs_dir.mkdir(parents=True, exist_ok=True)
deps: dict[str, dict] = {}
for component in sorted(ARDUINO_EXCLUDED_IDF_COMPONENTS):
if _idf_component_dep_name(component) in esphome_deps:
continue # ESPHome needs this one for real (don't stub it away)
stub_path = stubs_dir / _idf_component_stub_name(component)
stub_path.mkdir(exist_ok=True)
(stub_path / "CMakeLists.txt").write_text(
"idf_component_register()\n", encoding="utf-8"
)
deps[_idf_component_dep_name(component)] = {
"version": "*",
"override_path": str(stub_path),
}
return deps
def _write_tidy_project(
work_dir: Path,
requires: list[str],
extra_deps: dict[str, dict[str, str]],
settings: _Settings,
) -> None:
"""Generate the minimal IDF CMake project (top + main + idf_component.yml)."""
main_dir = work_dir / "main"
main_dir.mkdir(parents=True, exist_ok=True)
compile_options = "\n".join(
f'idf_build_set_property(COMPILE_OPTIONS "-D{define}" APPEND)'
for define in settings.platform_defines
)
(work_dir / "CMakeLists.txt").write_text(
_TOP_CMAKELISTS.format(
name=TIDY_PROJECT_NAME,
compile_options=compile_options,
idf_target=settings.idf_target,
),
encoding="utf-8",
)
(main_dir / "CMakeLists.txt").write_text(
_MAIN_CMAKELISTS.format(requires=" ".join(requires)), encoding="utf-8"
)
(main_dir / "tidy.cpp").write_text(_TIDY_MAIN_CPP, encoding="utf-8")
# Managed components: ESPHome's own manifest (arduinojson, lvgl, mdns, ...),
# plus the converted PlatformIO libs as local (override_path) deps. Placing
# it in main/ makes every dep a requirement of the main component, so their
# public includes land on the tidy translation unit.
import yaml
esphome_dir = Path(__file__).resolve().parent.parent # esphome/espidf -> esphome
manifest = yaml.safe_load(
(esphome_dir / "idf_component.yml").read_text(encoding="utf-8")
)
manifest.setdefault("dependencies", {}).update(extra_deps)
(main_dir / "idf_component.yml").write_text(
yaml.safe_dump(manifest, sort_keys=False), encoding="utf-8"
)
# ESPHome's static-analysis sdkconfig (repo root): enables the flags any
# component sets (e.g. CONFIG_BT_ENABLED) so sdkconfig-gated IDF components
# register and expose their includes. IDF reads ``sdkconfig.defaults`` from
# the project root.
(work_dir / "sdkconfig.defaults").write_text(
(esphome_dir.parent / "sdkconfig.defaults").read_text(encoding="utf-8"),
encoding="utf-8",
)
def _generate_compile_commands(
work_dir: Path, settings: _Settings, platformio_ini: Path
) -> Path:
"""Generate the tidy project and run ``idf.py reconfigure`` (no build).
Two-phase, like a real ESPHome build: a first configure with no builtin
requires discovers which components actually register for the target (e.g.
``esp_tee`` only registers on c5/c6/h2), then a second configure requires
that discovered set so their public includes reach the tidy TU.
"""
import logging
from esphome.build_gen.espidf import get_available_components
from esphome.espidf import toolchain
# Surface ESPHome's INFO logs (ESP-IDF framework download/extract/install,
# git-library clones) -- they go through logging, which the clang-tidy
# script otherwise leaves at WARNING so the first-run downloads look silent.
logging.basicConfig(level=logging.INFO, format="%(message)s")
_setup_core(work_dir, settings)
# Framework deps (e.g. arduino-esp32) + PlatformIO libs converted to local
# IDF components, all added to the manifest as deps.
extra_deps = dict(settings.framework_deps)
extra_deps.update(_convert_pio_libs(platformio_ini, settings.target_framework))
if settings.target_framework == "arduino":
# Stub the arduino-bundled components ESPHome doesn't use (avoids the
# libsodium clash with noise-c and ~26 unused heavy downloads).
extra_deps.update(_arduino_excluded_stubs(work_dir))
# Phase 1: discover the components available for this target.
_write_tidy_project(work_dir, [], extra_deps, settings)
if toolchain.run_reconfigure() != 0:
raise RuntimeError("idf.py reconfigure (discovery) failed")
requires = sorted(
set(get_available_components() or []) - _NON_REQUIRABLE_COMPONENTS
)
# Phase 2: require every available builtin component.
_write_tidy_project(work_dir, requires, extra_deps, settings)
if toolchain.run_reconfigure() != 0:
raise RuntimeError("idf.py reconfigure failed")
return work_dir / "build" / "compile_commands.json"
def _idedata_from_tidy_project(compile_commands: Path) -> dict:
"""Assemble idedata from the single tidy translation unit.
Unlike a real ESPHome build (many ``/src/esphome/`` TUs unioned), the tidy
project has one TU (``main/tidy.cpp``) that -- by requiring every component --
already carries the full include set, so we parse it directly.
"""
import json
from esphome.espidf.idedata import _get_toolchain_includes, _parse_entry
entries = json.loads(Path(compile_commands).read_text(encoding="utf-8"))
entry = next((e for e in entries if e["file"].endswith("tidy.cpp")), None)
if entry is None:
raise RuntimeError(f"tidy.cpp not found in {compile_commands}")
cxx_path, defines, includes, cxx_flags = _parse_entry(entry)
return {
"cxx_path": cxx_path,
"cxx_flags": cxx_flags,
"defines": defines,
"includes": {
"build": includes,
"toolchain": _get_toolchain_includes(cxx_path),
},
}
def load_idedata(environment: str, temp_folder: str, platformio_ini: Path) -> dict:
if explicit := os.environ.get("ESPHOME_IDF_COMPILE_COMMANDS"):
compile_commands = Path(explicit)
else:
# The tidy env is ``<target>-<framework>-tidy`` (e.g. esp32-idf-tidy,
# esp32s3-arduino-tidy); derive the target, variant and framework.
settings = _settings_for(environment)
# Resolve to an absolute path: ``override_path`` entries in the generated
# component manifests are interpreted by the IDF component manager relative
# to the manifest's own directory, so a relative work dir would be
# mis-resolved (doubled under ``main/``).
work_dir = (
Path(temp_folder)
/ f"idf-tidy-{settings.idf_target}-{settings.target_framework}"
).resolve()
compile_commands = _generate_compile_commands(
work_dir, settings, platformio_ini
)
if not compile_commands.is_file():
raise RuntimeError(f"compile_commands.json not found: {compile_commands}")
return _idedata_from_tidy_project(compile_commands)

View File

@@ -28,6 +28,12 @@ from helpers import (
temp_header_file,
)
# Limit the ESP-IDF tool install to esp32 for clang-tidy: the one xtensa-esp-elf
# toolchain bundles the s2/s3 compilers too, so all xtensa tidy envs still
# reconfigure while the large riscv32-esp-elf toolchain is skipped. Must be set
# before esphome.espidf.framework is imported (lazily, via load_idedata).
os.environ.setdefault("ESPHOME_IDF_DEFAULT_TARGETS", "esp32")
def clang_options(idedata):
cmd = []
@@ -52,6 +58,10 @@ def clang_options(idedata):
"-mfix-esp32-psram-cache-issue",
"-mfix-esp32-psram-cache-strategy=memw",
"-fno-tree-switch-conversion",
# GCC-only flags emitted by the native ESP-IDF toolchain build
"-freorder-blocks",
"-fno-jump-tables",
"-fno-shrink-wrap",
)
if "zephyr" in triplet:
@@ -97,8 +107,20 @@ def clang_options(idedata):
]
)
# copy compiler flags, except those clang doesn't understand.
cmd.extend(flag for flag in idedata["cxx_flags"] if flag not in omit_flags)
# Copy compiler flags, dropping: ones clang doesn't understand; -Werror*
# (clang-tidy enforces .clang-tidy's WarningsAsErrors, and a build -Werror
# would bypass the -clang-diagnostic-* suppressions); and -std= (the native
# ESP-IDF build defaults to gnu++2b, but ESPHome compiles with gnu++20 per
# platformio.ini -- analyzing as C++23 flags code that doesn't build under
# gnu++20). Force gnu++20 to match the real build.
cmd.extend(
flag
for flag in idedata["cxx_flags"]
if flag not in omit_flags
and not flag.startswith("-Werror")
and not flag.startswith("-std=")
)
cmd.append("-std=gnu++20")
# defines
cmd.extend(f"-D{define}" for define in idedata["defines"])

View File

@@ -664,26 +664,22 @@ def load_idedata(environment: str) -> dict[str, Any]:
start_time = time.time()
print(f"Loading IDE data for environment '{environment}'...")
platformio_ini = Path(root_path) / "platformio.ini"
# Reuse the clang-tidy input hash as the cache key: it already covers every
# file baked into the generated idedata (platformio.ini, sdkconfig.defaults,
# esphome/idf_component.yml), so this can't drift from that file list. A
# content hash -- unlike an mtime comparison -- stays correct across git
# checkouts, which don't preserve mtimes.
from clang_tidy_hash import calculate_clang_tidy_hash
temp_idedata = Path(temp_folder) / f"idedata-{environment}.json"
changed = False
if (
not platformio_ini.is_file()
or not temp_idedata.is_file()
or platformio_ini.stat().st_mtime >= temp_idedata.stat().st_mtime
):
changed = True
temp_hash = Path(temp_folder) / f"idedata-{environment}.hash"
if "idf" in environment:
# remove full sdkconfig when the defaults have changed so that it is regenerated
default_sdkconfig = Path(root_path) / "sdkconfig.defaults"
temp_sdkconfig = Path(temp_folder) / f"sdkconfig-{environment}"
if not temp_sdkconfig.is_file():
changed = True
elif default_sdkconfig.stat().st_mtime >= temp_sdkconfig.stat().st_mtime:
temp_sdkconfig.unlink()
changed = True
cache_key = calculate_clang_tidy_hash()
changed = (
not temp_idedata.is_file()
or not temp_hash.is_file()
or temp_hash.read_text().strip() != cache_key
)
if not changed:
data = json.loads(temp_idedata.read_text())
@@ -694,7 +690,12 @@ def load_idedata(environment: str) -> dict[str, Any]:
# ensure temp directory exists before running pio, as it writes sdkconfig to it
Path(temp_folder).mkdir(exist_ok=True)
if "nrf" in environment:
platformio_ini = Path(root_path) / "platformio.ini"
if "esp32" in environment:
from esphome.espidf.clang_tidy import load_idedata as idf_load_idedata
data = idf_load_idedata(environment, temp_folder, platformio_ini)
elif "nrf" in environment:
from helpers_zephyr import load_idedata as zephyr_load_idedata
data = zephyr_load_idedata(environment, temp_folder, platformio_ini)
@@ -705,6 +706,7 @@ def load_idedata(environment: str) -> dict[str, Any]:
match = re.search(r'{\s*".*}', stdout.decode("utf-8"))
data = json.loads(match.group())
temp_idedata.write_text(json.dumps(data, indent=2) + "\n")
temp_hash.write_text(cache_key + "\n")
elapsed = time.time() - start_time
print(f"IDE data generated and cached in {elapsed:.2f} seconds")

View File

@@ -1,15 +1,11 @@
# ESP-IDF sdkconfig defaults used for development purposes only, not used during runtime. Used when PlatformIO is ran
# directly from the source directory, e.g. by IDEs or for static analysis (clang-tidy). This should enable all flags
# that are set by any component.
# ESP-IDF sdkconfig defaults used for development purposes only, not used during runtime. Used for static analysis
# (clang-tidy) -- by both the PlatformIO and the native ESP-IDF toolchain paths -- and when PlatformIO is run directly
# from the source directory (e.g. by IDEs). This should enable all flags that are set by any component.
# esp32
CONFIG_COMPILER_OPTIMIZATION_DEFAULT=n
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
CONFIG_PARTITION_TABLE_CUSTOM=y
#CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_PARTITION_TABLE_SINGLE_APP=n
CONFIG_FREERTOS_HZ=1000
CONFIG_ESP_TASK_WDT=y
CONFIG_ESP_TASK_WDT_INIT=y
CONFIG_ESP_TASK_WDT_PANIC=y
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n
@@ -18,8 +14,7 @@ CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n
CONFIG_BT_ENABLED=y
# esp32_camera
CONFIG_RTCIO_SUPPORT_RTC_GPIO_DESC=y
CONFIG_ESP32_SPIRAM_SUPPORT=y
CONFIG_SPIRAM=y
# zigbee
CONFIG_ZB_ENABLED=y