179 lines
6.6 KiB
Python
179 lines
6.6 KiB
Python
"""Derive idedata from an ESP-IDF native-toolchain ``compile_commands.json``.
|
|
|
|
PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native ESP-IDF
|
|
toolchain has no such command, but its CMake build emits
|
|
``build/compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS). This module
|
|
turns that file into the same fields consumers (IDE integration, clang-tidy)
|
|
expect:
|
|
|
|
{cxx_path, cxx_flags, defines, includes: {build, toolchain}}
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import shlex
|
|
import subprocess
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
# C++ translation-unit suffixes used to identify ESPHome source files.
|
|
_CXX_SUFFIXES = (".cpp", ".cc")
|
|
# Suffixes of input/output files that appear bare on the command line (and so
|
|
# must not be mistaken for compiler flags).
|
|
_INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s")
|
|
# Path marker identifying an ESPHome source translation unit.
|
|
_ESPHOME_SRC_MARKER = "/src/esphome/"
|
|
|
|
|
|
def _expand_response_files(tokens: list[str], directory: Path) -> list[str]:
|
|
"""Inline any ``@response-file`` arguments (paths relative to ``directory``).
|
|
|
|
GCC response files embed flags that must be expanded so GCC-only flags
|
|
inside them (e.g. ``-mlongcalls``) can be filtered downstream; left as
|
|
``@file`` clang would read them and choke.
|
|
"""
|
|
out: list[str] = []
|
|
for tok in tokens:
|
|
if tok.startswith("@"):
|
|
rf = Path(tok[1:])
|
|
if not rf.is_absolute():
|
|
rf = directory / rf
|
|
try:
|
|
out.extend(
|
|
_expand_response_files(
|
|
shlex.split(rf.read_text(encoding="utf-8")), directory
|
|
)
|
|
)
|
|
continue
|
|
except OSError as err:
|
|
# Keep the literal token if the file can't be read, but log it
|
|
# so the (otherwise opaque) downstream clang failure is traceable.
|
|
_LOGGER.warning("Could not read response file %s: %s", rf, err)
|
|
out.append(tok)
|
|
return out
|
|
|
|
|
|
def _pick_entry(entries: list[dict]) -> dict:
|
|
"""Pick a representative ESPHome C++ translation unit.
|
|
|
|
All ESPHome sources share the same component flags/defines, so any one of
|
|
them yields the cxx_path / cxx_flags / defines we need.
|
|
"""
|
|
for entry in entries:
|
|
f = entry["file"]
|
|
if _ESPHOME_SRC_MARKER in f and f.endswith(_CXX_SUFFIXES):
|
|
return entry
|
|
for entry in entries:
|
|
if entry["file"].endswith(_CXX_SUFFIXES):
|
|
return entry
|
|
raise ValueError("no C++ translation unit found in compile_commands.json")
|
|
|
|
|
|
def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]:
|
|
"""Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
|
|
directory = Path(entry["directory"])
|
|
tokens = _expand_response_files(shlex.split(entry["command"]), directory)
|
|
|
|
def _include(raw: str) -> str:
|
|
# Include paths in compile_commands are interpreted relative to the
|
|
# entry's ``directory`` (e.g. build-local ``-Iconfig``); resolve them
|
|
# so the cached idedata is usable regardless of the consumer's cwd.
|
|
raw = raw.strip()
|
|
if raw and not Path(raw).is_absolute():
|
|
raw = os.path.normpath(directory / raw)
|
|
return raw
|
|
|
|
cxx_path = tokens[0]
|
|
defines: list[str] = []
|
|
includes: list[str] = []
|
|
cxx_flags: list[str] = []
|
|
|
|
it = iter(tokens[1:])
|
|
for tok in it:
|
|
if tok in ("-c", "-o"):
|
|
next(it, None) # drop the flag and its argument (input/output)
|
|
elif tok.startswith("-D"):
|
|
# ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single
|
|
# quoted arg with a space after -D) that some flags arrive as.
|
|
defines.append(tok[2:].strip() if len(tok) > 2 else next(it, "").strip())
|
|
elif tok.startswith("-I"):
|
|
includes.append(_include(tok[2:] if len(tok) > 2 else next(it, "")))
|
|
elif tok == "-isystem":
|
|
includes.append(_include(next(it, "")))
|
|
elif tok.startswith("-isystem"):
|
|
includes.append(_include(tok[len("-isystem") :]))
|
|
elif tok in ("-MT", "-MF", "-MQ"):
|
|
next(it, None) # dependency-file flag + its argument
|
|
elif tok.startswith(("-MD", "-MMD", "-MP", "-MM")):
|
|
pass # dependency-generation flags, no argument
|
|
elif tok.endswith(_INPUT_FILE_SUFFIXES):
|
|
pass # input/output files
|
|
else:
|
|
cxx_flags.append(tok)
|
|
return cxx_path, defines, includes, cxx_flags
|
|
|
|
|
|
def _get_toolchain_includes(cxx_path: str) -> list[str]:
|
|
"""Query the compiler for its builtin ``#include <...>`` search dirs."""
|
|
result = subprocess.run(
|
|
[cxx_path, "-E", "-x", "c++", "-", "-v"],
|
|
input="",
|
|
text=True,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE,
|
|
check=False,
|
|
close_fds=False,
|
|
)
|
|
includes: list[str] = []
|
|
capture = False
|
|
for line in result.stderr.splitlines():
|
|
if "#include <...> search starts here:" in line:
|
|
capture = True
|
|
continue
|
|
if "End of search list." in line:
|
|
break
|
|
if capture:
|
|
includes.append(line.strip())
|
|
if result.returncode != 0 or not includes:
|
|
raise RuntimeError(
|
|
f"Could not query builtin include dirs from {cxx_path} "
|
|
f"(return code {result.returncode}); stderr:\n{result.stderr.strip()}"
|
|
)
|
|
return includes
|
|
|
|
|
|
def idedata_from_build(compile_commands: Path) -> dict:
|
|
"""Parse compile_commands.json into the idedata fields consumers expect.
|
|
|
|
A single ESP-IDF compile entry only carries its own component's REQUIRES
|
|
include set, but consumers (clang-tidy) analyze ESPHome headers that
|
|
transitively pull in other components. So take cxx_path / cxx_flags /
|
|
defines from a representative ESPHome TU, but union the include dirs across
|
|
all ESPHome TUs to get a project-wide superset (as PlatformIO's idedata
|
|
provides).
|
|
"""
|
|
entries = json.loads(Path(compile_commands).read_text(encoding="utf-8"))
|
|
cxx_path, defines, _, cxx_flags = _parse_entry(_pick_entry(entries))
|
|
|
|
build_includes: dict[str, None] = {}
|
|
for entry in entries:
|
|
f = entry["file"]
|
|
if _ESPHOME_SRC_MARKER not in f or not f.endswith(_CXX_SUFFIXES):
|
|
continue
|
|
for inc in _parse_entry(entry)[2]:
|
|
build_includes.setdefault(inc, None)
|
|
|
|
return {
|
|
"cxx_path": cxx_path,
|
|
"cxx_flags": cxx_flags,
|
|
"defines": defines,
|
|
"includes": {
|
|
"build": list(build_includes),
|
|
"toolchain": _get_toolchain_includes(cxx_path),
|
|
},
|
|
}
|