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 pub(crate) cplusplus_macro_configured: bool,
42 include_search_roots: Vec<CppIncludeSearchRoot>,
43 driver_basename: Option<String>,
47 explicit_language: Option<CompiledLanguage>,
50 std_language: Option<CompiledLanguage>,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum CompiledLanguage {
60 C,
61 Cpp,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70enum CppIncludeSearchRootKind {
71 Project,
72 Quote,
73 System,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77struct CppIncludeSearchRoot {
78 path: PathBuf,
79 kind: CppIncludeSearchRootKind,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum CppExternalIncludeResolution {
85 MissingCompileContext,
87 Undeclared,
89 Conflicting,
91 Declared { root: PathBuf, header: PathBuf },
93}
94
95#[derive(Debug, Clone, Default, PartialEq, Eq)]
105pub struct CppCompileDatabaseCoverage {
106 missing_workspace_source_count: usize,
107 missing_workspace_source_sample: Vec<PathBuf>,
108}
109
110impl CppCompileDatabaseCoverage {
111 pub fn missing_workspace_source_count(&self) -> usize {
114 self.missing_workspace_source_count
115 }
116
117 pub fn missing_workspace_source_sample(&self) -> &[PathBuf] {
119 &self.missing_workspace_source_sample
120 }
121}
122
123#[derive(Debug, Default)]
124pub struct CppCompileContexts {
125 by_source: HashMap<PathBuf, Vec<CppCompileContext>>,
126 database_sources: HashSet<PathBuf>,
132}
133
134impl CppCompileContexts {
135 pub fn load(project: &dyn Project) -> Self {
136 Self::load_with_io_error(project, |_| {})
137 }
138
139 pub fn load_with_io_error(
142 project: &dyn Project,
143 on_error: impl FnOnce(&std::io::Error),
144 ) -> Self {
145 let workspace_root = canonical_or_normalized_path(project.root());
146 let database_path = workspace_root.join(COMPILATION_DATABASE_PATH);
147 let database = match std::fs::read_to_string(database_path) {
148 Ok(database) => database,
149 Err(error) => {
150 on_error(&error);
151 return Self::default();
152 }
153 };
154 let Ok(entries) = serde_json::from_str::<Vec<CompilationDatabaseEntry>>(&database) else {
155 return Self::default();
156 };
157
158 let mut by_source: HashMap<PathBuf, Vec<CppCompileContext>> = HashMap::default();
159 let mut database_sources = HashSet::default();
160 for entry in entries {
161 let Some(source) = entry.source_path(&workspace_root) else {
162 continue;
163 };
164 let source_identity = canonical_or_normalized_path(&source);
165 if source_identity.strip_prefix(&workspace_root).is_err() {
166 continue;
167 }
168 database_sources.insert(source_identity.clone());
169 let Some(context) = entry.compile_context(&workspace_root) else {
170 continue;
171 };
172 let candidates = by_source.entry(source_identity).or_default();
178 if !candidates.contains(&context) {
179 candidates.push(context);
180 }
181 }
182 Self {
183 by_source,
184 database_sources,
185 }
186 }
187
188 pub fn missing_workspace_sources<'a>(
198 &self,
199 workspace_root: &Path,
200 workspace_files: impl IntoIterator<Item = &'a ProjectFile>,
201 ) -> CppCompileDatabaseCoverage {
202 let workspace_root = canonical_or_normalized_path(workspace_root);
203 let listed_sources = workspace_files
204 .into_iter()
205 .map(|file| canonical_or_normalized_path(&file.abs_path()))
206 .collect::<HashSet<_>>();
207 let mut missing_count = 0usize;
208 let mut sample = BTreeSet::new();
209 for source in &self.database_sources {
210 let Some(relative) = (|| {
211 let relative = source.strip_prefix(&workspace_root).ok()?;
212 (!relative.as_os_str().is_empty() && !listed_sources.contains(source))
213 .then(|| relative.to_path_buf().normalize())
214 })() else {
215 continue;
216 };
217 missing_count = missing_count.saturating_add(1);
218 sample.insert(relative);
219 if sample.len() > MAX_MISSING_WORKSPACE_SOURCE_SAMPLE {
220 sample.pop_last();
221 }
222 }
223 CppCompileDatabaseCoverage {
224 missing_workspace_source_count: missing_count,
225 missing_workspace_source_sample: sample.into_iter().collect(),
226 }
227 }
228
229 pub fn contexts_for(&self, file: &ProjectFile) -> &[CppCompileContext] {
236 self.by_source
237 .get(&canonical_or_normalized_path(&file.abs_path()))
238 .map_or(&[], Vec::as_slice)
239 }
240
241 pub fn resolve_external_angle_include(
249 &self,
250 file: &ProjectFile,
251 include: &Path,
252 ) -> CppExternalIncludeResolution {
253 self.resolve_external_include(file, &file.abs_path(), include, false)
254 }
255
256 pub fn resolve_external_include(
264 &self,
265 file: &ProjectFile,
266 including_file: &Path,
267 include: &Path,
268 quoted: bool,
269 ) -> CppExternalIncludeResolution {
270 let contexts = self.contexts_for(file);
271 let Some(first_context) = contexts.first() else {
272 return CppExternalIncludeResolution::MissingCompileContext;
273 };
274 let first =
275 first_context.resolve_external_include(file.root(), including_file, include, quoted);
276 if contexts.iter().skip(1).any(|context| {
277 context.resolve_external_include(file.root(), including_file, include, quoted) != first
278 }) {
279 return CppExternalIncludeResolution::Conflicting;
280 }
281 match first {
282 Some((root, header)) => CppExternalIncludeResolution::Declared { root, header },
283 None => CppExternalIncludeResolution::Undeclared,
284 }
285 }
286}
287
288impl CppCompileContext {
289 pub fn tu_language(&self, source_path: &Path) -> CompiledLanguage {
301 if let Some(language) = self.explicit_language {
302 return language;
303 }
304 if let Some(language) = self.std_language {
305 return language;
306 }
307 if self
308 .driver_basename
309 .as_deref()
310 .is_some_and(|driver| driver.ends_with("++"))
311 {
312 return CompiledLanguage::Cpp;
313 }
314 if source_path
315 .extension()
316 .and_then(|extension| extension.to_str())
317 == Some("c")
318 {
319 CompiledLanguage::C
320 } else {
321 CompiledLanguage::Cpp
322 }
323 }
324
325 fn external_include_roots<'a>(
326 &'a self,
327 workspace_root: &'a Path,
328 quoted: bool,
329 ) -> impl Iterator<Item = &'a Path> + 'a {
330 self.include_search_roots.iter().filter_map(move |root| {
331 (quoted || root.kind != CppIncludeSearchRootKind::Quote)
332 .then_some(root)
333 .filter(|root| {
334 root.kind == CppIncludeSearchRootKind::System
335 || !root.path.starts_with(workspace_root)
336 })
337 .map(|root| root.path.as_path())
338 })
339 }
340
341 fn resolve_external_include(
342 &self,
343 workspace_root: &Path,
344 including_file: &Path,
345 include: &Path,
346 quoted: bool,
347 ) -> Option<(PathBuf, PathBuf)> {
348 if include.is_absolute()
349 || (!quoted
350 && include
351 .components()
352 .any(|component| !matches!(component, Component::Normal(_))))
353 || (quoted
354 && include.components().any(|component| {
355 matches!(component, Component::RootDir | Component::Prefix(_))
356 }))
357 {
358 return None;
359 }
360
361 let workspace_root = canonical_or_normalized_path(workspace_root);
362 if quoted {
363 let including_directory = including_file.parent()?;
364 if let Ok(candidate) = including_directory.join(include).canonicalize()
365 && candidate.is_file()
366 {
367 if candidate.clone().normalize().starts_with(&workspace_root) {
370 return None;
371 }
372 if let Some(root) = self
373 .external_include_roots(&workspace_root, true)
374 .filter_map(|root| {
375 let root = root.canonicalize().ok()?;
376 candidate.starts_with(&root).then_some(root)
377 })
378 .next()
379 {
380 return Some((root, candidate));
381 }
382 return None;
383 }
384 }
385
386 self.external_include_roots(&workspace_root, quoted)
387 .filter_map(|root| {
388 let root = root.canonicalize().ok()?;
389 let candidate = root.join(include).canonicalize().ok()?;
390 (candidate.starts_with(&root) && candidate.is_file()).then_some((root, candidate))
391 })
392 .next()
393 }
394}
395
396#[derive(Debug, Deserialize)]
397struct CompilationDatabaseEntry {
398 directory: PathBuf,
399 file: PathBuf,
400 arguments: Option<Vec<String>>,
401 command: Option<String>,
402}
403
404impl CompilationDatabaseEntry {
405 fn source_path(&self, workspace_root: &Path) -> Option<PathBuf> {
406 absolute_path(
407 &command_directory(workspace_root, &self.directory)?,
408 &self.file,
409 )
410 }
411
412 fn compile_context(&self, workspace_root: &Path) -> Option<CppCompileContext> {
413 let arguments = match &self.arguments {
414 Some(arguments) if !arguments.is_empty() => arguments.clone(),
415 Some(_) => return None,
416 None => shlex::split(self.command.as_deref()?)?,
417 };
418 parse_compile_arguments(
419 &command_directory(workspace_root, &self.directory)?,
420 &arguments,
421 )
422 }
423}
424
425fn command_directory(workspace_root: &Path, directory: &Path) -> Option<PathBuf> {
426 absolute_path(workspace_root, directory)
427}
428
429fn parse_compile_arguments(directory: &Path, arguments: &[String]) -> Option<CppCompileContext> {
430 if arguments.is_empty() {
431 return None;
432 }
433
434 let driver_basename = driver_basename(&arguments[0]);
435 let mut project_include_roots = Vec::new();
436 let mut system_include_roots = Vec::new();
437 let mut forced_includes = Vec::new();
438 let mut defined_macros = HashSet::default();
439 let mut cplusplus_macro_configured = false;
440 let mut include_search_roots = Vec::new();
441 let mut explicit_language = None;
442 let mut std_language = None;
443 let mut index = 1;
444 while index < arguments.len() {
445 let argument = &arguments[index];
446 match argument.as_str() {
447 "-x" => {
448 if let Some(language) = language_from_x_value(arguments.get(index + 1)?) {
449 explicit_language = Some(language);
450 }
451 index += 2;
452 }
453 "/TC" => {
454 explicit_language = Some(CompiledLanguage::C);
455 index += 1;
456 }
457 "/TP" => {
458 explicit_language = Some(CompiledLanguage::Cpp);
459 index += 1;
460 }
461 "-I" | "/I" => {
462 let path = argument_path(directory, arguments.get(index + 1)?)?;
463 project_include_roots.push(path.clone());
464 include_search_roots.push(CppIncludeSearchRoot {
465 path,
466 kind: CppIncludeSearchRootKind::Project,
467 });
468 index += 2;
469 }
470 "-iquote" => {
471 let path = argument_path(directory, arguments.get(index + 1)?)?;
472 project_include_roots.push(path.clone());
473 include_search_roots.push(CppIncludeSearchRoot {
474 path,
475 kind: CppIncludeSearchRootKind::Quote,
476 });
477 index += 2;
478 }
479 "-isystem" | "/external:I" | "/imsvc" => {
480 let path = argument_path(directory, arguments.get(index + 1)?)?;
481 system_include_roots.push(path.clone());
482 include_search_roots.push(CppIncludeSearchRoot {
483 path,
484 kind: CppIncludeSearchRootKind::System,
485 });
486 index += 2;
487 }
488 "-include" => {
489 forced_includes.push(argument_path(directory, arguments.get(index + 1)?)?);
490 index += 2;
491 }
492 "-D" => {
493 let name = macro_name(arguments.get(index + 1)?)?;
494 cplusplus_macro_configured |= name == "__cplusplus";
495 defined_macros.insert(name);
496 index += 2;
497 }
498 "-U" => {
503 let name = macro_name(arguments.get(index + 1)?)?;
504 cplusplus_macro_configured |= name == "__cplusplus";
505 defined_macros.remove(&name);
506 index += 2;
507 }
508 _ => {
509 if let Some(path) = argument
510 .strip_prefix("/external:I")
511 .or_else(|| argument.strip_prefix("/imsvc"))
512 {
513 let path = argument_path(directory, path)?;
514 system_include_roots.push(path.clone());
515 include_search_roots.push(CppIncludeSearchRoot {
516 path,
517 kind: CppIncludeSearchRootKind::System,
518 });
519 } else if let Some(path) = argument
520 .strip_prefix("-I")
521 .or_else(|| argument.strip_prefix("/I"))
522 {
523 let path = argument_path(directory, path)?;
524 project_include_roots.push(path.clone());
525 include_search_roots.push(CppIncludeSearchRoot {
526 path,
527 kind: CppIncludeSearchRootKind::Project,
528 });
529 } else if let Some(path) = argument.strip_prefix("-iquote") {
530 let path = argument_path(directory, path)?;
531 project_include_roots.push(path.clone());
532 include_search_roots.push(CppIncludeSearchRoot {
533 path,
534 kind: CppIncludeSearchRootKind::Quote,
535 });
536 } else if let Some(path) = argument.strip_prefix("-isystem") {
537 let path = argument_path(directory, path)?;
538 system_include_roots.push(path.clone());
539 include_search_roots.push(CppIncludeSearchRoot {
540 path,
541 kind: CppIncludeSearchRootKind::System,
542 });
543 } else if let Some(definition) = argument.strip_prefix("-D") {
544 let name = macro_name(definition)?;
545 cplusplus_macro_configured |= name == "__cplusplus";
546 defined_macros.insert(name);
547 } else if let Some(name) = argument.strip_prefix("-U") {
548 let name = macro_name(name)?;
549 cplusplus_macro_configured |= name == "__cplusplus";
550 defined_macros.remove(&name);
551 } else if let Some(value) = argument.strip_prefix("-std=") {
552 std_language = Some(if value.contains("++") {
553 CompiledLanguage::Cpp
554 } else {
555 CompiledLanguage::C
556 });
557 } else if let Some(value) = argument.strip_prefix("-x")
558 && let Some(language) = language_from_x_value(value)
559 {
560 explicit_language = Some(language);
561 }
562 index += 1;
563 }
564 }
565 }
566
567 Some(CppCompileContext {
568 project_include_roots,
569 system_include_roots,
570 forced_includes,
571 defined_macros,
572 cplusplus_macro_configured,
573 include_search_roots,
574 driver_basename,
575 explicit_language,
576 std_language,
577 })
578}
579
580fn driver_basename(driver: &str) -> Option<String> {
585 Path::new(driver)
586 .file_stem()
587 .and_then(|stem| stem.to_str())
588 .map(str::to_owned)
589}
590
591fn language_from_x_value(value: &str) -> Option<CompiledLanguage> {
595 let family = value
596 .strip_suffix("-header")
597 .or_else(|| value.strip_suffix("-cpp-output"))
598 .unwrap_or(value);
599 match family {
600 "c" => Some(CompiledLanguage::C),
601 "c++" => Some(CompiledLanguage::Cpp),
602 _ => None,
603 }
604}
605
606fn argument_path(directory: &Path, raw: &str) -> Option<PathBuf> {
607 if raw.is_empty() {
608 return None;
609 }
610 absolute_path(directory, Path::new(raw))
611}
612
613fn absolute_path(directory: &Path, path: &Path) -> Option<PathBuf> {
614 let path = if path.is_absolute() {
615 path.to_path_buf()
616 } else {
617 directory.join(path)
618 }
619 .normalize();
620 path.is_absolute().then_some(path)
621}
622
623fn canonical_or_normalized_path(path: &Path) -> PathBuf {
629 let path = path.to_path_buf().normalize();
630 let mut missing_tail: Vec<OsString> = Vec::new();
631 let mut current = path.as_path();
632 loop {
633 if let Ok(canonical) = current.canonicalize() {
634 let mut result = canonical.normalize();
635 for component in missing_tail.iter().rev() {
636 result.push(component);
637 }
638 return result.normalize();
639 }
640 let Some(name) = current.file_name() else {
641 return path;
642 };
643 missing_tail.push(name.to_owned());
644 let Some(parent) = current.parent() else {
645 return path;
646 };
647 current = parent;
648 }
649}
650
651fn macro_name(definition: &str) -> Option<String> {
652 let end = definition.find('=').unwrap_or(definition.len());
653 let name = &definition[..end];
654 (!name.is_empty()).then(|| name.to_string())
655}
656
657#[cfg(test)]
658mod tests {
659 use super::{CompiledLanguage, CppCompileContexts, CppExternalIncludeResolution};
660 use brokk_bifrost_core::analyzer::project::{FilesystemProject, Project, TestProject};
661 use brokk_bifrost_core::analyzer::{Language, ProjectFile};
662 use brokk_bifrost_test_macros::hardwon;
663 use std::path::{Path, PathBuf};
664
665 fn context(arguments: &[&str]) -> super::CppCompileContext {
669 let arguments = arguments
670 .iter()
671 .map(|argument| argument.to_string())
672 .collect::<Vec<_>>();
673 super::parse_compile_arguments(Path::new("/workspace"), &arguments)
674 .expect("non-empty argument vector parses")
675 }
676
677 fn project_with_database(database: Option<&str>) -> (tempfile::TempDir, TestProject) {
678 let temp = tempfile::tempdir().expect("temp dir");
679 let root = temp.path().canonicalize().expect("canonical root");
680 ProjectFile::new(root.clone(), "src/main.cpp")
681 .write("int main() { return 0; }")
682 .expect("source");
683 if let Some(database) = database {
684 ProjectFile::new(root.clone(), "compile_commands.json")
685 .write(database)
686 .expect("database");
687 }
688 (temp, TestProject::new(root, Language::Cpp))
689 }
690
691 #[test]
692 fn arguments_entry_collects_include_paths_and_macro_names() {
693 let (_temp, project) = project_with_database(Some(
694 r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-iquotequotes","-isystem","system-include","-DDEBUG=1","-D","FEATURE=a=b","-c","src/main.cpp"]}]"#,
695 ));
696 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
697 let contexts = CppCompileContexts::load(&project);
698 let [context] = contexts.contexts_for(&file) else {
699 panic!("one matching context");
700 };
701
702 assert_eq!(
703 vec![
704 project.root_path().join("include"),
705 project.root_path().join("quotes"),
706 ],
707 context.project_include_roots
708 );
709 assert_eq!(
710 vec![project.root_path().join("system-include")],
711 context.system_include_roots
712 );
713 assert!(context.defined_macros.contains("DEBUG"));
714 assert!(context.defined_macros.contains("FEATURE"));
715 assert!(!context.defined_macros.contains("FEATURE=a"));
716 }
717
718 #[test]
719 fn quoted_command_entry_is_tokenized_without_executing_it() {
720 let (_temp, project) = project_with_database(Some(
721 r#"[{"directory":".","file":"src/main.cpp","command":"clang++ -I 'project include' -DNAME=\\\"two words\\\" -c src/main.cpp"}]"#,
722 ));
723 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
724 let contexts = CppCompileContexts::load(&project);
725 let [context] = contexts.contexts_for(&file) else {
726 panic!("one matching context");
727 };
728
729 assert_eq!(
730 vec![project.root_path().join("project include")],
731 context.project_include_roots
732 );
733 assert!(context.defined_macros.contains("NAME"));
734 }
735
736 #[test]
737 #[hardwon("#2011: conditional C visibility ignored command-order macro state")]
738 fn undefine_flags_apply_in_command_order() {
739 let context = context(&[
740 "cc",
741 "-DKEPT",
742 "-DCANCELLED",
743 "-U",
744 "CANCELLED",
745 "-UREVIVED",
746 "-DREVIVED",
747 "-UNEVER_DEFINED",
748 ]);
749 assert!(context.defined_macros.contains("KEPT"));
750 assert!(context.defined_macros.contains("REVIVED"));
751 assert!(!context.defined_macros.contains("CANCELLED"));
752 assert!(!context.defined_macros.contains("NEVER_DEFINED"));
753 assert!(!context.cplusplus_macro_configured);
754 for flags in [
755 vec!["c++", "-U__cplusplus"],
756 vec!["c++", "-U", "__cplusplus"],
757 vec!["c++", "-D__cplusplus=1", "-U__cplusplus"],
758 vec!["c++", "-U__cplusplus", "-D", "__cplusplus=1"],
759 ] {
760 let configured = super::parse_compile_arguments(
761 Path::new("."),
762 &flags
763 .iter()
764 .map(|flag| (*flag).to_string())
765 .collect::<Vec<_>>(),
766 )
767 .expect("compiler flags");
768 assert!(configured.cplusplus_macro_configured, "{flags:?}");
769 assert_eq!(
770 configured.defined_macros.contains("__cplusplus"),
771 flags.last() == Some(&"__cplusplus=1"),
772 "{flags:?}"
773 );
774 }
775 }
776
777 #[test]
778 fn a_file_compiled_in_two_configurations_keeps_both() {
779 let (_temp, project) = project_with_database(Some(
780 r#"[
781 {"directory":".","file":"src/main.cpp","arguments":["clang++","-c","src/main.cpp"]},
782 {"directory":".","file":"src/main.cpp","arguments":["clang++","-DOTHER","-c","src/main.cpp"]},
783 {"directory":".","file":"src/other.cpp","arguments":["clang++","-c","src/other.cpp"]}
784 ]"#,
785 ));
786 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
787 let contexts = CppCompileContexts::load(&project);
788 let candidates = contexts.contexts_for(&file);
789
790 assert_eq!(2, candidates.len());
793 assert!(candidates[0].defined_macros.is_empty());
794 assert!(candidates[1].defined_macros.contains("OTHER"));
795 }
796
797 #[test]
798 fn repeated_identical_entries_are_one_configuration() {
799 let (_temp, project) = project_with_database(Some(
800 r#"[
801 {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]},
802 {"directory":".","file":"src/main.cpp","arguments":["clang++","-I","include","-c","src/main.cpp"]}
803 ]"#,
804 ));
805 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
806 let contexts = CppCompileContexts::load(&project);
807
808 assert_eq!(1, contexts.contexts_for(&file).len());
811 }
812
813 #[test]
814 fn missing_workspace_sources_are_canonical_bounded_and_deterministic() {
815 let (_temp, project) = project_with_database(None);
816 let root = project.root_path().to_path_buf();
817 ProjectFile::new(root.clone(), "src/present.cpp")
818 .write("int present() { return 0; }")
819 .expect("present source");
820 ProjectFile::new(root.clone(), "src/present.c")
821 .write("int present_c(void) { return 0; }")
822 .expect("present C source");
823 ProjectFile::new(root.clone(), "include/present.hin")
824 .write("int generated_declaration(void);")
825 .expect("present C header template");
826
827 let mut entries = vec![serde_json::json!({
828 "directory": ".",
829 "file": "src/./present.cpp",
830 "arguments": ["clang++", "-c", "src/present.cpp"]
831 })];
832 entries.push(serde_json::json!({
833 "directory": ".",
834 "file": "src/present.c",
835 "arguments": ["clang", "-c", "src/present.c"]
836 }));
837 entries.push(serde_json::json!({
838 "directory": ".",
839 "file": "include/present.hin",
840 "arguments": ["clang", "-x", "c-header", "include/present.hin"]
841 }));
842 entries.extend(
843 (0..(super::MAX_MISSING_WORKSPACE_SOURCE_SAMPLE + 3)).map(|index| {
844 serde_json::json!({
845 "directory": ".",
846 "file": format!("generated/./unit-{index:02}.cpp"),
847 "arguments": ["clang++", "-c", format!("generated/unit-{index:02}.cpp")]
848 })
849 }),
850 );
851 entries.push(serde_json::json!({
854 "directory": "generated",
855 "file": "../generated/unit-00.cpp",
856 "arguments": ["clang++", "-c", "../generated/unit-00.cpp"]
857 }));
858 ProjectFile::new(root.clone(), "compile_commands.json")
859 .write(serde_json::to_string(&entries).expect("database JSON"))
860 .expect("database");
861
862 let contexts = CppCompileContexts::load(&project);
863 let listed = project
864 .analyzable_files(Language::Cpp)
865 .expect("C++ listing");
866 let coverage = contexts.missing_workspace_sources(&root, &listed);
867
868 assert_eq!(
869 super::MAX_MISSING_WORKSPACE_SOURCE_SAMPLE + 3,
870 coverage.missing_workspace_source_count()
871 );
872 assert_eq!(
873 (0..super::MAX_MISSING_WORKSPACE_SOURCE_SAMPLE)
874 .map(|index| Path::new("generated").join(format!("unit-{index:02}.cpp")))
875 .collect::<Vec<_>>(),
876 coverage.missing_workspace_source_sample()
877 );
878 assert!(
879 !coverage
880 .missing_workspace_source_sample()
881 .contains(&PathBuf::from("src/present.cpp"))
882 );
883
884 let reversed = entries.into_iter().rev().collect::<Vec<_>>();
885 ProjectFile::new(root.clone(), "compile_commands.json")
886 .write(serde_json::to_string(&reversed).expect("reversed database JSON"))
887 .expect("reversed database");
888 let reversed_coverage =
889 CppCompileContexts::load(&project).missing_workspace_sources(&root, &listed);
890 assert_eq!(coverage, reversed_coverage);
891 }
892
893 #[test]
894 fn missing_workspace_sources_excludes_outside_root_and_ignored_files() {
895 let temp = tempfile::tempdir().expect("workspace root");
896 let root = temp.path().canonicalize().expect("canonical root");
897 let outside = tempfile::tempdir().expect("outside root");
898 let outside_source = outside
899 .path()
900 .canonicalize()
901 .expect("canonical outside root")
902 .join("outside.cpp")
903 .to_string_lossy()
904 .into_owned();
905 ProjectFile::new(root.clone(), "src/present.cpp")
906 .write("int present() { return 0; }")
907 .expect("present source");
908 ProjectFile::new(root.clone(), "ignored.cpp")
909 .write("int ignored() { return 0; }")
910 .expect("ignored source");
911 ProjectFile::new(root.clone(), ".bifrostignore")
912 .write("ignored.cpp\n")
913 .expect("ignore file");
914 let database = serde_json::json!([
915 {
916 "directory": ".",
917 "file": "src/../src/present.cpp",
918 "arguments": ["clang++", "-c", "src/present.cpp"]
919 },
920 {
921 "directory": ".",
922 "file": "ignored.cpp",
923 "arguments": ["clang++", "-c", "ignored.cpp"]
924 },
925 {
926 "directory": ".",
927 "file": outside_source.clone(),
928 "arguments": ["clang++", "-c", outside_source]
929 }
930 ]);
931 ProjectFile::new(root.clone(), "compile_commands.json")
932 .write(database.to_string())
933 .expect("database");
934 let project = FilesystemProject::new(root.clone()).expect("filesystem project");
935 let listed = project
936 .analyzable_files(Language::Cpp)
937 .expect("C++ listing");
938 assert!(
939 listed
940 .iter()
941 .any(|file| file.rel_path() == Path::new("src/present.cpp"))
942 );
943 assert!(
944 !listed
945 .iter()
946 .any(|file| file.rel_path() == Path::new("ignored.cpp"))
947 );
948
949 let coverage = CppCompileContexts::load(&project).missing_workspace_sources(&root, &listed);
950 assert_eq!(1, coverage.missing_workspace_source_count());
951 assert_eq!(
952 &[PathBuf::from("ignored.cpp")],
953 coverage.missing_workspace_source_sample()
954 );
955 }
956
957 #[test]
958 fn a_quoted_include_checks_the_including_directory_before_external_roots() {
959 let external = tempfile::tempdir().expect("external root");
960 let external_root = external
961 .path()
962 .canonicalize()
963 .expect("canonical external root");
964 std::fs::write(external_root.join("vendor.hpp"), "class External {};\n")
965 .expect("external vendor header");
966 std::fs::write(external_root.join("from-search.hpp"), "class Search {};\n")
967 .expect("external search header");
968 let database = serde_json::json!([{
969 "directory": ".",
970 "file": "src/main.cpp",
971 "arguments": [
972 "clang++",
973 "-I",
974 external_root,
975 "-c",
976 "src/main.cpp"
977 ]
978 }])
979 .to_string();
980 let (_temp, project) = project_with_database(Some(&database));
981 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
982 let source_path = file.abs_path();
983 let source_directory = source_path.parent().expect("source directory");
984 std::fs::create_dir_all(source_directory).expect("source directory");
985 std::fs::write(source_directory.join("vendor.hpp"), "class Local {};\n")
986 .expect("local vendor header");
987 let contexts = CppCompileContexts::load(&project);
988
989 assert_eq!(
990 CppExternalIncludeResolution::Undeclared,
991 contexts.resolve_external_include(
992 &file,
993 &file.abs_path(),
994 std::path::Path::new("vendor.hpp"),
995 true,
996 )
997 );
998 assert!(matches!(
999 contexts.resolve_external_include(
1000 &file,
1001 &file.abs_path(),
1002 std::path::Path::new("from-search.hpp"),
1003 true,
1004 ),
1005 CppExternalIncludeResolution::Declared { .. }
1006 ));
1007 assert!(matches!(
1008 contexts.resolve_external_include(
1009 &file,
1010 &file.abs_path(),
1011 std::path::Path::new("vendor.hpp"),
1012 false,
1013 ),
1014 CppExternalIncludeResolution::Declared { .. }
1015 ));
1016 }
1017
1018 #[test]
1019 fn configurations_must_agree_on_the_external_header() {
1020 let first = tempfile::tempdir().expect("first root");
1021 let second = tempfile::tempdir().expect("second root");
1022 let first = first.path().canonicalize().expect("canonical first root");
1023 let second = second.path().canonicalize().expect("canonical second root");
1024 std::fs::write(first.join("vector"), "namespace std { class vector {}; }")
1025 .expect("first header");
1026 std::fs::write(second.join("vector"), "namespace std { class vector {}; }")
1027 .expect("second header");
1028 let database = serde_json::json!([
1029 {
1030 "directory": ".",
1031 "file": "src/main.cpp",
1032 "arguments": ["clang++", "-isystem", first, "-c", "src/main.cpp"]
1033 },
1034 {
1035 "directory": ".",
1036 "file": "src/main.cpp",
1037 "arguments": ["clang++", "-isystem", second, "-c", "src/main.cpp"]
1038 }
1039 ])
1040 .to_string();
1041 let (_temp, project) = project_with_database(Some(&database));
1042 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1043
1044 assert_eq!(
1045 CppExternalIncludeResolution::Conflicting,
1046 CppCompileContexts::load(&project)
1047 .resolve_external_angle_include(&file, std::path::Path::new("vector"))
1048 );
1049 }
1050
1051 #[test]
1052 fn external_include_cannot_escape_its_declared_root() {
1053 let external = tempfile::tempdir().expect("external root");
1054 let root = external.path().canonicalize().expect("canonical root");
1055 std::fs::create_dir_all(root.join("include")).expect("include directory");
1056 std::fs::write(root.join("outside.hpp"), "class Outside {};").expect("outside header");
1057 let database = serde_json::json!([{
1058 "directory": ".",
1059 "file": "src/main.cpp",
1060 "arguments": [
1061 "clang++",
1062 "-isystem",
1063 root.join("include"),
1064 "-c",
1065 "src/main.cpp"
1066 ]
1067 }])
1068 .to_string();
1069 let (_temp, project) = project_with_database(Some(&database));
1070 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1071
1072 assert_eq!(
1073 CppExternalIncludeResolution::Undeclared,
1074 CppCompileContexts::load(&project)
1075 .resolve_external_angle_include(&file, std::path::Path::new("../outside.hpp"))
1076 );
1077 assert_eq!(
1078 CppExternalIncludeResolution::Undeclared,
1079 CppCompileContexts::load(&project)
1080 .resolve_external_angle_include(&file, &root.join("outside.hpp"))
1081 );
1082 }
1083
1084 #[test]
1085 fn msvc_project_and_system_include_flags_preserve_search_order() {
1086 let (_temp, project) = project_with_database(Some(
1087 r#"[{"directory":".","file":"src/main.cpp","arguments":["cl.exe","/I","vendor/include","/external:Ifake-system/include","/imsvc","toolchain/include","/c","src/main.cpp"]}]"#,
1088 ));
1089 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1090 let contexts = CppCompileContexts::load(&project);
1091 let [context] = contexts.contexts_for(&file) else {
1092 panic!("one MSVC compile context");
1093 };
1094
1095 assert_eq!(1, context.project_include_roots.len());
1096 assert_eq!(2, context.system_include_roots.len());
1097 }
1098
1099 #[test]
1100 fn driver_basename_ending_in_plus_plus_selects_cpp_over_a_c_extension() {
1101 let cx = context(&["clang++", "-c", "file.c"]);
1102 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1103
1104 let cx = context(&["arm-none-eabi-gcc", "-c", "file.c"]);
1107 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1108
1109 let cx = context(&["arm-none-eabi-g++", "-c", "file.h"]);
1111 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.h")));
1112 }
1113
1114 #[test]
1115 fn driver_exe_suffix_is_stripped_before_the_plus_plus_check() {
1116 let cx = context(&["clang++.exe", "-c", "file.c"]);
1117 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1118 }
1119
1120 #[test]
1121 fn std_family_outranks_the_driver_basename() {
1122 let cx = context(&["g++", "-std=gnu11", "-c", "file.c"]);
1125 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1126
1127 let cx = context(&["gcc", "-std=c++17", "-c", "file.cc"]);
1128 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cc")));
1129
1130 let cx = context(&["gcc", "-std=c17", "-c", "file.cc"]);
1131 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cc")));
1132
1133 let cx = context(&["gcc", "-std=gnu++20", "-c", "file.c"]);
1134 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1135 }
1136
1137 #[test]
1138 fn explicit_x_split_form_outranks_std_and_driver() {
1139 let cx = context(&["clang", "-x", "c", "-std=c++17", "-c", "file.cpp"]);
1140 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
1141 }
1142
1143 #[test]
1144 fn explicit_x_glued_form_is_recognized() {
1145 let cx = context(&["gcc", "-xc++", "-c", "file.c"]);
1146 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1147
1148 let cx = context(&["g++", "-xc", "-c", "file.cpp"]);
1149 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
1150 }
1151
1152 #[test]
1153 fn explicit_x_on_a_c_file_overrides_the_extension() {
1154 let cx = context(&["gcc", "-x", "c++", "-c", "file.c"]);
1157 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1158 }
1159
1160 #[test]
1161 fn msvc_tc_and_tp_force_the_language_regardless_of_extension() {
1162 let cx = context(&["cl.exe", "/TC", "/c", "file.cpp"]);
1163 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.cpp")));
1164
1165 let cx = context(&["cl.exe", "/TP", "/c", "file.c"]);
1166 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.c")));
1167 }
1168
1169 #[test]
1170 fn missing_or_malformed_database_has_no_context() {
1171 let (_temp, project) = project_with_database(None);
1172 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1173 assert!(
1174 CppCompileContexts::load(&project)
1175 .contexts_for(&file)
1176 .is_empty()
1177 );
1178
1179 let (_temp, project) = project_with_database(Some("not json"));
1180 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1181 assert!(
1182 CppCompileContexts::load(&project)
1183 .contexts_for(&file)
1184 .is_empty()
1185 );
1186 }
1187
1188 #[test]
1189 fn an_explicit_system_root_declares_an_angle_include() {
1190 let (_temp, project) = project_with_database(Some(
1191 r#"[{"directory":".","file":"src/main.cpp","arguments":["clang++","-isystem","fake-system/include","-c","src/main.cpp"]}]"#,
1192 ));
1193 let header = ProjectFile::new(
1194 project.root_path().to_path_buf(),
1195 "fake-system/include/vector",
1196 );
1197 header
1198 .write("namespace std { class vector {}; }")
1199 .expect("header");
1200 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1201
1202 assert_eq!(
1203 CppExternalIncludeResolution::Declared {
1204 root: project
1205 .root_path()
1206 .join("fake-system/include")
1207 .canonicalize()
1208 .expect("canonical root"),
1209 header: header.abs_path().canonicalize().expect("canonical header"),
1210 },
1211 CppCompileContexts::load(&project)
1212 .resolve_external_angle_include(&file, std::path::Path::new("vector"))
1213 );
1214 }
1215
1216 #[test]
1217 fn an_external_project_root_declares_but_a_workspace_project_root_does_not() {
1218 let external = tempfile::tempdir().expect("external root");
1219 let external_root = external
1220 .path()
1221 .canonicalize()
1222 .expect("canonical external root");
1223 std::fs::write(external_root.join("vendor.hpp"), "class Vendor {};")
1224 .expect("external header");
1225 let database = serde_json::json!([{
1226 "directory": ".",
1227 "file": "src/main.cpp",
1228 "arguments": [
1229 "clang++",
1230 "-I",
1231 external_root,
1232 "-I",
1233 "include",
1234 "-c",
1235 "src/main.cpp"
1236 ]
1237 }])
1238 .to_string();
1239 let (_temp, project) = project_with_database(Some(&database));
1240 ProjectFile::new(project.root_path().to_path_buf(), "include/local.hpp")
1241 .write("class Local {};")
1242 .expect("local header");
1243 let file = ProjectFile::new(project.root_path().to_path_buf(), "src/main.cpp");
1244 let contexts = CppCompileContexts::load(&project);
1245
1246 assert!(matches!(
1247 contexts.resolve_external_angle_include(&file, std::path::Path::new("vendor.hpp")),
1248 CppExternalIncludeResolution::Declared { .. }
1249 ));
1250 assert_eq!(
1251 CppExternalIncludeResolution::Undeclared,
1252 contexts.resolve_external_angle_include(&file, std::path::Path::new("local.hpp"))
1253 );
1254 }
1255
1256 #[test]
1257 fn default_by_extension_c_is_c_and_everything_else_is_cpp() {
1258 let cx = context(&["cc", "-c", "file.c"]);
1259 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1260
1261 let cx = context(&["cc", "-c", "file.cc"]);
1262 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cc")));
1263 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.hpp")));
1264 let cx = context(&["cl.exe", "/c", "file.c"]);
1265 assert_eq!(CompiledLanguage::C, cx.tu_language(Path::new("file.c")));
1266
1267 let cx = context(&["cl.exe", "/c", "file.cpp"]);
1268 assert_eq!(CompiledLanguage::Cpp, cx.tu_language(Path::new("file.cpp")));
1269 }
1270
1271 #[test]
1272 #[hardwon("#6929: a Windows verbatim prefix made an in-workspace sibling external")]
1273 fn quoted_sibling_external_header_respects_workspace_boundary() {
1274 let outer = tempfile::tempdir().unwrap();
1277 let outer_root = outer.path().canonicalize().unwrap();
1278 let workspace = outer_root.join("workspace");
1279 let external = outer_root.join("external");
1280 std::fs::create_dir_all(&workspace).unwrap();
1281 std::fs::create_dir_all(&external).unwrap();
1282 for directory in [&workspace, &external] {
1283 std::fs::write(directory.join("caller.hpp"), "#include \"sibling.hpp\"\n").unwrap();
1284 std::fs::write(directory.join("sibling.hpp"), "struct S {};\n").unwrap();
1285 }
1286 let cx = super::parse_compile_arguments(
1289 &workspace,
1290 &[
1291 "clang++".into(),
1292 "-I".into(),
1293 outer_root.to_string_lossy().into_owned(),
1294 ],
1295 )
1296 .unwrap();
1297 assert_eq!(
1298 cx.resolve_external_include(
1299 &workspace,
1300 &workspace.join("caller.hpp"),
1301 Path::new("sibling.hpp"),
1302 true
1303 ),
1304 None
1305 );
1306 assert_eq!(
1307 cx.resolve_external_include(
1308 &workspace,
1309 &external.join("caller.hpp"),
1310 Path::new("sibling.hpp"),
1311 true
1312 ),
1313 Some((outer_root, external.join("sibling.hpp")))
1314 );
1315 }
1316}