diff --git a/src/clanguru/compilation_options_manager.py b/src/clanguru/compilation_options_manager.py index a6d0279..0dc1047 100644 --- a/src/clanguru/compilation_options_manager.py +++ b/src/clanguru/compilation_options_manager.py @@ -1,6 +1,7 @@ import io import json import os +import shlex import traceback from dataclasses import dataclass from pathlib import Path @@ -13,6 +14,24 @@ from py_app_dev.core.exceptions import UserNotificationException +def split_command(command: str) -> list[str]: + r""" + Split a compilation database ``command`` string into arguments. + + Quotes are removed, so ``-DMACRO=""`` yields an empty macro value instead of the + string literal ``""``. + + The backslash rule follows the shell that wrote the command. On Windows it is a path + separator, so ``-IC:\inc`` must survive intact. Everywhere else it is an escape, so + ``-DVERSION=\"1.0\"`` must yield the quoted value ``"1.0"``. + """ + lexer = shlex.shlex(command, posix=True) + lexer.whitespace_split = True + if os.name == "nt": + lexer.escape = "" + return list(lexer) + + class PathField(SerializableType): def _serialize(self) -> str: return str(self) @@ -39,7 +58,7 @@ def get_compiler(self) -> Path | None: if self.arguments: return Path(self.arguments[0]) if self.command: - return Path(self.command.split()[0]) + return Path(split_command(self.command)[0]) return None def get_compile_options(self) -> list[str]: @@ -47,7 +66,7 @@ def get_compile_options(self) -> list[str]: if self.arguments: options = self.arguments if self.command: - options = self.command.split() + options = split_command(self.command) return self.clean_up_arguments(options) def get_includes_and_defines(self) -> list[str]: @@ -158,7 +177,7 @@ def set_default_options(self, options: list[str]) -> None: def _filter_includes_and_defines(options: list[str]) -> list[str]: - """Keep only -I and -D flags (including their values when passed as separate arguments).""" + """Keep only -I, -isystem and -D flags (including their values when passed as separate arguments).""" filtered: list[str] = [] take_next = False for option in options: @@ -166,10 +185,10 @@ def _filter_includes_and_defines(options: list[str]) -> list[str]: filtered.append(option) take_next = False continue - if option in ("-I", "-D"): + if option in ("-I", "-isystem", "-D"): filtered.append(option) take_next = True - elif option.startswith(("-I", "-D")): + elif option.startswith(("-I", "-isystem", "-D")): filtered.append(option) return filtered diff --git a/tests/test_compilation_options_manager.py b/tests/test_compilation_options_manager.py index 0a5dba1..7694b97 100644 --- a/tests/test_compilation_options_manager.py +++ b/tests/test_compilation_options_manager.py @@ -1,4 +1,5 @@ import json +import os from collections.abc import Generator from pathlib import Path @@ -179,6 +180,16 @@ def test_clean_up_arguments_with_partial_paths(compile_command): ['-DVERSION="1.0"', "-I/usr/include", "-I", "/usr/local/include"], id="combined_and_separate", ), + pytest.param( + ["g++", "-I/project/src", "-isystem", "/build/kconfig", "-Wall", "-c", "/home/user/project/input.c", "-o", "/home/user/project/output.o"], + ["-I/project/src", "-isystem", "/build/kconfig"], + id="separate_system_include", + ), + pytest.param( + ["g++", "-isystem/build/kconfig", "-DDEBUG", "-c", "/home/user/project/input.c", "-o", "/home/user/project/output.o"], + ["-isystem/build/kconfig", "-DDEBUG"], + id="combined_system_include", + ), pytest.param( ["gcc", "-Wall", "-Werror", "-O2", "-std=c11", "-c", "/home/user/project/input.c", "-o", "/home/user/project/output.o"], [], @@ -242,6 +253,43 @@ def test_filter_compilation_database(tmp_path: Path) -> None: assert kept_files == ["a.c", "b.c", "d.cc"] +@pytest.mark.parametrize( + "command, expected_compiler, expected_options", + [ + pytest.param( + 'gcc -DEMPTY="" -DSTUFF -I/usr/include -c input.c -o output.o', + Path("gcc"), + ["-DEMPTY=", "-DSTUFF", "-I/usr/include"], + id="quoted_empty_define", + ), + pytest.param( + r'"C:\Program Files\gcc\bin\gcc.exe" -DSTUFF -c input.c -o output.o', + Path(r"C:\Program Files\gcc\bin\gcc.exe"), + ["-DSTUFF"], + id="windows_compiler_path_with_spaces", + ), + pytest.param( + r'C:\tools\gcc.exe -DVERSION="1.0" -IC:\inc -c input.c -o output.o', + Path(r"C:\tools\gcc.exe"), + ["-DVERSION=1.0", r"-IC:\inc"], + marks=pytest.mark.skipif(os.name != "nt", reason="an unquoted backslash is an escape outside Windows"), + id="windows_backslashes_kept", + ), + pytest.param( + r"gcc -DVERSION=\"1.0\" -I/usr/include -c input.c -o output.o", + Path("gcc"), + ['-DVERSION="1.0"', "-I/usr/include"], + marks=pytest.mark.skipif(os.name == "nt", reason="Windows has no backslash escape to resolve"), + id="posix_escaped_string_define", + ), + ], +) +def test_command_string_is_split_with_shell_quoting(command: str, expected_compiler: Path, expected_options: list[str]) -> None: + compile_command = CompileCommand(directory=Path("/project"), file=Path("input.c"), command=command, output=Path("output.o")) + assert compile_command.get_compiler() == expected_compiler + assert compile_command.get_compile_options() == expected_options + + def test_to_json(tmp_path: Path) -> None: db_content = [ { diff --git a/tests/test_cparser.py b/tests/test_cparser.py index 681909c..6847b8e 100644 --- a/tests/test_cparser.py +++ b/tests/test_cparser.py @@ -1,3 +1,4 @@ +import json import textwrap from pathlib import Path from tempfile import TemporaryDirectory @@ -309,6 +310,30 @@ def test_parsing_gtest_tests(tmp_path: Path, gtest_include_path: Path) -> None: assert "{{ gtest.test }}" in (param_class.description or "") +def test_parsing_with_system_include_path(tmp_path: Path) -> None: + """A macro from a header found only via ``-isystem`` must enable the code it guards.""" + config_dir = tmp_path / "kconfig" + config_dir.mkdir() + (config_dir / "autoconf.h").write_text("#define CONFIG_FEATURE 1\n") + source_file = tmp_path / "feature.c" + source_file.write_text( + dedent("""\ + #include "autoconf.h" + #if CONFIG_FEATURE + // Only present when the feature is selected + void feature_function(void) {} + #endif + """) + ) + entry = {"directory": str(tmp_path), "file": str(source_file), "arguments": ["gcc", "-isystem", str(config_dir), "-c", str(source_file)]} + compile_db = tmp_path / "compile_commands.json" + compile_db.write_text(json.dumps([entry])) + + tu = CLangParser().load(source_file, CompilationOptionsManager(compile_db)) + + assert [f.name for f in CLangParser.get_functions(tu)] == ["feature_function"] + + def test_token_cached_fields_match_raw_properties(tmp_path: Path) -> None: file = tmp_path / "cached.c" file.write_text("int x = 42;\n")