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