Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions src/clanguru/compilation_options_manager.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import io
import json
import os
import shlex
import traceback
from dataclasses import dataclass
from pathlib import Path
Expand All @@ -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)
Expand All @@ -39,15 +58,15 @@ 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]:
options = []
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]:
Expand Down Expand Up @@ -158,18 +177,18 @@ 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:
if take_next:
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

Expand Down
48 changes: 48 additions & 0 deletions tests/test_compilation_options_manager.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import os
from collections.abc import Generator
from pathlib import Path

Expand Down Expand Up @@ -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"],
[],
Expand Down Expand Up @@ -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 = [
{
Expand Down
25 changes: 25 additions & 0 deletions tests/test_cparser.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import textwrap
from pathlib import Path
from tempfile import TemporaryDirectory
Expand Down Expand Up @@ -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")
Expand Down
Loading