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::collections::BTreeSet;
13use std::ffi::OsString;
14use std::path::{Component, Path, PathBuf};
15
16const COMPILATION_DATABASE_PATH: &str = "compile_commands.json";
17
18const MAX_MISSING_WORKSPACE_SOURCE_SAMPLE: usize = 32;
23
24pub fn is_cpp_compile_context_input(file: &ProjectFile) -> bool {
27 file.rel_path() == Path::new(COMPILATION_DATABASE_PATH)
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct CppCompileContext {
36 pub project_include_roots: Vec<PathBuf>,
37 pub system_include_roots: Vec<PathBuf>,
38 pub forced_includes: Vec<PathBuf>,
39 pub defined_macros: HashSet<String>,
40 include_search_roots: Vec<CppIncludeSearchRoot>,
41 driver_basename: Option<String>,
45 explicit_language: Option<CompiledLanguage>,
48 std_language: Option<CompiledLanguage>,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum CompiledLanguage {
58 C,
59 Cpp,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68enum CppIncludeSearchRootKind {
69 Project,
70 Quote,
71 System,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75struct CppIncludeSearchRoot {
76 path: PathBuf,
77 kind: CppIncludeSearchRootKind,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum CppExternalIncludeResolution {
83 MissingCompileContext,
85 Undeclared,
87 Conflicting,
89 Declared { root: PathBuf, header: PathBuf },
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Eq)]
103pub struct CppCompileDatabaseCoverage {
104 missing_workspace_source_count: usize,
105 missing_workspace_source_sample: Vec<PathBuf>,
106}
107
108impl CppCompileDatabaseCoverage {
109 pub fn missing_workspace_source_count(&self) -> usize {
112 self.missing_workspace_source_count
113 }
114
115 pub fn missing_workspace_source_sample(&self) -> &[PathBuf] {
117 &self.missing_workspace_source_sample
118 }
119}
120
121#[derive(Debug, Default)]
122pub struct CppCompileContexts {
123 by_source: HashMap<PathBuf, Vec<CppCompileContext>>,
124 database_sources: HashSet<PathBuf>,
130}
131
132impl CppCompileContexts {
133 pub fn load(project: &dyn Project) -> Self {
134 let workspace_root = canonical_or_normalized_path(project.root());
135 let database_path = workspace_root.join(COMPILATION_DATABASE_PATH);
136 let Ok(database) = std::fs::read_to_string(database_path) else {
137 return Self::default();
138 };
139 let Ok(entries) = serde_json::from_str::<Vec<CompilationDatabaseEntry>>(&database) else {
140 return Self::default();
141 };
142
143 let mut by_source: HashMap<PathBuf, Vec<CppCompileContext>> = HashMap::default();
144 let mut database_sources = HashSet::default();
145 for entry in entries {
146 let Some(source) = entry.source_path(&workspace_root) else {
147 continue;
148 };
149 let source_identity = canonical_or_normalized_path(&source);
150 if source_identity.strip_prefix(&workspace_root).is_err() {
151 continue;
152 }
153 database_sources.insert(source_identity.clone());
154 let Some(context) = entry.compile_context(&workspace_root) else {
155 continue;
156 };
157 let candidates = by_source.entry(source_identity).or_default();
163 if !candidates.contains(&context) {
164 candidates.push(context);
165 }
166 }
167 Self {
168 by_source,
169 database_sources,
170 }
171 }
172
173 pub fn missing_workspace_sources<'a>(
183 &self,
184 workspace_root: &Path,
185 workspace_files: impl IntoIterator<Item = &'a ProjectFile>,
186 ) -> CppCompileDatabaseCoverage {
187 let workspace_root = canonical_or_normalized_path(workspace_root);
188 let listed_sources = workspace_files
189 .into_iter()
190 .map(|file| canonical_or_normalized_path(&file.abs_path()))
191 .collect::<HashSet<_>>();
192 let mut missing_count = 0usize;
193 let mut sample = BTreeSet::new();
194 for source in &self.database_sources {
195 let Some(relative) = (|| {
196 let relative = source.strip_prefix(&workspace_root).ok()?;
197 (!relative.as_os_str().is_empty() && !listed_sources.contains(source))
198 .then(|| relative.to_path_buf().normalize())
199 })() else {
200 continue;
201 };
202 missing_count = missing_count.saturating_add(1);
203 sample.insert(relative);
204 if sample.len() > MAX_MISSING_WORKSPACE_SOURCE_SAMPLE {
205 sample.pop_last();
206 }
207 }
208 CppCompileDatabaseCoverage {
209 missing_workspace_source_count: missing_count,
210 missing_workspace_source_sample: sample.into_iter().collect(),
211 }
212 }
213
214 pub fn contexts_for(&self, file: &ProjectFile) -> &[CppCompileContext] {
221 self.by_source
222 .get(&canonical_or_normalized_path(&file.abs_path()))
223 .map_or(&[], Vec::as_slice)
224 }
225
226 pub fn resolve_external_angle_include(
234 &self,
235 file: &ProjectFile,
236 include: &Path,
237 ) -> CppExternalIncludeResolution {
238 let contexts = self.contexts_for(file);
239 let Some(first_context) = contexts.first() else {
240 return CppExternalIncludeResolution::MissingCompileContext;
241 };
242 let first = first_context.resolve_external_angle_include(file.root(), include);
243 if contexts
244 .iter()
245 .skip(1)
246 .any(|context| context.resolve_external_angle_include(file.root(), include) != first)
247 {
248 return CppExternalIncludeResolution::Conflicting;
249 }
250 match first {
251 Some((root, header)) => CppExternalIncludeResolution::Declared { root, header },
252 None => CppExternalIncludeResolution::Undeclared,
253 }
254 }
255
256 pub fn external_angle_include_roots(&self, workspace_root: &Path) -> Vec<PathBuf> {
263 let mut roots = self
264 .by_source
265 .values()
266 .flatten()
267 .flat_map(|context| context.external_angle_include_roots(workspace_root))
268 .map(Path::to_path_buf)
269 .collect::<Vec<_>>();
270 roots.sort();
271 roots.dedup();
272 roots
273 }
274}
275
276impl CppCompileContext {
277 pub fn tu_language(&self, source_path: &Path) -> CompiledLanguage {
289 if let Some(language) = self.explicit_language {
290 return language;
291 }
292 if let Some(language) = self.std_language {
293 return language;
294 }
295 if self
296 .driver_basename
297 .as_deref()
298 .is_some_and(|driver| driver.ends_with("++"))
299 {
300 return CompiledLanguage::Cpp;
301 }
302 if source_path
303 .extension()
304 .and_then(|extension| extension.to_str())
305 == Some("c")
306 {
307 CompiledLanguage::C
308 } else {
309 CompiledLanguage::Cpp
310 }
311 }
312
313 pub fn external_angle_include_roots<'a>(
315 &'a self,
316 workspace_root: &'a Path,
317 ) -> impl Iterator<Item = &'a Path> + 'a {
318 self.include_search_roots.iter().filter_map(move |root| {
319 (root.kind != CppIncludeSearchRootKind::Quote
320 && (root.kind == CppIncludeSearchRootKind::System
321 || !root.path.starts_with(workspace_root)))
322 .then_some(root.path.as_path())
323 })
324 }
325
326 fn resolve_external_angle_include(
327 &self,
328 workspace_root: &Path,
329 include: &Path,
330 ) -> Option<(PathBuf, PathBuf)> {
331 if include.is_absolute()
332 || include
333 .components()
334 .any(|component| !matches!(component, Component::Normal(_)))
335 {
336 return None;
337 }
338 self.external_angle_include_roots(workspace_root)
339 .filter_map(|root| {
340 let root = root.canonicalize().ok()?;
341 let candidate = root.join(include).canonicalize().ok()?;
342 (candidate.starts_with(&root) && candidate.is_file()).then_some((root, candidate))
343 })
344 .next()
345 }
346}
347
348#[derive(Debug, Deserialize)]
349struct CompilationDatabaseEntry {
350 directory: PathBuf,
351 file: PathBuf,
352 arguments: Option<Vec<String>>,
353 command: Option<String>,
354}
355
356impl CompilationDatabaseEntry {
357 fn source_path(&self, workspace_root: &Path) -> Option<PathBuf> {
358 absolute_path(
359 &command_directory(workspace_root, &self.directory)?,
360 &self.file,
361 )
362 }
363
364 fn compile_context(&self, workspace_root: &Path) -> Option<CppCompileContext> {
365 let arguments = match &self.arguments {
366 Some(arguments) if !arguments.is_empty() => arguments.clone(),
367 Some(_) => return None,
368 None => shlex::split(self.command.as_deref()?)?,
369 };
370 parse_compile_arguments(
371 &command_directory(workspace_root, &self.directory)?,
372 &arguments,
373 )
374 }
375}
376
377fn command_directory(workspace_root: &Path, directory: &Path) -> Option<PathBuf> {
378 absolute_path(workspace_root, directory)
379}
380
381fn parse_compile_arguments(directory: &Path, arguments: &[String]) -> Option<CppCompileContext> {
382 if arguments.is_empty() {
383 return None;
384 }
385
386 let driver_basename = driver_basename(&arguments[0]);
387 let mut project_include_roots = Vec::new();
388 let mut system_include_roots = Vec::new();
389 let mut forced_includes = Vec::new();
390 let mut defined_macros = HashSet::default();
391 let mut include_search_roots = Vec::new();
392 let mut explicit_language = None;
393 let mut std_language = None;
394 let mut index = 1;
395 while index < arguments.len() {
396 let argument = &arguments[index];
397 match argument.as_str() {
398 "-x" => {
399 if let Some(language) = language_from_x_value(arguments.get(index + 1)?) {
400 explicit_language = Some(language);
401 }
402 index += 2;
403 }
404 "/TC" => {
405 explicit_language = Some(CompiledLanguage::C);
406 index += 1;
407 }
408 "/TP" => {
409 explicit_language = Some(CompiledLanguage::Cpp);
410 index += 1;
411 }
412 "-I" | "/I" => {
413 let path = argument_path(directory, arguments.get(index + 1)?)?;
414 project_include_roots.push(path.clone());
415 include_search_roots.push(CppIncludeSearchRoot {
416 path,
417 kind: CppIncludeSearchRootKind::Project,
418 });
419 index += 2;
420 }
421 "-iquote" => {
422 let path = argument_path(directory, arguments.get(index + 1)?)?;
423 project_include_roots.push(path.clone());
424 include_search_roots.push(CppIncludeSearchRoot {
425 path,
426 kind: CppIncludeSearchRootKind::Quote,
427 });
428 index += 2;
429 }
430 "-isystem" | "/external:I" | "/imsvc" => {
431 let path = argument_path(directory, arguments.get(index + 1)?)?;
432 system_include_roots.push(path.clone());
433 include_search_roots.push(CppIncludeSearchRoot {
434 path,
435 kind: CppIncludeSearchRootKind::System,
436 });
437 index += 2;
438 }
439 "-include" => {
440 forced_includes.push(argument_path(directory, arguments.get(index + 1)?)?);
441 index += 2;
442 }
443 "-D" => {
444 defined_macros.insert(macro_name(arguments.get(index + 1)?)?);
445 index += 2;
446 }
447 "-U" => {
452 defined_macros.remove(¯o_name(arguments.get(index + 1)?)?);
453 index += 2;
454 }
455 _ => {
456 if let Some(path) = argument
457 .strip_prefix("/external:I")
458 .or_else(|| argument.strip_prefix("/imsvc"))
459 {
460 let path = argument_path(directory, path)?;
461 system_include_roots.push(path.clone());
462 include_search_roots.push(CppIncludeSearchRoot {
463 path,
464 kind: CppIncludeSearchRootKind::System,
465 });
466 } else if let Some(path) = argument
467 .strip_prefix("-I")
468 .or_else(|| argument.strip_prefix("/I"))
469 {
470 let path = argument_path(directory, path)?;
471 project_include_roots.push(path.clone());
472 include_search_roots.push(CppIncludeSearchRoot {
473 path,
474 kind: CppIncludeSearchRootKind::Project,
475 });
476 } else if let Some(path) = argument.strip_prefix("-iquote") {
477 let path = argument_path(directory, path)?;
478 project_include_roots.push(path.clone());
479 include_search_roots.push(CppIncludeSearchRoot {
480 path,
481 kind: CppIncludeSearchRootKind::Quote,
482 });
483 } else if let Some(path) = argument.strip_prefix("-isystem") {
484 let path = argument_path(directory, path)?;
485 system_include_roots.push(path.clone());
486 include_search_roots.push(CppIncludeSearchRoot {
487 path,
488 kind: CppIncludeSearchRootKind::System,
489 });
490 } else if let Some(definition) = argument.strip_prefix("-D") {
491 defined_macros.insert(macro_name(definition)?);
492 } else if let Some(name) = argument.strip_prefix("-U") {
493 defined_macros.remove(¯o_name(name)?);
494 } else if let Some(value) = argument.strip_prefix("-std=") {
495 std_language = Some(if value.contains("++") {
496 CompiledLanguage::Cpp
497 } else {
498 CompiledLanguage::C
499 });
500 } else if let Some(value) = argument.strip_prefix("-x")
501 && let Some(language) = language_from_x_value(value)
502 {
503 explicit_language = Some(language);
504 }
505 index += 1;
506 }
507 }
508 }
509
510 Some(CppCompileContext {
511 project_include_roots,
512 system_include_roots,
513 forced_includes,
514 defined_macros,
515 include_search_roots,
516 driver_basename,
517 explicit_language,
518 std_language,
519 })
520}
521
522fn driver_basename(driver: &str) -> Option<String> {
527 Path::new(driver)
528 .file_stem()
529 .and_then(|stem| stem.to_str())
530 .map(str::to_owned)
531}
532
533fn language_from_x_value(value: &str) -> Option<CompiledLanguage> {
537 let family = value
538 .strip_suffix("-header")
539 .or_else(|| value.strip_suffix("-cpp-output"))
540 .unwrap_or(value);
541 match family {
542 "c" => Some(CompiledLanguage::C),
543 "c++" => Some(CompiledLanguage::Cpp),
544 _ => None,
545 }
546}
547
548fn argument_path(directory: &Path, raw: &str) -> Option<PathBuf> {
549 if raw.is_empty() {
550 return None;
551 }
552 absolute_path(directory, Path::new(raw))
553}
554
555fn absolute_path(directory: &Path, path: &Path) -> Option<PathBuf> {
556 let path = if path.is_absolute() {
557 path.to_path_buf()
558 } else {
559 directory.join(path)
560 }
561 .normalize();
562 path.is_absolute().then_some(path)
563}
564
565fn canonical_or_normalized_path(path: &Path) -> PathBuf {
571 let path = path.to_path_buf().normalize();
572 let mut missing_tail: Vec<OsString> = Vec::new();
573 let mut current = path.as_path();
574 loop {
575 if let Ok(canonical) = current.canonicalize() {
576 let mut result = canonical.normalize();
577 for component in missing_tail.iter().rev() {
578 result.push(component);
579 }
580 return result.normalize();
581 }
582 let Some(name) = current.file_name() else {
583 return path;
584 };
585 missing_tail.push(name.to_owned());
586 let Some(parent) = current.parent() else {
587 return path;
588 };
589 current = parent;
590 }
591}
592
593fn macro_name(definition: &str) -> Option<String> {
594 let end = definition.find('=').unwrap_or(definition.len());
595 let name = &definition[..end];
596 (!name.is_empty()).then(|| name.to_string())
597}
598
599#[cfg(test)]
600mod tests {
601 use super::{CompiledLanguage, CppCompileContexts, CppExternalIncludeResolution};
602 use brokk_bifrost_core::analyzer::project::{FilesystemProject, Project, TestProject};
603 use brokk_bifrost_core::analyzer::{Language, ProjectFile};
604 use std::path::{Path, PathBuf};
605
606 fn context(arguments: &[&str]) -> super::CppCompileContext {
610 let arguments = arguments
611 .iter()
612 .map(|argument| argument.to_string())
613 .collect::<Vec<_>>();
614 super::parse_compile_arguments(Path::new("/workspace"), &arguments)
615 .expect("non-empty argument vector parses")
616 }
617
618 fn project_with_database(database: Option<&str>) -> (tempfile::TempDir, TestProject) {
619 let temp = tempfile::tempdir().expect("temp dir");
620 let root = temp.path().canonicalize().expect("canonical root");
621 ProjectFile::new(root.clone(), "src/main.cpp")
622 .write("int main() { return 0; }")
623 .expect("source");
624 if let Some(database) = database {
625 ProjectFile::new(root.clone(), "compile_commands.json")
626 .write(database)
627 .expect("database");
628 }
629 (temp, TestProject::new(root, Language::Cpp))
630 }
631
632 #[test]
633 fn missing_or_malformed_database_has_no_context() {
634 let (_temp, project) = project_with_database(None);
635 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
636 assert!(
637 CppCompileContexts::load(&project)
638 .contexts_for(&file)
639 .is_empty()
640 );
641
642 let (_temp, project) = project_with_database(Some("not json"));
643 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
644 assert!(
645 CppCompileContexts::load(&project)
646 .contexts_for(&file)
647 .is_empty()
648 );
649 }
650
651 #[test]
652 fn arguments_entry_collects_include_paths_and_macro_names() {
653 let (_temp, project) = project_with_database(Some(
654 r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-iquotequotes","-isystem","system-include","-DDEBUG=1","-D","FEATURE","-c","src/main.cpp"]}]"#,
655 ));
656 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
657 let contexts = CppCompileContexts::load(&project);
658 let [context] = contexts.contexts_for(&file) else {
659 panic!("one matching context");
660 };
661
662 assert_eq!(
663 vec![
664 project.root_path().join("include"),
665 project.root_path().join("quotes"),
666 ],
667 context.project_include_roots
668 );
669 assert_eq!(
670 vec![project.root_path().join("system-include")],
671 context.system_include_roots
672 );
673 assert!(context.defined_macros.contains("DEBUG"));
674 assert!(context.defined_macros.contains("FEATURE"));
675 }
676
677 #[test]
678 fn quoted_command_entry_is_tokenized_without_executing_it() {
679 let (_temp, project) = project_with_database(Some(
680 r#"[{"directory":".","file":"src/main.cpp","command":"clang++ -I 'project include' -DNAME=\\\"two words\\\" -c src/main.cpp"}]"#,
681 ));
682 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
683 let contexts = CppCompileContexts::load(&project);
684 let [context] = contexts.contexts_for(&file) else {
685 panic!("one matching context");
686 };
687
688 assert_eq!(
689 vec![project.root_path().join("project include")],
690 context.project_include_roots
691 );
692 assert!(context.defined_macros.contains("NAME"));
693 }
694
695 #[test]
696 fn undefine_flags_apply_in_command_order() {
697 let context = context(&[
698 "cc",
699 "-DKEPT",
700 "-DCANCELLED",
701 "-U",
702 "CANCELLED",
703 "-UREVIVED",
704 "-DREVIVED",
705 "-UNEVER_DEFINED",
706 ]);
707 assert!(context.defined_macros.contains("KEPT"));
708 assert!(context.defined_macros.contains("REVIVED"));
709 assert!(!context.defined_macros.contains("CANCELLED"));
710 assert!(!context.defined_macros.contains("NEVER_DEFINED"));
711 }
712
713 #[test]
714 fn a_file_compiled_in_two_configurations_keeps_both() {
715 let (_temp, project) = project_with_database(Some(
716 r#"[
717 {"directory":".","file":"src/main.cpp","arguments":["clang++","-c","src/main.cpp"]},
718 {"directory":".","file":"src/main.cpp","arguments":["clang++","-DOTHER","-c","src/main.cpp"]},
719 {"directory":".","file":"src/other.cpp","arguments":["clang++","-c","src/other.cpp"]}
720 ]"#,
721 ));
722 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
723 let contexts = CppCompileContexts::load(&project);
724 let candidates = contexts.contexts_for(&file);
725
726 assert_eq!(2, candidates.len());
729 assert!(candidates[0].defined_macros.is_empty());
730 assert!(candidates[1].defined_macros.contains("OTHER"));
731 }
732
733 #[test]
734 fn repeated_identical_entries_are_one_configuration() {
735 let (_temp, project) = project_with_database(Some(
736 r#"[
737 {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]},
738 {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]}
739 ]"#,
740 ));
741 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
742 let contexts = CppCompileContexts::load(&project);
743
744 assert_eq!(1, contexts.contexts_for(&file).len());
747 }
748
749 #[test]
750 fn missing_workspace_sources_are_canonical_bounded_and_deterministic() {
751 let (_temp, project) = project_with_database(None);
752 let root = project.root_path().to_path_buf();
753 ProjectFile::new(root.clone(), "src/present.cpp")
754 .write("int present() { return 0; }")
755 .expect("present source");
756 ProjectFile::new(root.clone(), "src/present.c")
757 .write("int present_c(void) { return 0; }")
758 .expect("present C source");
759 ProjectFile::new(root.clone(), "include/present.hin")
760 .write("int generated_declaration(void);")
761 .expect("present C header template");
762
763 let mut entries = vec![serde_json::json!({
764 "directory": ".",
765 "file": "src/./present.cpp",
766 "arguments": ["clang++", "-c", "src/present.cpp"]
767 })];
768 entries.push(serde_json::json!({
769 "directory": ".",
770 "file": "src/present.c",
771 "arguments": ["clang", "-c", "src/present.c"]
772 }));
773 entries.push(serde_json::json!({
774 "directory": ".",
775 "file": "include/present.hin",
776 "arguments": ["clang", "-x", "c-header", "include/present.hin"]
777 }));
778 entries.extend(
779 (0..(super::MAX_MISSING_WORKSPACE_SOURCE_SAMPLE + 3)).map(|index| {
780 serde_json::json!({
781 "directory": ".",
782 "file": format!("generated/./unit-{index:02}.cpp"),
783 "arguments": ["clang++", "-c", format!("generated/unit-{index:02}.cpp")]
784 })
785 }),
786 );
787 entries.push(serde_json::json!({
790 "directory": "generated",
791 "file": "../generated/unit-00.cpp",
792 "arguments": ["clang++", "-c", "../generated/unit-00.cpp"]
793 }));
794 ProjectFile::new(root.clone(), "compile_commands.json")
795 .write(serde_json::to_string(&entries).expect("database JSON"))
796 .expect("database");
797
798 let contexts = CppCompileContexts::load(&project);
799 let listed = project
800 .analyzable_files(Language::Cpp)
801 .expect("C++ listing");
802 let coverage = contexts.missing_workspace_sources(&root, &listed);
803
804 assert_eq!(
805 super::MAX_MISSING_WORKSPACE_SOURCE_SAMPLE + 3,
806 coverage.missing_workspace_source_count()
807 );
808 assert_eq!(
809 (0..super::MAX_MISSING_WORKSPACE_SOURCE_SAMPLE)
810 .map(|index| Path::new("generated").join(format!("unit-{index:02}.cpp")))
811 .collect::<Vec<_>>(),
812 coverage.missing_workspace_source_sample()
813 );
814 assert!(
815 !coverage
816 .missing_workspace_source_sample()
817 .contains(&PathBuf::from("src/present.cpp"))
818 );
819
820 let reversed = entries.into_iter().rev().collect::<Vec<_>>();
821 ProjectFile::new(root.clone(), "compile_commands.json")
822 .write(serde_json::to_string(&reversed).expect("reversed database JSON"))
823 .expect("reversed database");
824 let reversed_coverage =
825 CppCompileContexts::load(&project).missing_workspace_sources(&root, &listed);
826 assert_eq!(coverage, reversed_coverage);
827 }
828
829 #[test]
830 fn missing_workspace_sources_excludes_outside_root_and_ignored_files() {
831 let temp = tempfile::tempdir().expect("workspace root");
832 let root = temp.path().canonicalize().expect("canonical root");
833 let outside = tempfile::tempdir().expect("outside root");
834 let outside_source = outside
835 .path()
836 .canonicalize()
837 .expect("canonical outside root")
838 .join("outside.cpp")
839 .to_string_lossy()
840 .into_owned();
841 ProjectFile::new(root.clone(), "src/present.cpp")
842 .write("int present() { return 0; }")
843 .expect("present source");
844 ProjectFile::new(root.clone(), "ignored.cpp")
845 .write("int ignored() { return 0; }")
846 .expect("ignored source");
847 ProjectFile::new(root.clone(), ".bifrostignore")
848 .write("ignored.cpp\n")
849 .expect("ignore file");
850 let database = serde_json::json!([
851 {
852 "directory": ".",
853 "file": "src/../src/present.cpp",
854 "arguments": ["clang++", "-c", "src/present.cpp"]
855 },
856 {
857 "directory": ".",
858 "file": "ignored.cpp",
859 "arguments": ["clang++", "-c", "ignored.cpp"]
860 },
861 {
862 "directory": ".",
863 "file": outside_source.clone(),
864 "arguments": ["clang++", "-c", outside_source]
865 }
866 ]);
867 ProjectFile::new(root.clone(), "compile_commands.json")
868 .write(database.to_string())
869 .expect("database");
870 let project = FilesystemProject::new(root.clone()).expect("filesystem project");
871 let listed = project
872 .analyzable_files(Language::Cpp)
873 .expect("C++ listing");
874 assert!(
875 listed
876 .iter()
877 .any(|file| file.rel_path() == Path::new("src/present.cpp"))
878 );
879 assert!(
880 !listed
881 .iter()
882 .any(|file| file.rel_path() == Path::new("ignored.cpp"))
883 );
884
885 let coverage = CppCompileContexts::load(&project).missing_workspace_sources(&root, &listed);
886 assert_eq!(1, coverage.missing_workspace_source_count());
887 assert_eq!(
888 &[PathBuf::from("ignored.cpp")],
889 coverage.missing_workspace_source_sample()
890 );
891 }
892
893 #[test]
894 fn an_unmatched_file_has_no_context() {
895 let (_temp, project) = project_with_database(Some(
896 r#"[{"directory":".","file":"src/other.cpp","arguments":["clang++","-c","src/other.cpp"]}]"#,
897 ));
898 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
899 assert!(
900 CppCompileContexts::load(&project)
901 .contexts_for(&file)
902 .is_empty()
903 );
904 }
905
906 #[test]
907 fn an_explicit_system_root_declares_an_angle_include() {
908 let (_temp, project) = project_with_database(Some(
909 r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","fake-system/include","-c","src/main.cpp"]}]"#,
910 ));
911 let header = ProjectFile::new(
912 project.root_path().to_path_buf(),
913 "fake-system/include/vector",
914 );
915 header
916 .write("namespace std { class vector {}; }")
917 .expect("header");
918 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
919
920 assert_eq!(
921 CppExternalIncludeResolution::Declared {
922 root: project
923 .root_path()
924 .join("fake-system/include")
925 .canonicalize()
926 .expect("canonical root"),
927 header: header.abs_path().canonicalize().expect("canonical header"),
928 },
929 CppCompileContexts::load(&project)
930 .resolve_external_angle_include(&file, std::path::Path::new("vector"))
931 );
932 }
933
934 #[test]
935 fn an_external_project_root_declares_but_a_workspace_project_root_does_not() {
936 let external = tempfile::tempdir().expect("external root");
937 let external_root = external
938 .path()
939 .canonicalize()
940 .expect("canonical external root");
941 std::fs::write(external_root.join("vendor.hpp"), "class Vendor {};")
942 .expect("external header");
943 let database = serde_json::json!([{
944 "directory": ".",
945 "file": "src/main.cpp",
946 "arguments": [
947 "clang++",
948 "-I",
949 external_root,
950 "-I",
951 "include",
952 "-c",
953 "src/main.cpp"
954 ]
955 }])
956 .to_string();
957 let (_temp, project) = project_with_database(Some(&database));
958 ProjectFile::new(project.root_path().to_path_buf(), "include/local.hpp")
959 .write("class Local {};")
960 .expect("local header");
961 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
962 let contexts = CppCompileContexts::load(&project);
963
964 assert!(matches!(
965 contexts.resolve_external_angle_include(&file, std::path::Path::new("vendor.hpp")),
966 CppExternalIncludeResolution::Declared { .. }
967 ));
968 assert_eq!(
969 CppExternalIncludeResolution::Undeclared,
970 contexts.resolve_external_angle_include(&file, std::path::Path::new("local.hpp"))
971 );
972 }
973
974 #[test]
975 fn configurations_must_agree_on_the_external_header() {
976 let first = tempfile::tempdir().expect("first root");
977 let second = tempfile::tempdir().expect("second root");
978 let first = first.path().canonicalize().expect("canonical first root");
979 let second = second.path().canonicalize().expect("canonical second root");
980 std::fs::write(first.join("vector"), "namespace std { class vector {}; }")
981 .expect("first header");
982 std::fs::write(second.join("vector"), "namespace std { class vector {}; }")
983 .expect("second header");
984 let database = serde_json::json!([
985 {
986 "directory": ".",
987 "file": "src/main.cpp",
988 "arguments": ["clang++", "-isystem", first, "-c", "src/main.cpp"]
989 },
990 {
991 "directory": ".",
992 "file": "src/main.cpp",
993 "arguments": ["clang++", "-isystem", second, "-c", "src/main.cpp"]
994 }
995 ])
996 .to_string();
997 let (_temp, project) = project_with_database(Some(&database));
998 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
999
1000 assert_eq!(
1001 CppExternalIncludeResolution::Conflicting,
1002 CppCompileContexts::load(&project)
1003 .resolve_external_angle_include(&file, std::path::Path::new("vector"))
1004 );
1005 }
1006
1007 #[test]
1008 fn external_include_cannot_escape_its_declared_root() {
1009 let external = tempfile::tempdir().expect("external root");
1010 let root = external.path().canonicalize().expect("canonical root");
1011 std::fs::create_dir_all(root.join("include")).expect("include directory");
1012 std::fs::write(root.join("outside.hpp"), "class Outside {};").expect("outside header");
1013 let database = serde_json::json!([{
1014 "directory": ".",
1015 "file": "src/main.cpp",
1016 "arguments": [
1017 "clang++",
1018 "-isystem",
1019 root.join("include"),
1020 "-c",
1021 "src/main.cpp"
1022 ]
1023 }])
1024 .to_string();
1025 let (_temp, project) = project_with_database(Some(&database));
1026 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1027
1028 assert_eq!(
1029 CppExternalIncludeResolution::Undeclared,
1030 CppCompileContexts::load(&project)
1031 .resolve_external_angle_include(&file, std::path::Path::new("../outside.hpp"))
1032 );
1033 assert_eq!(
1034 CppExternalIncludeResolution::Undeclared,
1035 CppCompileContexts::load(&project)
1036 .resolve_external_angle_include(&file, &root.join("outside.hpp"))
1037 );
1038 }
1039
1040 #[test]
1041 fn msvc_project_and_system_include_flags_preserve_search_order() {
1042 let (_temp, project) = project_with_database(Some(
1043 r#"[{"directory":".","file":"src/main.cpp","arguments":["cl.exe","/I","vendor/include","/external:Ifake-system/include","/imsvc","toolchain/include","/c","src/main.cpp"]}]"#,
1044 ));
1045 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1046 let contexts = CppCompileContexts::load(&project);
1047 let [context] = contexts.contexts_for(&file) else {
1048 panic!("one MSVC compile context");
1049 };
1050
1051 assert_eq!(1, context.project_include_roots.len());
1052 assert_eq!(2, context.system_include_roots.len());
1053 }
1054
1055 #[test]
1056 fn default_by_extension_c_is_c_and_everything_else_is_cpp() {
1057 let cx = context(&["cc", "-c", "file.c"]);
1058 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1059
1060 let cx = context(&["cc", "-c", "file.cc"]);
1061 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cc")));
1062 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.hpp")));
1063 }
1064
1065 #[test]
1066 fn driver_basename_ending_in_plus_plus_selects_cpp_over_a_c_extension() {
1067 let cx = context(&["clang++", "-c", "file.c"]);
1068 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1069
1070 let cx = context(&["arm-none-eabi-gcc", "-c", "file.c"]);
1073 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1074
1075 let cx = context(&["arm-none-eabi-g++", "-c", "file.h"]);
1077 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.h")));
1078 }
1079
1080 #[test]
1081 fn driver_exe_suffix_is_stripped_before_the_plus_plus_check() {
1082 let cx = context(&["clang++.exe", "-c", "file.c"]);
1083 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1084 }
1085
1086 #[test]
1087 fn std_family_outranks_the_driver_basename() {
1088 let cx = context(&["g++", "-std=gnu11", "-c", "file.c"]);
1091 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1092
1093 let cx = context(&["gcc", "-std=c++17", "-c", "file.cc"]);
1094 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cc")));
1095
1096 let cx = context(&["gcc", "-std=c17", "-c", "file.cc"]);
1097 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cc")));
1098
1099 let cx = context(&["gcc", "-std=gnu++20", "-c", "file.c"]);
1100 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1101 }
1102
1103 #[test]
1104 fn explicit_x_split_form_outranks_std_and_driver() {
1105 let cx = context(&["clang", "-x", "c", "-std=c++17", "-c", "file.cpp"]);
1106 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
1107 }
1108
1109 #[test]
1110 fn explicit_x_glued_form_is_recognized() {
1111 let cx = context(&["gcc", "-xc++", "-c", "file.c"]);
1112 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1113
1114 let cx = context(&["g++", "-xc", "-c", "file.cpp"]);
1115 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
1116 }
1117
1118 #[test]
1119 fn explicit_x_on_a_c_file_overrides_the_extension() {
1120 let cx = context(&["gcc", "-x", "c++", "-c", "file.c"]);
1123 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1124 }
1125
1126 #[test]
1127 fn unrecognized_x_value_is_ignored() {
1128 let cx = context(&["gcc", "-x", "assembler", "-c", "file.s"]);
1129 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.s")));
1132 }
1133
1134 #[test]
1135 fn msvc_tc_and_tp_force_the_language_regardless_of_extension() {
1136 let cx = context(&["cl.exe", "/TC", "/c", "file.cpp"]);
1137 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
1138
1139 let cx = context(&["cl.exe", "/TP", "/c", "file.c"]);
1140 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1141 }
1142
1143 #[test]
1144 fn cl_exe_without_tc_or_tp_decides_by_extension() {
1145 let cx = context(&["cl.exe", "/c", "file.c"]);
1146 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1147
1148 let cx = context(&["cl.exe", "/c", "file.cpp"]);
1149 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cpp")));
1150 }
1151}