Skip to content

Commit 237adac

Browse files
committed
feat: add compile commands parsing
1 parent 7e78674 commit 237adac

File tree

2 files changed

+281
-0
lines changed

2 files changed

+281
-0
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import io
2+
import json
3+
import traceback
4+
from dataclasses import dataclass, field
5+
from pathlib import Path
6+
from typing import ClassVar, List, Optional
7+
8+
from mashumaro import DataClassDictMixin
9+
from mashumaro.config import TO_DICT_ADD_OMIT_NONE_FLAG, BaseConfig
10+
from mashumaro.mixins.json import DataClassJSONMixin
11+
from mashumaro.types import SerializableType
12+
13+
from .exceptions import UserNotificationException
14+
15+
16+
class PathField(SerializableType):
17+
def _serialize(self) -> str:
18+
return str(self)
19+
20+
@classmethod
21+
def _deserialize(cls, value: str) -> Path:
22+
return Path(value)
23+
24+
25+
@dataclass
26+
class CompileCommand(DataClassDictMixin):
27+
directory: Path
28+
file: Path
29+
arguments: List[str] = field(default_factory=list)
30+
command: Optional[str] = None
31+
output: Optional[Path] = None
32+
33+
def get_compile_options(self) -> List[str]:
34+
options = []
35+
if self.arguments:
36+
options = self.arguments
37+
if self.command:
38+
options = self.command.split()
39+
return self.clean_up_arguments(options)
40+
41+
def get_file_path(self) -> Path:
42+
return self.file if self.file.is_absolute() else self.directory / self.file
43+
44+
def clean_up_arguments(self, arguments: List[str]) -> List[str]:
45+
"""
46+
Clean up the command line to only get the compilation options.
47+
48+
Ignore the first argument which is the compiler.
49+
Remove the options for the output and input files.
50+
Any arguments containing the input or output file names or paths are removed.
51+
For example: -DStuff -ISome/Path -o output.o input.c -> -DStuff -ISome/Path
52+
"""
53+
cleaned_args = []
54+
skip_next = False
55+
input_filename = self.file.name
56+
output_filename = self.output.name if self.output else None
57+
58+
for arg in arguments[1:]: # Skip the first argument (compiler)
59+
if skip_next:
60+
skip_next = False
61+
continue
62+
63+
# Skip -o and its value
64+
if arg == "-o":
65+
skip_next = True
66+
continue
67+
68+
# Skip -c option
69+
if arg == "-c":
70+
continue
71+
72+
# Skip arguments containing input or output file names or paths
73+
if input_filename in arg or (output_filename and output_filename in arg):
74+
continue
75+
76+
# Keep all other arguments
77+
cleaned_args.append(arg)
78+
79+
return cleaned_args
80+
81+
82+
@dataclass
83+
class CompilationDatabase(DataClassJSONMixin):
84+
commands: List[CompileCommand]
85+
86+
def getCompileCommands(self, file: Path) -> List[CompileCommand]:
87+
return [command for command in self.commands if command.get_file_path() == file]
88+
89+
class Config(BaseConfig):
90+
"""Custom configuration for the dataclass serialization to ignore None values."""
91+
92+
code_generation_options: ClassVar[List[str]] = [TO_DICT_ADD_OMIT_NONE_FLAG]
93+
94+
@classmethod
95+
def from_json_file(cls, file_path: Path) -> "CompilationDatabase":
96+
try:
97+
result = cls.from_dict({"commands": json.loads(file_path.read_text())})
98+
except Exception as e:
99+
output = io.StringIO()
100+
traceback.print_exc(file=output)
101+
raise UserNotificationException(output.getvalue()) from e
102+
return result
103+
104+
def to_json_string(self) -> str:
105+
return json.dumps(self.to_dict(omit_none=True), indent=2)
106+
107+
def to_json_file(self, file_path: Path) -> None:
108+
file_path.write_text(self.to_json_string())
109+
110+
111+
class CompilationOptionsManager:
112+
def __init__(self, compilation_database: Optional[Path] = None, no_default: bool = False):
113+
self.compilation_database: Optional[CompilationDatabase] = CompilationDatabase.from_json_file(compilation_database) if compilation_database else None
114+
self.no_default = no_default
115+
self.default_options = ["-std=c11"]
116+
117+
def get_compile_options(self, file: Path) -> List[str]:
118+
if self.compilation_database:
119+
commands: List[CompileCommand] = self.compilation_database.getCompileCommands(file)
120+
# TODO: how to handle multiple commands for the same file?
121+
if commands:
122+
return commands[0].get_compile_options()
123+
return [] if self.no_default else self.default_options
124+
125+
def set_default_options(self, options: List[str]) -> None:
126+
self.default_options = options

tests/test_compile_commands.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import json
2+
from pathlib import Path
3+
from typing import Generator
4+
5+
import pytest
6+
7+
from py_app_dev.core.compile_commands import CompilationDatabase, CompilationOptionsManager, CompileCommand
8+
9+
10+
@pytest.fixture
11+
def temp_compilation_database(tmp_path: Path) -> Generator[Path, None, None]:
12+
db_content = [
13+
{"directory": tmp_path.as_posix(), "file": "test1.c", "arguments": ["gcc", "-I/usr/include", "-DDEBUG", "test1.c"]},
14+
{"directory": tmp_path.as_posix(), "file": "test2.c", "command": "gcc -c -O2 test2.c"},
15+
]
16+
db_file = tmp_path / "compile_commands.json"
17+
db_file.write_text(json.dumps(db_content))
18+
yield db_file
19+
20+
21+
def test_compilation_options_manager_without_database() -> None:
22+
manager = CompilationOptionsManager()
23+
assert manager.get_compile_options(Path("any_file.c")) == ["-std=c11"]
24+
25+
26+
def test_compilation_options_manager_with_database(temp_compilation_database: Path) -> None:
27+
manager = CompilationOptionsManager(temp_compilation_database)
28+
29+
# Test for file in the database
30+
test1_path = Path(temp_compilation_database.parent) / "test1.c"
31+
options = manager.get_compile_options(test1_path)
32+
assert options == ["-I/usr/include", "-DDEBUG"]
33+
34+
# Test for file in the database with command string
35+
test2_path = Path(temp_compilation_database.parent) / "test2.c"
36+
options = manager.get_compile_options(test2_path)
37+
assert options == ["-O2"]
38+
39+
# Test for file not in the database
40+
not_in_db_path = Path(temp_compilation_database.parent) / "not_in_db.c"
41+
options = manager.get_compile_options(not_in_db_path)
42+
assert options == ["-std=c11"]
43+
44+
45+
def test_compilation_options_manager_no_default() -> None:
46+
manager = CompilationOptionsManager(no_default=True)
47+
options = manager.get_compile_options(Path("any_file.c"))
48+
assert options == []
49+
50+
51+
def test_set_default_options() -> None:
52+
manager = CompilationOptionsManager()
53+
new_defaults = ["-std=c99", "-Wall"]
54+
manager.set_default_options(new_defaults)
55+
assert manager.get_compile_options(Path("any_file.c")) == new_defaults
56+
57+
58+
def test_compilation_database_from_json() -> None:
59+
json_data = """
60+
[
61+
{
62+
"directory": "/home/user/project",
63+
"file": "main.c",
64+
"arguments": ["gcc", "-c", "-I/usr/include", "main.c"]
65+
}
66+
]
67+
"""
68+
tmp_file = Path("temp_compile_commands.json")
69+
tmp_file.write_text(json_data)
70+
71+
try:
72+
db = CompilationDatabase.from_json_file(tmp_file)
73+
assert len(db.commands) == 1
74+
assert db.commands[0].directory == Path("/home/user/project")
75+
assert db.commands[0].file == Path("main.c")
76+
assert db.commands[0].arguments == ["gcc", "-c", "-I/usr/include", "main.c"]
77+
finally:
78+
tmp_file.unlink()
79+
80+
81+
def test_get_compile_commands() -> None:
82+
json_data = """
83+
[
84+
{
85+
"directory": "/home/user/project",
86+
"file": "main.c",
87+
"arguments": ["gcc", "-c", "-I/usr/include", "main.c"]
88+
},
89+
{
90+
"directory": "/home/user/project",
91+
"file": "helper.c",
92+
"command": "gcc -c -O2 helper.c"
93+
}
94+
]
95+
"""
96+
tmp_file = Path("temp_compile_commands.json")
97+
tmp_file.write_text(json_data)
98+
99+
try:
100+
db = CompilationDatabase.from_json_file(tmp_file)
101+
commands = db.getCompileCommands(Path("/home/user/project/main.c"))
102+
assert len(commands) == 1
103+
assert commands[0].file == Path("main.c")
104+
assert commands[0].arguments == ["gcc", "-c", "-I/usr/include", "main.c"]
105+
106+
commands = db.getCompileCommands(Path("/home/user/project/helper.c"))
107+
assert len(commands) == 1
108+
assert commands[0].file == Path("helper.c")
109+
assert commands[0].command == "gcc -c -O2 helper.c"
110+
111+
commands = db.getCompileCommands(Path("/home/user/project/nonexistent.c"))
112+
assert len(commands) == 0
113+
finally:
114+
tmp_file.unlink()
115+
116+
117+
@pytest.fixture
118+
def compile_command():
119+
return CompileCommand(directory=Path("/home/user/project"), file=Path("/home/user/project/input.c"), output=Path("/home/user/project/output.o"))
120+
121+
122+
def test_clean_up_arguments_basic(compile_command):
123+
arguments = ["gcc", "-DStuff", "-ISome/Path", "-o", "/home/user/project/output.o", "/home/user/project/input.c"]
124+
expected = ["-DStuff", "-ISome/Path"]
125+
assert compile_command.clean_up_arguments(arguments) == expected
126+
127+
128+
def test_clean_up_arguments_with_c_option(compile_command):
129+
arguments = ["gcc", "-DStuff", "-ISome/Path", "-c", "-o", "/home/user/project/output.o", "/home/user/project/input.c"]
130+
expected = ["-DStuff", "-ISome/Path"]
131+
assert compile_command.clean_up_arguments(arguments) == expected
132+
133+
134+
def test_clean_up_arguments_with_combined_options(compile_command):
135+
arguments = ["gcc", "-DStuff", "-ISome/Path", "-c", "-o/home/user/project/output.o", "/home/user/project/input.c"]
136+
expected = ["-DStuff", "-ISome/Path"]
137+
assert compile_command.clean_up_arguments(arguments) == expected
138+
139+
140+
def test_clean_up_arguments_with_multiple_input_files(compile_command):
141+
arguments = ["gcc", "-DStuff", "-ISome/Path", "-c", "/home/user/project/input.c", "/home/user/project/helper.c", "-o", "/home/user/project/output.o"]
142+
expected = ["-DStuff", "-ISome/Path", "/home/user/project/helper.c"]
143+
assert compile_command.clean_up_arguments(arguments) == expected
144+
145+
146+
def test_clean_up_arguments_with_complex_options(compile_command):
147+
arguments = ["gcc", "-DStuff", "-ISome/Path", "-Werror", "-Wall", "-std=c11", '-DVERSION="1.0"', "-c", "/home/user/project/input.c", "-o", "/home/user/project/output.o"]
148+
expected = ["-DStuff", "-ISome/Path", "-Werror", "-Wall", "-std=c11", '-DVERSION="1.0"']
149+
assert compile_command.clean_up_arguments(arguments) == expected
150+
151+
152+
def test_clean_up_arguments_with_partial_paths(compile_command):
153+
arguments = ["gcc", "-DStuff", "-I/home/user/project", "-Werror", "-Wall", "-c", "project/input.c", "-o", "project/output.o"]
154+
expected = ["-DStuff", "-I/home/user/project", "-Werror", "-Wall"]
155+
assert compile_command.clean_up_arguments(arguments) == expected

0 commit comments

Comments
 (0)