1use brokk_bifrost_core::analyzer::ProjectFile;
8use brokk_bifrost_core::analyzer::project::Project;
9use brokk_bifrost_core::hash::{HashMap, HashSet};
10use brokk_bifrost_core::path_normalization::NormalizePath;
11use serde::Deserialize;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct CppCompileContext {
20 pub project_include_roots: Vec<PathBuf>,
21 pub system_include_roots: Vec<PathBuf>,
22 pub forced_includes: Vec<PathBuf>,
23 pub defined_macros: HashSet<String>,
24}
25
26#[derive(Debug, Default)]
27pub struct CppCompileContexts {
28 by_source: HashMap<PathBuf, CppCompileContext>,
29}
30
31impl CppCompileContexts {
32 pub fn load(project: &dyn Project) -> Self {
33 let database_path = project.root().join("compile_commands.json");
34 let Ok(database) = std::fs::read_to_string(database_path) else {
35 return Self::default();
36 };
37 let Ok(entries) = serde_json::from_str::<Vec<CompilationDatabaseEntry>>(&database) else {
38 return Self::default();
39 };
40
41 let mut by_source = HashMap::default();
42 let mut ambiguous_sources = HashSet::default();
43 for entry in entries {
44 let Some(source) = entry.source_path(project.root()) else {
45 continue;
46 };
47 if !source.starts_with(project.root()) || ambiguous_sources.contains(&source) {
48 continue;
49 }
50 let Some(context) = entry.compile_context(project.root()) else {
51 continue;
52 };
53 if by_source.insert(source.clone(), context).is_some() {
54 by_source.remove(&source);
55 ambiguous_sources.insert(source);
56 }
57 }
58 Self { by_source }
59 }
60
61 pub fn for_file(&self, file: &ProjectFile) -> Option<&CppCompileContext> {
62 self.by_source.get(&file.abs_path().normalize())
63 }
64}
65
66#[derive(Debug, Deserialize)]
67struct CompilationDatabaseEntry {
68 directory: PathBuf,
69 file: PathBuf,
70 arguments: Option<Vec<String>>,
71 command: Option<String>,
72}
73
74impl CompilationDatabaseEntry {
75 fn source_path(&self, workspace_root: &Path) -> Option<PathBuf> {
76 absolute_path(
77 &command_directory(workspace_root, &self.directory)?,
78 &self.file,
79 )
80 }
81
82 fn compile_context(&self, workspace_root: &Path) -> Option<CppCompileContext> {
83 let arguments = match &self.arguments {
84 Some(arguments) if !arguments.is_empty() => arguments.clone(),
85 Some(_) => return None,
86 None => shlex::split(self.command.as_deref()?)?,
87 };
88 parse_compile_arguments(
89 &command_directory(workspace_root, &self.directory)?,
90 &arguments,
91 )
92 }
93}
94
95fn command_directory(workspace_root: &Path, directory: &Path) -> Option<PathBuf> {
96 absolute_path(workspace_root, directory)
97}
98
99fn parse_compile_arguments(directory: &Path, arguments: &[String]) -> Option<CppCompileContext> {
100 if arguments.is_empty() {
101 return None;
102 }
103
104 let mut project_include_roots = Vec::new();
105 let mut system_include_roots = Vec::new();
106 let mut forced_includes = Vec::new();
107 let mut defined_macros = HashSet::default();
108 let mut index = 1;
109 while index < arguments.len() {
110 let argument = &arguments[index];
111 match argument.as_str() {
112 "-I" => {
113 project_include_roots.push(argument_path(directory, arguments.get(index + 1)?)?);
114 index += 2;
115 }
116 "-iquote" => {
117 project_include_roots.push(argument_path(directory, arguments.get(index + 1)?)?);
118 index += 2;
119 }
120 "-isystem" => {
121 system_include_roots.push(argument_path(directory, arguments.get(index + 1)?)?);
122 index += 2;
123 }
124 "-include" => {
125 forced_includes.push(argument_path(directory, arguments.get(index + 1)?)?);
126 index += 2;
127 }
128 "-D" => {
129 defined_macros.insert(macro_name(arguments.get(index + 1)?)?);
130 index += 2;
131 }
132 _ => {
133 if let Some(path) = argument.strip_prefix("-I") {
134 project_include_roots.push(argument_path(directory, path)?);
135 } else if let Some(path) = argument.strip_prefix("-iquote") {
136 project_include_roots.push(argument_path(directory, path)?);
137 } else if let Some(path) = argument.strip_prefix("-isystem") {
138 system_include_roots.push(argument_path(directory, path)?);
139 } else if let Some(definition) = argument.strip_prefix("-D") {
140 defined_macros.insert(macro_name(definition)?);
141 }
142 index += 1;
143 }
144 }
145 }
146
147 Some(CppCompileContext {
148 project_include_roots,
149 system_include_roots,
150 forced_includes,
151 defined_macros,
152 })
153}
154
155fn argument_path(directory: &Path, raw: &str) -> Option<PathBuf> {
156 if raw.is_empty() {
157 return None;
158 }
159 absolute_path(directory, Path::new(raw))
160}
161
162fn absolute_path(directory: &Path, path: &Path) -> Option<PathBuf> {
163 let path = if path.is_absolute() {
164 path.to_path_buf()
165 } else {
166 directory.join(path)
167 }
168 .normalize();
169 path.is_absolute().then_some(path)
170}
171
172fn macro_name(definition: &str) -> Option<String> {
173 let end = definition.find('=').unwrap_or(definition.len());
174 let name = &definition[..end];
175 (!name.is_empty()).then(|| name.to_string())
176}
177
178#[cfg(test)]
179mod tests {
180 use super::CppCompileContexts;
181 use brokk_bifrost_core::analyzer::project::TestProject;
182 use brokk_bifrost_core::analyzer::{Language, ProjectFile};
183
184 fn project_with_database(database: Option<&str>) -> (tempfile::TempDir, TestProject) {
185 let temp = tempfile::tempdir().expect("temp dir");
186 let root = temp.path().canonicalize().expect("canonical root");
187 ProjectFile::new(root.clone(), "src/main.cpp")
188 .write("int main() { return 0; }")
189 .expect("source");
190 if let Some(database) = database {
191 ProjectFile::new(root.clone(), "compile_commands.json")
192 .write(database)
193 .expect("database");
194 }
195 (temp, TestProject::new(root, Language::Cpp))
196 }
197
198 #[test]
199 fn missing_or_malformed_database_has_no_context() {
200 let (_temp, project) = project_with_database(None);
201 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
202 assert!(CppCompileContexts::load(&project).for_file(&file).is_none());
203
204 let (_temp, project) = project_with_database(Some("not json"));
205 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
206 assert!(CppCompileContexts::load(&project).for_file(&file).is_none());
207 }
208
209 #[test]
210 fn arguments_entry_collects_include_paths_and_macro_names() {
211 let (_temp, project) = project_with_database(Some(
212 r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-iquotequotes","-isystem","system-include","-DDEBUG=1","-D","FEATURE","-c","src/main.cpp"]}]"#,
213 ));
214 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
215 let contexts = CppCompileContexts::load(&project);
216 let context = contexts.for_file(&file).expect("matching context");
217
218 assert_eq!(
219 vec![
220 project.root_path().join("include"),
221 project.root_path().join("quotes"),
222 ],
223 context.project_include_roots
224 );
225 assert_eq!(
226 vec![project.root_path().join("system-include")],
227 context.system_include_roots
228 );
229 assert!(context.defined_macros.contains("DEBUG"));
230 assert!(context.defined_macros.contains("FEATURE"));
231 }
232
233 #[test]
234 fn quoted_command_entry_is_tokenized_without_executing_it() {
235 let (_temp, project) = project_with_database(Some(
236 r#"[{"directory":".","file":"src/main.cpp","command":"clang++ -I 'project include' -DNAME=\\\"two words\\\" -c src/main.cpp"}]"#,
237 ));
238 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
239 let contexts = CppCompileContexts::load(&project);
240 let context = contexts.for_file(&file).expect("matching context");
241
242 assert_eq!(
243 vec![project.root_path().join("project include")],
244 context.project_include_roots
245 );
246 assert!(context.defined_macros.contains("NAME"));
247 }
248
249 #[test]
250 fn duplicate_or_unmatched_entries_do_not_supply_context() {
251 let (_temp, project) = project_with_database(Some(
252 r#"[
253 {"directory":".","file":"src/main.cpp","arguments":["clang++","-c","src/main.cpp"]},
254 {"directory":".","file":"src/main.cpp","arguments":["clang++","-DOTHER","-c","src/main.cpp"]},
255 {"directory":".","file":"src/other.cpp","arguments":["clang++","-c","src/other.cpp"]}
256 ]"#,
257 ));
258 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
259 assert!(CppCompileContexts::load(&project).for_file(&file).is_none());
260 }
261}