diff --git a/esphome/arduino8266/toolchain.py b/esphome/arduino8266/toolchain.py index 5703d73181..6d2c2cde5d 100644 --- a/esphome/arduino8266/toolchain.py +++ b/esphome/arduino8266/toolchain.py @@ -210,8 +210,12 @@ def get_idedata() -> dict | None: """ from esphome.espidf.idedata import load_or_build_idedata + ccache = framework.ccache_path() return load_or_build_idedata( get_build_dir() / "compile_commands.json", get_elf_path(), CORE.relative_internal_path("idedata", f"{CORE.name}.json"), + # The compile DB's commands carry the same ccache prefix the ninja + # rules were generated with + launcher=str(ccache) if ccache else None, ) diff --git a/esphome/espidf/idedata.py b/esphome/espidf/idedata.py index 355a395ebc..ae15888649 100644 --- a/esphome/espidf/idedata.py +++ b/esphome/espidf/idedata.py @@ -120,7 +120,9 @@ def _pick_entry(entries: list[dict]) -> dict: raise ValueError("no C++ translation unit found in compile_commands.json") -def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]: +def _parse_entry( + entry: dict, launcher: str | None = None +) -> 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(_split_command(entry["command"]), directory) @@ -136,8 +138,10 @@ def _parse_entry(entry: dict) -> tuple[str, list[str], list[str], list[str]]: raw = os.path.normpath(directory / raw) return raw.replace("\\", "/") - # A ccache-wrapped command ("ccache g++ ...") names the compiler second. - if Path(tokens[0]).stem == "ccache": + # A launcher-wrapped command ("ccache g++ ...") names the compiler + # second. The caller passes the exact launcher it configured into the + # build, so this is a comparison, not a guess by name. + if launcher is not None and tokens[0] == launcher: tokens = tokens[1:] # token0 is the compiler path; the rest of the command already uses forward # slashes on Windows, so normalize it too for a consistent idedata file. @@ -223,12 +227,17 @@ def _cc_path_from_cxx(cxx_path: str) -> str: def load_or_build_idedata( - compile_commands: Path, elf_path: Path, cache: Path + compile_commands: Path, + elf_path: Path, + cache: Path, + launcher: str | None = None, ) -> dict | None: """Return idedata for a compile_commands.json build, cached on mtime. Shared by the native ESP-IDF and ESP8266 Arduino toolchains. Returns None - when the compile DB doesn't exist yet (nothing was built). + when the compile DB doesn't exist yet (nothing was built). ``launcher`` + is the compiler-launcher path (ccache) the build was generated with, if + any; commands in the compile DB are prefixed with it. """ if not compile_commands.is_file(): _LOGGER.debug("No %s yet; skipping idedata generation", compile_commands) @@ -247,14 +256,14 @@ def load_or_build_idedata( if isinstance(cached, dict) and "cc_path" in cached: return cached - data = idedata_from_build(compile_commands) + data = idedata_from_build(compile_commands, launcher) data["prog_path"] = str(elf_path) cache.parent.mkdir(parents=True, exist_ok=True) cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") return data -def idedata_from_build(compile_commands: Path) -> dict: +def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> dict: """Parse compile_commands.json into the idedata fields consumers expect. A single ESP-IDF compile entry only carries its own component's REQUIRES @@ -265,13 +274,13 @@ def idedata_from_build(compile_commands: Path) -> dict: provides). """ entries = json.loads(Path(compile_commands).read_text(encoding="utf-8")) - cxx_path, defines, _, cxx_flags = _parse_entry(_pick_entry(entries)) + cxx_path, defines, _, cxx_flags = _parse_entry(_pick_entry(entries), launcher) build_includes: dict[str, None] = {} for entry in entries: if not _is_esphome_src(entry["file"]): continue - for inc in _parse_entry(entry)[2]: + for inc in _parse_entry(entry, launcher)[2]: build_includes.setdefault(inc, None) return { diff --git a/tests/unit_tests/test_arduino8266_toolchain.py b/tests/unit_tests/test_arduino8266_toolchain.py index 4757d4db22..a0ac1aa5e6 100644 --- a/tests/unit_tests/test_arduino8266_toolchain.py +++ b/tests/unit_tests/test_arduino8266_toolchain.py @@ -200,14 +200,31 @@ def test_print_size_summary_size_tool_failure( def test_get_idedata_delegates(tmp_path: Path) -> None: - with patch( - "esphome.espidf.idedata.load_or_build_idedata", return_value={"cc_path": "x"} - ) as mock_load: + with ( + patch( + "esphome.espidf.idedata.load_or_build_idedata", + return_value={"cc_path": "x"}, + ) as mock_load, + patch.object(framework, "ccache_path", return_value=Path("/cc/ccache")), + ): assert toolchain.get_idedata() == {"cc_path": "x"} compile_commands, elf, cache = mock_load.call_args[0] assert compile_commands.name == "compile_commands.json" assert elf.name == "firmware.elf" assert cache.name == "test8266.json" + # The exact configured launcher is passed for compile DB parsing + assert mock_load.call_args.kwargs["launcher"] == str(Path("/cc/ccache")) + + +def test_get_idedata_no_ccache(tmp_path: Path) -> None: + with ( + patch( + "esphome.espidf.idedata.load_or_build_idedata", return_value={} + ) as mock_load, + patch.object(framework, "ccache_path", return_value=None), + ): + toolchain.get_idedata() + assert mock_load.call_args.kwargs["launcher"] is None def test_run_compile_skips_compdb_when_ninja_unchanged(tmp_path: Path) -> None: diff --git a/tests/unit_tests/test_espidf_idedata.py b/tests/unit_tests/test_espidf_idedata.py index f6b25bd1de..7036e893a2 100644 --- a/tests/unit_tests/test_espidf_idedata.py +++ b/tests/unit_tests/test_espidf_idedata.py @@ -264,18 +264,24 @@ def test_parse_entry_normalizes_windows_cxx_path() -> None: assert "C:/inc/a" in includes -def test_parse_entry_strips_ccache_prefix() -> None: - """A ccache-wrapped compile names the compiler second; the wrapper must - not be mistaken for the compiler path.""" +def test_parse_entry_strips_launcher_prefix() -> None: + """A launcher-wrapped compile names the compiler second; the exact + configured launcher is stripped, not anything ccache-shaped.""" entry = _entry( f"{ABS}build", f"{ABS}build/src/esphome/core/application.cpp", "/opt/homebrew/bin/ccache /tools/xtensa-lx106-elf-g++ -DUSE_ESP8266 " "-c app.cpp -o app.cpp.o", ) - cxx_path, defines, _, _ = idedata._parse_entry(entry) + cxx_path, defines, _, _ = idedata._parse_entry( + entry, launcher="/opt/homebrew/bin/ccache" + ) assert cxx_path == "/tools/xtensa-lx106-elf-g++" assert defines == ["USE_ESP8266"] + # Without a configured launcher nothing is stripped, even a token that + # happens to be named ccache + cxx_path, _, _, _ = idedata._parse_entry(entry) + assert cxx_path == "/opt/homebrew/bin/ccache" def _write_compile_commands(tmp_path: Path) -> Path: