"""Tests for esphome.build_gen.host (the host ninja generator).""" from __future__ import annotations import logging import os from pathlib import Path import sys from unittest.mock import patch import pytest from esphome.arduino.library import ArduinoLibrary from esphome.build_gen import host as build_gen from esphome.build_helpers.ninja import escape as _e, quote_path as _q from esphome.build_helpers.ninja_gen import Flag from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_HOST from esphome.core import CORE, EsphomeError, Library from esphome.host.toolchain import PROGRAM_NAME, HostCompilers COMPILERS = HostCompilers(cc=("/usr/bin/gcc", "-m32"), cxx=("/usr/bin/g++",)) @pytest.fixture(autouse=True) def _core(tmp_path: Path) -> None: CORE.build_path = tmp_path CORE.name = "dev" CORE.cpp_standard = "gnu++20" CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_HOST} def _make_src(tmp_path: Path, *names: str) -> Path: src = tmp_path / "src" for name in names: path = src / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text("") return src def _render( ccache: str | None = None, version: str = "g++ 1.0", compilers: HostCompilers = COMPILERS, ) -> tuple[bool, str]: with patch.object(build_gen, "_compiler_version", return_value=version): changed = build_gen.write_project(compilers, ccache) ninja = CORE.build_path / ".pioenvs" / "dev" / "build.ninja" return changed, ninja.read_text() @pytest.mark.parametrize( ("entries", "compile_flags", "link_flags"), [ ( ["-DUSE_HOST", "-Iinc", "-Ufoo", "-Wall"], [("-DUSE_HOST",), ("-Iinc",), ("-Ufoo",), ("-Wall",)], [], ), (["-std=gnu++20"], [("-std=gnu++20",)], []), ( ["-lssl", "-L/opt/lib", "-Wl,--gc-sections"], [], [("-lssl",), ("-L/opt/lib",), ("-Wl,--gc-sections",)], ), # A link-only flag travels with its argument ( ["-Xlinker --wrap=malloc", "-z noexecstack", "-framework Cocoa"], [], [ ("-Xlinker", "--wrap=malloc"), ("-z", "noexecstack"), ("-framework", "Cocoa"), ], ), # Both lines, as SCons routes unclassified flags ( ["-g -O2", "-fsanitize=address", "-pthread", "--coverage"], [ ("-g",), ("-O2",), ("-fsanitize=address",), ("-pthread",), ("--coverage",), ], [ ("-g",), ("-O2",), ("-fsanitize=address",), ("-pthread",), ("--coverage",), ], ), # A flag with a path argument travels with it, glued on or not ( ["-include pre.h", "-isystem /x", "-isystem/y", "-iquotez"], [("-include", "pre.h"), ("-isystem", "/x"), ("-isystem/y",), ("-iquotez",)], [], ), ], ) def test_split_flags( entries: list[str], compile_flags: list[Flag], link_flags: list[Flag] ) -> None: flags = build_gen.parse_flags(entries, "esphome") assert build_gen.split_flags(flags) == (compile_flags, link_flags) @pytest.mark.parametrize( "entries", [ # The flag is the last token of its entry ["-g -include"], # Entries are a set: the next entry is not this flag's argument ["-include", "pre.h"], ["-framework", "-pthread"], # A path never starts with "-" ["-include -pthread"], ], ) def test_parse_flags_refuses_a_flag_without_its_argument(entries: list[str]) -> None: with pytest.raises(EsphomeError, match="with no argument; write the flag"): build_gen.parse_flags(entries, "esphome") def test_parse_flags_accepts_a_linker_argument_that_is_a_flag() -> None: assert build_gen.parse_flags(["-Xlinker --wrap=malloc"], "esphome") == [ ("-Xlinker", "--wrap=malloc") ] def test_flag_lists_route_the_standard() -> None: """cpp_standard wins for C++; C never sees a C++ standard.""" CORE.build_flags = { "-DUSE_HOST", "-Iinc", "-Wl,-x", "-g", "-lssl", "-std=c++17", "-std=gnu17", } CORE.cxx_build_flags = {"-Wno-volatile"} cflags, cxxflags, link_flags = build_gen._flag_lists() # The relative include is anchored at the build path inc = f"-I{CORE.build_path / 'inc'}" assert cflags == ["-DUSE_HOST", inc, "-g", "-std=gnu17"] assert cxxflags == ["-std=gnu++20", "-DUSE_HOST", inc, "-g", "-Wno-volatile"] assert link_flags == ["-Wl,-x", "-g", "-lssl"] def test_flag_lists_without_cpp_standard_keeps_user_std() -> None: CORE.cpp_standard = None CORE.build_flags = {"-std=c++17"} cflags, cxxflags, _link = build_gen._flag_lists() assert cflags == [] assert cxxflags == ["-std=c++17"] def test_flag_lists_apply_unflags(caplog: pytest.LogCaptureFixture) -> None: CORE.build_flags = {"-g", "-DUSE_HOST"} CORE.build_unflags = {"-g", "-Onope"} with caplog.at_level(logging.WARNING): cflags, cxxflags, link_flags = build_gen._flag_lists() assert "-g" not in cflags + cxxflags + link_flags assert "-DUSE_HOST" in cflags assert "build_unflags entries matched no build flag: -Onope" in caplog.text def test_resolve_host_libraries_without_libraries() -> None: with patch("esphome.arduino.library.resolve_libraries") as resolve: assert build_gen._resolve_host_libraries() == [] resolve.assert_not_called() def test_resolve_host_libraries_is_framework_less() -> None: CORE.add_library(Library(name="lvgl/lvgl", version="9.5.0")) lib = ArduinoLibrary(name="lvgl") with patch( "esphome.arduino.library.resolve_libraries", return_value=[lib] ) as resolve: assert build_gen._resolve_host_libraries() == [lib] resolve.assert_called_once_with( None, pio_platform="native", board_mcu="host", cache_key="host", framework=None, manifest_optional=True, ) def test_write_project_requires_generated_sources(tmp_path: Path) -> None: with pytest.raises(EsphomeError, match="Generated source directory .* is missing"): build_gen.write_project(COMPILERS, None) _make_src(tmp_path, "esphome.h") with pytest.raises(EsphomeError, match="No source files found"): _render() def test_write_project_emits_every_source_kind(tmp_path: Path) -> None: src = _make_src(tmp_path, "main.cpp", "esphome/core/a.c", "x.S", "y.s", "h.h") CORE.build_flags = {"-DUSE_HOST", "-g"} changed, ninja = _render(ccache="/usr/bin/ccache") assert changed is True # A compiler override's arguments follow the program assert f"cc = {_q('/usr/bin/gcc')} -m32\n" in ninja assert f"cxx = {_q('/usr/bin/g++')}" in ninja assert f"ccache = {_q('/usr/bin/ccache')}" in ninja assert f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')}" in ninja assert ( f"build obj/src/esphome/core/a.c.o: c {_e(src / 'esphome/core/a.c')}" in ninja ) assert "build obj/src/x.S.o: aspp " in ninja assert "build obj/src/y.s.o: asm " in ninja assert _e(src / "h.h") not in ninja # __FILE__ stays relative to the build path, as it was under PlatformIO, # whether the source arrives absolute or rewritten by ccache sep = os.sep maps = ( f"{_q(f'-fmacro-prefix-map={tmp_path}{sep}=')} " f"{_q(f'-fmacro-prefix-map=..{sep}..{sep}=')}" ) assert f"cflags = -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja assert f"cxxflags = -std=gnu++20 -DUSE_HOST -g -I{_q(src)} {maps}\n" in ninja # Assembly gets the defines and includes only assert f"asflags = -DUSE_HOST -I{_q(src)} {maps}\n" in ninja assert "linkflags = -g\n" in ninja assert "libdirflags = \n" in ninja assert "libflags = \n" in ninja assert "rule ar" not in ninja assert ( f"build {PROGRAM_NAME}: link obj/src/esphome/core/a.c.o obj/src/main.cpp.o " f"obj/src/x.S.o obj/src/y.s.o | \n archives = \ndefault {PROGRAM_NAME}\n" ) in ninja # Unchanged content reports no change so the compile DB can be reused changed, _ = _render(ccache="/usr/bin/ccache") assert changed is False def test_write_project_precompiles_the_core_headers(tmp_path: Path) -> None: """C++ src edges load one precompiled prefix; C and assembly do not.""" src = _make_src(tmp_path, "main.cpp", "esphome/core/a.c", "x.S") _, ninja = _render() build_dir = tmp_path / ".pioenvs" / "dev" source = build_dir / "esphome_pch_src.h" assert source.read_text() == '#include "esphome/core/pch_prefix.h"\n' assert "rule pch\n" in ninja sum_path = build_dir / "esphome_pch.h.gch.sum" assert ( f"build esphome_pch.h.gch: pch {_e(source)} | esphome_pch.h.gch.sum\n" in ninja ) assert "srccxxflags = -Winvalid-pch " in ninja assert ( f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')} | esphome_pch.h.gch\n" " flags = $srccxxflags\n" ) in ninja assert ( f"build obj/src/esphome/core/a.c.o: c {_e(src / 'esphome/core/a.c')}\n" ) in ninja assert f"build obj/src/x.S.o: aspp {_e(src / 'x.S')}\n" in ninja # An updated compiler behind the same path rebuilds the header first = sum_path.read_text() _render(version="g++ 2.0") assert sum_path.read_text() != first def test_compiler_version_asks_the_compiler() -> None: assert build_gen._compiler_version((sys.executable,)).startswith("Python ") def test_write_project_pch_disabled( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: src = _make_src(tmp_path, "main.cpp") monkeypatch.setenv("ESPHOME_PCH_ENABLE", "0") # The compiler is not asked for its version either with patch.object(build_gen, "_compiler_version", side_effect=AssertionError): build_gen.write_project(COMPILERS, None) ninja = (tmp_path / ".pioenvs" / "dev" / "build.ninja").read_text() assert "esphome_pch" not in ninja assert f"build obj/src/main.cpp.o: cxx {_e(src / 'main.cpp')}\n" in ninja def test_write_project_pch_skipped_for_a_user_force_include( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """A -include in the build flags precedes the prefix, so the compiler would never load the precompiled header.""" _make_src(tmp_path, "main.cpp") CORE.build_flags = {"-include pre.h"} _, ninja = _render() assert "esphome_pch" not in ninja assert "prevents the precompiled header" in caplog.text def test_write_project_pch_skipped_for_a_force_include_in_the_compiler( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: """CXX="g++ -include pre.h" puts it ahead of every flag.""" _make_src(tmp_path, "main.cpp") override = HostCompilers(cc=COMPILERS.cc, cxx=("/usr/bin/g++", "-include", "pre.h")) _, ninja = _render(compilers=override) assert "esphome_pch" not in ninja assert "prevents the precompiled header" in caplog.text def test_write_project_without_ccache(tmp_path: Path) -> None: _make_src(tmp_path, "main.cpp") _changed, ninja = _render() assert "ccache = \n" in ninja def test_write_project_routes_user_link_flags(tmp_path: Path) -> None: _make_src(tmp_path, "main.cpp") lib_dir = tmp_path / "opt" / "lib" # Forward slashes: build flags are lexed like a POSIX shell line, which # reads a backslash as an escape CORE.build_flags = { f"-L{lib_dir.as_posix()}", "-lcrypto", "-Wl,-framework,Security", } _changed, ninja = _render() assert "linkflags = -Wl,-framework,Security\n" in ninja assert f"libdirflags = -L{_q(lib_dir)}\n" in ninja assert "libflags = -lcrypto\n" in ninja def _libraries(tmp_path: Path) -> list[ArduinoLibrary]: lib_dir = tmp_path / "libs" for name in ("foo/src/a.cpp", "foo/src/sub/b.c", "bare/x.cpp"): path = lib_dir / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text("") archived = ArduinoLibrary( name="foo", sources=[lib_dir / "foo/src/a.cpp", lib_dir / "foo/src/sub/b.c"], include_dirs=[lib_dir / "foo/src"], flags=["-DFOO=1"], link_dirs=[lib_dir / "foo/lib"], link_libs=["bar"], link_flags=["-Wl,--gc-sections"], ) direct = ArduinoLibrary( name="bare", sources=[lib_dir / "bare/x.cpp"], lib_archive=False ) header_only = ArduinoLibrary(name="hdr", include_dirs=[lib_dir / "hdr"]) return [archived, direct, header_only] def test_write_project_with_libraries( tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: src = _make_src(tmp_path, "main.cpp") libs = _libraries(tmp_path) with ( patch.object(build_gen, "_resolve_host_libraries", return_value=libs), patch.object(build_gen, "find_tool", return_value="/usr/bin/ar") as find_tool, patch.object(build_gen.sys, "platform", "linux"), caplog.at_level(logging.DEBUG), ): _changed, ninja = _render() find_tool.assert_called_once_with("AR", ("ar",)) assert ( f"rule ar\n command = $python $buildtool ar {_q('/usr/bin/ar')} $out $out.rsp" in ninja ) lib_dir = tmp_path / "libs" # Every library's include dir joins the global include path assert f"-I{_q(src)} -I{_q(lib_dir / 'foo/src')} -I{_q(lib_dir / 'hdr')}" in ninja assert "linkflags = -Wl,--gc-sections\n" in ninja assert f"libdirflags = -L{_q(lib_dir / 'foo/lib')}\n" in ninja assert "libflags = -lbar\n" in ninja # Library sources compile with the library's own flags, rooted at their # common parent; its own include dirs lead the line so another library's # header of the same name cannot shadow them own = f"-I{_q(lib_dir / 'foo/src')}" assert "$own_includes $cxxflags $flags" in ninja assert ( f"build obj/lib/foo/a.cpp.o: cxx {_e(lib_dir / 'foo/src/a.cpp')}\n" f" own_includes = {own}\n flags = -DFOO=1\n" in ninja ) assert ( f"build obj/lib/foo/sub/b.c.o: c {_e(lib_dir / 'foo/src/sub/b.c')}\n" f" own_includes = {own}\n flags = -DFOO=1\n" in ninja ) assert "build libfoo.a: ar obj/lib/foo/a.cpp.o obj/lib/foo/sub/b.c.o\n" in ninja # libArchive: false objects link directly; the archive is an order-only # input wrapped in a group for GNU ld assert ( f"build {PROGRAM_NAME}: link obj/src/main.cpp.o obj/lib/bare/x.cpp.o " "| libfoo.a\n" " archives = -Wl,--start-group libfoo.a -Wl,--end-group\n" ) in ninja assert "Library hdr has no source files" in caplog.text def test_write_project_darwin_links_archives_bare(tmp_path: Path) -> None: _make_src(tmp_path, "main.cpp") libs = _libraries(tmp_path) with ( patch.object(build_gen, "_resolve_host_libraries", return_value=libs), patch.object(build_gen, "find_tool", return_value="/usr/bin/ar"), patch.object(build_gen.sys, "platform", "darwin"), ): _changed, ninja = _render() assert " archives = libfoo.a\n" in ninja assert "--start-group" not in ninja def test_build_unflags_remove_a_flag_with_its_argument( caplog: pytest.LogCaptureFixture, ) -> None: """Only the named pair goes; the same path under another flag stays.""" CORE.build_flags = {"-iquote inc", "-isystem inc", "-Xlinker -dead_strip", "-g"} CORE.build_unflags = {"-isystem inc", "-Xlinker -dead_strip"} with caplog.at_level(logging.WARNING): cflags, _cxxflags, link_flags = build_gen._flag_lists() assert cflags == ["-g", "-iquote", str(CORE.build_path / "inc")] assert link_flags == ["-g"] assert "matched no build flag" not in caplog.text def test_build_unflags_warn_when_nothing_matches( caplog: pytest.LogCaptureFixture, ) -> None: CORE.build_flags = {"-g"} CORE.build_unflags = {"-isystem inc", "-DNOPE"} with caplog.at_level(logging.WARNING): build_gen._flag_lists() assert "matched no build flag: -DNOPE, -isystem " in caplog.text def test_build_unflags_match_anchored_paths(caplog: pytest.LogCaptureFixture) -> None: CORE.build_flags = {"-Iinc", "-g"} CORE.build_unflags = {"-Iinc"} with caplog.at_level(logging.WARNING): cflags, _cxxflags, _link = build_gen._flag_lists() assert cflags == ["-g"] assert "matched no build flag" not in caplog.text