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::{Component, 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 include_search_roots: Vec<CppIncludeSearchRoot>,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33enum CppIncludeSearchRootKind {
34 Project,
35 Quote,
36 System,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40struct CppIncludeSearchRoot {
41 path: PathBuf,
42 kind: CppIncludeSearchRootKind,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum CppExternalIncludeResolution {
48 MissingCompileContext,
50 Undeclared,
52 Conflicting,
54 Declared { root: PathBuf, header: PathBuf },
56}
57
58#[derive(Debug, Default)]
59pub struct CppCompileContexts {
60 by_source: HashMap<PathBuf, Vec<CppCompileContext>>,
61}
62
63impl CppCompileContexts {
64 pub fn load(project: &dyn Project) -> Self {
65 let database_path = project.root().join("compile_commands.json");
66 let Ok(database) = std::fs::read_to_string(database_path) else {
67 return Self::default();
68 };
69 let Ok(entries) = serde_json::from_str::<Vec<CompilationDatabaseEntry>>(&database) else {
70 return Self::default();
71 };
72
73 let mut by_source: HashMap<PathBuf, Vec<CppCompileContext>> = HashMap::default();
74 for entry in entries {
75 let Some(source) = entry.source_path(project.root()) else {
76 continue;
77 };
78 if !source.starts_with(project.root()) {
79 continue;
80 }
81 let Some(context) = entry.compile_context(project.root()) else {
82 continue;
83 };
84 let candidates = by_source.entry(source).or_default();
90 if !candidates.contains(&context) {
91 candidates.push(context);
92 }
93 }
94 Self { by_source }
95 }
96
97 pub fn contexts_for(&self, file: &ProjectFile) -> &[CppCompileContext] {
104 self.by_source
105 .get(&file.abs_path().normalize())
106 .map_or(&[], Vec::as_slice)
107 }
108
109 pub fn resolve_external_angle_include(
117 &self,
118 file: &ProjectFile,
119 include: &Path,
120 ) -> CppExternalIncludeResolution {
121 let contexts = self.contexts_for(file);
122 let Some(first_context) = contexts.first() else {
123 return CppExternalIncludeResolution::MissingCompileContext;
124 };
125 let first = first_context.resolve_external_angle_include(file.root(), include);
126 if contexts
127 .iter()
128 .skip(1)
129 .any(|context| context.resolve_external_angle_include(file.root(), include) != first)
130 {
131 return CppExternalIncludeResolution::Conflicting;
132 }
133 match first {
134 Some((root, header)) => CppExternalIncludeResolution::Declared { root, header },
135 None => CppExternalIncludeResolution::Undeclared,
136 }
137 }
138
139 pub fn external_angle_include_roots(&self, workspace_root: &Path) -> Vec<PathBuf> {
146 let mut roots = self
147 .by_source
148 .values()
149 .flatten()
150 .flat_map(|context| context.external_angle_include_roots(workspace_root))
151 .map(Path::to_path_buf)
152 .collect::<Vec<_>>();
153 roots.sort();
154 roots.dedup();
155 roots
156 }
157}
158
159impl CppCompileContext {
160 pub fn external_angle_include_roots<'a>(
162 &'a self,
163 workspace_root: &'a Path,
164 ) -> impl Iterator<Item = &'a Path> + 'a {
165 self.include_search_roots.iter().filter_map(move |root| {
166 (root.kind != CppIncludeSearchRootKind::Quote
167 && (root.kind == CppIncludeSearchRootKind::System
168 || !root.path.starts_with(workspace_root)))
169 .then_some(root.path.as_path())
170 })
171 }
172
173 fn resolve_external_angle_include(
174 &self,
175 workspace_root: &Path,
176 include: &Path,
177 ) -> Option<(PathBuf, PathBuf)> {
178 if include.is_absolute()
179 || include
180 .components()
181 .any(|component| !matches!(component, Component::Normal(_)))
182 {
183 return None;
184 }
185 self.external_angle_include_roots(workspace_root)
186 .filter_map(|root| {
187 let root = root.canonicalize().ok()?;
188 let candidate = root.join(include).canonicalize().ok()?;
189 (candidate.starts_with(&root) && candidate.is_file()).then_some((root, candidate))
190 })
191 .next()
192 }
193}
194
195#[derive(Debug, Deserialize)]
196struct CompilationDatabaseEntry {
197 directory: PathBuf,
198 file: PathBuf,
199 arguments: Option<Vec<String>>,
200 command: Option<String>,
201}
202
203impl CompilationDatabaseEntry {
204 fn source_path(&self, workspace_root: &Path) -> Option<PathBuf> {
205 absolute_path(
206 &command_directory(workspace_root, &self.directory)?,
207 &self.file,
208 )
209 }
210
211 fn compile_context(&self, workspace_root: &Path) -> Option<CppCompileContext> {
212 let arguments = match &self.arguments {
213 Some(arguments) if !arguments.is_empty() => arguments.clone(),
214 Some(_) => return None,
215 None => shlex::split(self.command.as_deref()?)?,
216 };
217 parse_compile_arguments(
218 &command_directory(workspace_root, &self.directory)?,
219 &arguments,
220 )
221 }
222}
223
224fn command_directory(workspace_root: &Path, directory: &Path) -> Option<PathBuf> {
225 absolute_path(workspace_root, directory)
226}
227
228fn parse_compile_arguments(directory: &Path, arguments: &[String]) -> Option<CppCompileContext> {
229 if arguments.is_empty() {
230 return None;
231 }
232
233 let mut project_include_roots = Vec::new();
234 let mut system_include_roots = Vec::new();
235 let mut forced_includes = Vec::new();
236 let mut defined_macros = HashSet::default();
237 let mut include_search_roots = Vec::new();
238 let mut index = 1;
239 while index < arguments.len() {
240 let argument = &arguments[index];
241 match argument.as_str() {
242 "-I" | "/I" => {
243 let path = argument_path(directory, arguments.get(index + 1)?)?;
244 project_include_roots.push(path.clone());
245 include_search_roots.push(CppIncludeSearchRoot {
246 path,
247 kind: CppIncludeSearchRootKind::Project,
248 });
249 index += 2;
250 }
251 "-iquote" => {
252 let path = argument_path(directory, arguments.get(index + 1)?)?;
253 project_include_roots.push(path.clone());
254 include_search_roots.push(CppIncludeSearchRoot {
255 path,
256 kind: CppIncludeSearchRootKind::Quote,
257 });
258 index += 2;
259 }
260 "-isystem" | "/external:I" | "/imsvc" => {
261 let path = argument_path(directory, arguments.get(index + 1)?)?;
262 system_include_roots.push(path.clone());
263 include_search_roots.push(CppIncludeSearchRoot {
264 path,
265 kind: CppIncludeSearchRootKind::System,
266 });
267 index += 2;
268 }
269 "-include" => {
270 forced_includes.push(argument_path(directory, arguments.get(index + 1)?)?);
271 index += 2;
272 }
273 "-D" => {
274 defined_macros.insert(macro_name(arguments.get(index + 1)?)?);
275 index += 2;
276 }
277 _ => {
278 if let Some(path) = argument
279 .strip_prefix("/external:I")
280 .or_else(|| argument.strip_prefix("/imsvc"))
281 {
282 let path = argument_path(directory, path)?;
283 system_include_roots.push(path.clone());
284 include_search_roots.push(CppIncludeSearchRoot {
285 path,
286 kind: CppIncludeSearchRootKind::System,
287 });
288 } else if let Some(path) = argument
289 .strip_prefix("-I")
290 .or_else(|| argument.strip_prefix("/I"))
291 {
292 let path = argument_path(directory, path)?;
293 project_include_roots.push(path.clone());
294 include_search_roots.push(CppIncludeSearchRoot {
295 path,
296 kind: CppIncludeSearchRootKind::Project,
297 });
298 } else if let Some(path) = argument.strip_prefix("-iquote") {
299 let path = argument_path(directory, path)?;
300 project_include_roots.push(path.clone());
301 include_search_roots.push(CppIncludeSearchRoot {
302 path,
303 kind: CppIncludeSearchRootKind::Quote,
304 });
305 } else if let Some(path) = argument.strip_prefix("-isystem") {
306 let path = argument_path(directory, path)?;
307 system_include_roots.push(path.clone());
308 include_search_roots.push(CppIncludeSearchRoot {
309 path,
310 kind: CppIncludeSearchRootKind::System,
311 });
312 } else if let Some(definition) = argument.strip_prefix("-D") {
313 defined_macros.insert(macro_name(definition)?);
314 }
315 index += 1;
316 }
317 }
318 }
319
320 Some(CppCompileContext {
321 project_include_roots,
322 system_include_roots,
323 forced_includes,
324 defined_macros,
325 include_search_roots,
326 })
327}
328
329fn argument_path(directory: &Path, raw: &str) -> Option<PathBuf> {
330 if raw.is_empty() {
331 return None;
332 }
333 absolute_path(directory, Path::new(raw))
334}
335
336fn absolute_path(directory: &Path, path: &Path) -> Option<PathBuf> {
337 let path = if path.is_absolute() {
338 path.to_path_buf()
339 } else {
340 directory.join(path)
341 }
342 .normalize();
343 path.is_absolute().then_some(path)
344}
345
346fn macro_name(definition: &str) -> Option<String> {
347 let end = definition.find('=').unwrap_or(definition.len());
348 let name = &definition[..end];
349 (!name.is_empty()).then(|| name.to_string())
350}
351
352#[cfg(test)]
353mod tests {
354 use super::{CppCompileContexts, CppExternalIncludeResolution};
355 use brokk_bifrost_core::analyzer::project::TestProject;
356 use brokk_bifrost_core::analyzer::{Language, ProjectFile};
357
358 fn project_with_database(database: Option<&str>) -> (tempfile::TempDir, TestProject) {
359 let temp = tempfile::tempdir().expect("temp dir");
360 let root = temp.path().canonicalize().expect("canonical root");
361 ProjectFile::new(root.clone(), "src/main.cpp")
362 .write("int main() { return 0; }")
363 .expect("source");
364 if let Some(database) = database {
365 ProjectFile::new(root.clone(), "compile_commands.json")
366 .write(database)
367 .expect("database");
368 }
369 (temp, TestProject::new(root, Language::Cpp))
370 }
371
372 #[test]
373 fn missing_or_malformed_database_has_no_context() {
374 let (_temp, project) = project_with_database(None);
375 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
376 assert!(
377 CppCompileContexts::load(&project)
378 .contexts_for(&file)
379 .is_empty()
380 );
381
382 let (_temp, project) = project_with_database(Some("not json"));
383 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
384 assert!(
385 CppCompileContexts::load(&project)
386 .contexts_for(&file)
387 .is_empty()
388 );
389 }
390
391 #[test]
392 fn arguments_entry_collects_include_paths_and_macro_names() {
393 let (_temp, project) = project_with_database(Some(
394 r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-iquotequotes","-isystem","system-include","-DDEBUG=1","-D","FEATURE","-c","src/main.cpp"]}]"#,
395 ));
396 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
397 let contexts = CppCompileContexts::load(&project);
398 let [context] = contexts.contexts_for(&file) else {
399 panic!("one matching context");
400 };
401
402 assert_eq!(
403 vec![
404 project.root_path().join("include"),
405 project.root_path().join("quotes"),
406 ],
407 context.project_include_roots
408 );
409 assert_eq!(
410 vec![project.root_path().join("system-include")],
411 context.system_include_roots
412 );
413 assert!(context.defined_macros.contains("DEBUG"));
414 assert!(context.defined_macros.contains("FEATURE"));
415 }
416
417 #[test]
418 fn quoted_command_entry_is_tokenized_without_executing_it() {
419 let (_temp, project) = project_with_database(Some(
420 r#"[{"directory":".","file":"src/main.cpp","command":"clang++ -I 'project include' -DNAME=\\\"two words\\\" -c src/main.cpp"}]"#,
421 ));
422 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
423 let contexts = CppCompileContexts::load(&project);
424 let [context] = contexts.contexts_for(&file) else {
425 panic!("one matching context");
426 };
427
428 assert_eq!(
429 vec![project.root_path().join("project include")],
430 context.project_include_roots
431 );
432 assert!(context.defined_macros.contains("NAME"));
433 }
434
435 #[test]
436 fn a_file_compiled_in_two_configurations_keeps_both() {
437 let (_temp, project) = project_with_database(Some(
438 r#"[
439 {"directory":".","file":"src/main.cpp","arguments":["clang++","-c","src/main.cpp"]},
440 {"directory":".","file":"src/main.cpp","arguments":["clang++","-DOTHER","-c","src/main.cpp"]},
441 {"directory":".","file":"src/other.cpp","arguments":["clang++","-c","src/other.cpp"]}
442 ]"#,
443 ));
444 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
445 let contexts = CppCompileContexts::load(&project);
446 let candidates = contexts.contexts_for(&file);
447
448 assert_eq!(2, candidates.len());
451 assert!(candidates[0].defined_macros.is_empty());
452 assert!(candidates[1].defined_macros.contains("OTHER"));
453 }
454
455 #[test]
456 fn repeated_identical_entries_are_one_configuration() {
457 let (_temp, project) = project_with_database(Some(
458 r#"[
459 {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]},
460 {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]}
461 ]"#,
462 ));
463 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
464 let contexts = CppCompileContexts::load(&project);
465
466 assert_eq!(1, contexts.contexts_for(&file).len());
469 }
470
471 #[test]
472 fn an_unmatched_file_has_no_context() {
473 let (_temp, project) = project_with_database(Some(
474 r#"[{"directory":".","file":"src/other.cpp","arguments":["clang++","-c","src/other.cpp"]}]"#,
475 ));
476 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
477 assert!(
478 CppCompileContexts::load(&project)
479 .contexts_for(&file)
480 .is_empty()
481 );
482 }
483
484 #[test]
485 fn an_explicit_system_root_declares_an_angle_include() {
486 let (_temp, project) = project_with_database(Some(
487 r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","fake-system/include","-c","src/main.cpp"]}]"#,
488 ));
489 let header = ProjectFile::new(
490 project.root_path().to_path_buf(),
491 "fake-system/include/vector",
492 );
493 header
494 .write("namespace std { class vector {}; }")
495 .expect("header");
496 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
497
498 assert_eq!(
499 CppExternalIncludeResolution::Declared {
500 root: project
501 .root_path()
502 .join("fake-system/include")
503 .canonicalize()
504 .expect("canonical root"),
505 header: header.abs_path().canonicalize().expect("canonical header"),
506 },
507 CppCompileContexts::load(&project)
508 .resolve_external_angle_include(&file, std::path::Path::new("vector"))
509 );
510 }
511
512 #[test]
513 fn an_external_project_root_declares_but_a_workspace_project_root_does_not() {
514 let external = tempfile::tempdir().expect("external root");
515 let external_root = external
516 .path()
517 .canonicalize()
518 .expect("canonical external root");
519 std::fs::write(external_root.join("vendor.hpp"), "class Vendor {};")
520 .expect("external header");
521 let database = format!(
522 r#"[{{"directory":".","file":"src/main.cpp","arguments":["clang++","-I","{}","-I","include","-c","src/main.cpp"]}}]"#,
523 external_root.display()
524 );
525 let (_temp, project) = project_with_database(Some(&database));
526 ProjectFile::new(project.root_path().to_path_buf(), "include/local.hpp")
527 .write("class Local {};")
528 .expect("local header");
529 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
530 let contexts = CppCompileContexts::load(&project);
531
532 assert!(matches!(
533 contexts.resolve_external_angle_include(&file, std::path::Path::new("vendor.hpp")),
534 CppExternalIncludeResolution::Declared { .. }
535 ));
536 assert_eq!(
537 CppExternalIncludeResolution::Undeclared,
538 contexts.resolve_external_angle_include(&file, std::path::Path::new("local.hpp"))
539 );
540 }
541
542 #[test]
543 fn configurations_must_agree_on_the_external_header() {
544 let first = tempfile::tempdir().expect("first root");
545 let second = tempfile::tempdir().expect("second root");
546 let first = first.path().canonicalize().expect("canonical first root");
547 let second = second.path().canonicalize().expect("canonical second root");
548 std::fs::write(first.join("vector"), "namespace std { class vector {}; }")
549 .expect("first header");
550 std::fs::write(second.join("vector"), "namespace std { class vector {}; }")
551 .expect("second header");
552 let database = format!(
553 r#"[
554 {{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","{}","-c","src/main.cpp"]}},
555 {{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","{}","-c","src/main.cpp"]}}
556 ]"#,
557 first.display(),
558 second.display()
559 );
560 let (_temp, project) = project_with_database(Some(&database));
561 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
562
563 assert_eq!(
564 CppExternalIncludeResolution::Conflicting,
565 CppCompileContexts::load(&project)
566 .resolve_external_angle_include(&file, std::path::Path::new("vector"))
567 );
568 }
569
570 #[test]
571 fn external_include_cannot_escape_its_declared_root() {
572 let external = tempfile::tempdir().expect("external root");
573 let root = external.path().canonicalize().expect("canonical root");
574 std::fs::create_dir_all(root.join("include")).expect("include directory");
575 std::fs::write(root.join("outside.hpp"), "class Outside {};").expect("outside header");
576 let database = format!(
577 r#"[{{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","{}","-c","src/main.cpp"]}}]"#,
578 root.join("include").display()
579 );
580 let (_temp, project) = project_with_database(Some(&database));
581 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
582
583 assert_eq!(
584 CppExternalIncludeResolution::Undeclared,
585 CppCompileContexts::load(&project)
586 .resolve_external_angle_include(&file, std::path::Path::new("../outside.hpp"))
587 );
588 assert_eq!(
589 CppExternalIncludeResolution::Undeclared,
590 CppCompileContexts::load(&project)
591 .resolve_external_angle_include(&file, &root.join("outside.hpp"))
592 );
593 }
594
595 #[test]
596 fn msvc_project_and_system_include_flags_preserve_search_order() {
597 let (_temp, project) = project_with_database(Some(
598 r#"[{"directory":".","file":"src/main.cpp","arguments":["cl.exe","/I","vendor/include","/external:Ifake-system/include","/imsvc","toolchain/include","/c","src/main.cpp"]}]"#,
599 ));
600 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
601 let contexts = CppCompileContexts::load(&project);
602 let [context] = contexts.contexts_for(&file) else {
603 panic!("one MSVC compile context");
604 };
605
606 assert_eq!(1, context.project_include_roots.len());
607 assert_eq!(2, context.system_include_roots.len());
608 }
609}