From b3a0e166fe3209448b844f7e150b955937754203 Mon Sep 17 00:00:00 2001 From: "Ivan Grokhotkov (bot)" Date: Thu, 16 Jul 2026 16:34:20 +0200 Subject: [PATCH] feat: add compile_options support to idf.py build-file Allow specifying extra compiler flags (warning flags, preprocessor definitions, etc.) via a new 'compile_options' entry in the idf-build-file frontmatter. The options are applied to the source file via target_compile_options in the generated main component CMakeLists, with CMake quoting so that flags like -DMSG="hello world" reach the compiler exactly as written. Co-Authored-By: Claude Fable 5 --- docs/en/api-guides/tools/idf-build-file.rst | 11 ++ .../zh_CN/api-guides/tools/idf-build-file.rst | 11 ++ tools/idf_py_actions/build_file_ext.py | 21 +++- tools/test_idf_py/test_build_file.py | 101 ++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) diff --git a/docs/en/api-guides/tools/idf-build-file.rst b/docs/en/api-guides/tools/idf-build-file.rst index 86860d27e4e..18b7e6760ce 100644 --- a/docs/en/api-guides/tools/idf-build-file.rst +++ b/docs/en/api-guides/tools/idf-build-file.rst @@ -67,6 +67,17 @@ A list of ``sdkconfig`` options. These are written to ``sdkconfig.defaults`` in - CONFIG_IDF_TARGET=esp32s3 - CONFIG_SPIRAM=y +``compile_options`` +^^^^^^^^^^^^^^^^^^^ + +A list of extra compiler options applied when compiling the source file, such as warning flags or preprocessor definitions. + +.. code-block:: yaml + + compile_options: + - -Wconversion + - -DBUFFER_SIZE=1024 + ``dependencies`` ^^^^^^^^^^^^^^^^ diff --git a/docs/zh_CN/api-guides/tools/idf-build-file.rst b/docs/zh_CN/api-guides/tools/idf-build-file.rst index cf2ca9ab2e7..a75539d8c8d 100644 --- a/docs/zh_CN/api-guides/tools/idf-build-file.rst +++ b/docs/zh_CN/api-guides/tools/idf-build-file.rst @@ -67,6 +67,17 @@ - CONFIG_IDF_TARGET=esp32s3 - CONFIG_SPIRAM=y +``compile_options`` +^^^^^^^^^^^^^^^^^^^ + +编译源文件时使用的额外编译器选项列表,例如警告标志或预处理器定义。 + +.. code-block:: yaml + + compile_options: + - -Wconversion + - -DBUFFER_SIZE=1024 + ``dependencies`` ^^^^^^^^^^^^^^^^ diff --git a/tools/idf_py_actions/build_file_ext.py b/tools/idf_py_actions/build_file_ext.py index 74d61c93a74..08502a6168c 100644 --- a/tools/idf_py_actions/build_file_ext.py +++ b/tools/idf_py_actions/build_file_ext.py @@ -106,6 +106,16 @@ def _is_bool_or_number(value: str) -> bool: return False +def _cmake_quote(value: str) -> str: + """Quote a string as a CMake quoted argument. + + Backslashes and double quotes are escaped so that the option reaches the + compiler exactly as written in the frontmatter, e.g. -DMSG="hello world". + """ + escaped = value.replace('\\', '\\\\').replace('"', '\\"') + return f'"{escaped}"' + + def _classify_dependencies(dependencies: list) -> tuple[list[str], dict[str, Any]]: """Split dependencies into built-in components and managed components. @@ -224,6 +234,15 @@ def _generate_container_project(source_file: str, frontmatter: dict, container_d f'idf_component_register(SRCS "{source_basename}"\n' f' INCLUDE_DIRS "."{requires_clause})\n' ) + + # Extra compiler options from the frontmatter + compile_options = frontmatter.get('compile_options', []) + if not isinstance(compile_options, list) or not all(isinstance(opt, str) for opt in compile_options): + raise FatalError(f'compile_options in {source_file} frontmatter must be a list of strings') + if compile_options: + quoted_options = ' '.join(_cmake_quote(opt) for opt in compile_options) + main_cmake += f'target_compile_options(${{COMPONENT_LIB}} PRIVATE {quoted_options})\n' + _write_if_changed(os.path.join(main_dir, 'CMakeLists.txt'), main_cmake) # sdkconfig.defaults @@ -331,7 +350,7 @@ def action_extensions(base_actions: dict, project_path: str) -> dict: 'Build a standalone C source file without requiring a full ESP-IDF project structure. ' 'The source file may contain an optional YAML frontmatter in a block comment ' 'starting with "idf-build-file:" to specify sdkconfig options, component dependencies, ' - 'and other settings.\n\n' + 'compiler options, and other settings.\n\n' 'A container project is automatically created and cached. Subsequent builds of the ' 'same file reuse the container project for fast incremental builds.\n\n' 'This command is composable with other idf.py commands. For example:\n' diff --git a/tools/test_idf_py/test_build_file.py b/tools/test_idf_py/test_build_file.py index 684309e88b5..128bc8fbd9d 100644 --- a/tools/test_idf_py/test_build_file.py +++ b/tools/test_idf_py/test_build_file.py @@ -16,10 +16,12 @@ import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'idf_py_actions')) from build_file_ext import _classify_dependencies +from build_file_ext import _cmake_quote from build_file_ext import _generate_container_project from build_file_ext import _is_bool_or_number from build_file_ext import _parse_frontmatter from build_file_ext import _write_if_changed +from idf_py_actions.errors import FatalError IDF_PATH = os.environ.get('IDF_PATH', os.path.join(os.path.dirname(__file__), '..', '..')) IDF_PY = os.path.join(IDF_PATH, 'tools', 'idf.py') @@ -151,6 +153,20 @@ class TestIsBoolOrNumber: assert _is_bool_or_number('') is True +class TestCmakeQuote: + def test_plain_flag(self) -> None: + assert _cmake_quote('-O2') == '"-O2"' + + def test_simple_define(self) -> None: + assert _cmake_quote('-DFOO=BAR') == '"-DFOO=BAR"' + + def test_define_with_quotes(self) -> None: + assert _cmake_quote('-DMSG="hello world"') == '"-DMSG=\\"hello world\\""' + + def test_backslash(self) -> None: + assert _cmake_quote('-DPATH=a\\b') == '"-DPATH=a\\\\b"' + + class TestWriteIfChanged: def test_creates_new_file(self, tmp_dir: str) -> None: path = os.path.join(tmp_dir, 'test.txt') @@ -256,6 +272,55 @@ class TestGenerateContainerProject: content = f.read() assert 'protocol_examples_common' in content + def test_generates_compile_options(self, tmp_dir: str) -> None: + src = os.path.join(tmp_dir, 'test.c') + with open(src, 'w') as f: + f.write('void app_main(void) {}\n') + + frontmatter = {'compile_options': ['-O2', '-DFOO=BAR', '-DMSG="hello world"']} + container = os.path.join(tmp_dir, 'container') + _generate_container_project(src, frontmatter, container) + + with open(os.path.join(container, 'main', 'CMakeLists.txt')) as f: + content = f.read() + assert 'target_compile_options(${COMPONENT_LIB} PRIVATE "-O2" "-DFOO=BAR" "-DMSG=\\"hello world\\"")' in content + + def test_no_compile_options_by_default(self, tmp_dir: str) -> None: + src = os.path.join(tmp_dir, 'test.c') + with open(src, 'w') as f: + f.write('void app_main(void) {}\n') + + container = os.path.join(tmp_dir, 'container') + _generate_container_project(src, {}, container) + + with open(os.path.join(container, 'main', 'CMakeLists.txt')) as f: + content = f.read() + assert 'target_compile_options' not in content + + def test_compile_options_removed_on_regeneration(self, tmp_dir: str) -> None: + src = os.path.join(tmp_dir, 'test.c') + with open(src, 'w') as f: + f.write('void app_main(void) {}\n') + + container = os.path.join(tmp_dir, 'container') + _generate_container_project(src, {'compile_options': ['-O2']}, container) + _generate_container_project(src, {}, container) + + with open(os.path.join(container, 'main', 'CMakeLists.txt')) as f: + content = f.read() + assert 'target_compile_options' not in content + + def test_compile_options_must_be_list_of_strings(self, tmp_dir: str) -> None: + src = os.path.join(tmp_dir, 'test.c') + with open(src, 'w') as f: + f.write('void app_main(void) {}\n') + + container = os.path.join(tmp_dir, 'container') + with pytest.raises(FatalError, match='compile_options'): + _generate_container_project(src, {'compile_options': '-O2'}, container) + with pytest.raises(FatalError, match='compile_options'): + _generate_container_project(src, {'compile_options': [['-O2']]}, container) + def test_copies_source_file(self, tmp_dir: str) -> None: src = os.path.join(tmp_dir, 'test.c') with open(src, 'w') as f: @@ -327,6 +392,42 @@ class TestBuildFileEndToEnd: # Verify size command ran (it prints memory usage) assert 'Total image size' in output or 'Used static' in output + def test_compile_options_reach_the_compiler(self, tmp_dir: str) -> None: + """Compiler flags from the frontmatter must reach the compiler with quoting intact. + + The source file fails to compile unless each flag style (plain flag, + simple define, string define with a space) arrives exactly as written. + """ + src = os.path.join(tmp_dir, 'flags.c') + with open(src, 'w') as f: + f.write( + '/*\n' + ' idf-build-file:\n' + ' config:\n' + ' - CONFIG_IDF_TARGET=esp32\n' + ' compile_options:\n' + ' - -Wall\n' + ' - -DANSWER=42\n' + ' - \'-DGREETING="hello world"\'\n' + ' */\n' + '#include \n' + '#ifndef ANSWER\n' + '#error "ANSWER not defined"\n' + '#endif\n' + '#if ANSWER != 42\n' + '#error "ANSWER has wrong value"\n' + '#endif\n' + '/* Fails unless GREETING expands to the string literal "hello world" */\n' + '_Static_assert(sizeof(GREETING) == sizeof("hello world"), "GREETING has wrong value");\n' + 'void app_main(void) { printf("%s %d\\n", GREETING, ANSWER); }\n' + ) + + result, output, container_dir = self._run_build_file(src) + assert result.returncode == 0, f'build with compile_options failed:\n{output}' + assert container_dir, f'container project dir not found in output:\n{output}' + with open(os.path.join(container_dir, 'main', 'CMakeLists.txt')) as f: + assert 'target_compile_options' in f.read() + def test_config_change_takes_effect_on_rebuild(self, tmp_dir: str) -> None: """Changing an sdkconfig option in the frontmatter must be applied on rebuild.""" src = os.path.join(tmp_dir, 'hello.c')