1use brokk_bifrost_core::analyzer::ProjectFile;
9use brokk_bifrost_core::analyzer::model::{ImportInfo, Language};
10use brokk_bifrost_core::analyzer::project::Project;
11use brokk_bifrost_core::hash::{HashMap, HashSet};
12use brokk_bifrost_core::path_normalization::NormalizePath;
13use brokk_bifrost_core::path_utils::path_suffix_key;
14use std::collections::BTreeSet;
15use std::path::{Path, PathBuf};
16use tree_sitter::Node;
17
18use crate::source_facts::CppFileSourceFacts;
19
20const CPP_HEADER_EXTENSIONS: &[&str] = &["h", "hin", "hpp", "hh", "hxx"];
25
26pub fn is_cpp_translation_unit(file: &ProjectFile) -> bool {
31 file.rel_path()
32 .extension()
33 .and_then(|extension| extension.to_str())
34 .map(|extension| extension.to_ascii_lowercase())
35 .is_some_and(|extension| {
36 Language::Cpp.extensions().contains(&extension.as_str())
37 && !CPP_HEADER_EXTENSIONS.contains(&extension.as_str())
38 })
39}
40
41pub struct IncludeTargetIndex {
48 by_rel_path: HashMap<PathBuf, Vec<ProjectFile>>,
49 by_file_name: HashMap<String, Vec<ProjectFile>>,
50}
51
52pub fn canonical_include_paths(facts: &CppFileSourceFacts) -> Vec<String> {
58 facts
59 .facts
60 .includes
61 .iter()
62 .map(|include| include.path.clone())
63 .collect()
64}
65
66pub fn canonical_include_statements(facts: &CppFileSourceFacts) -> Vec<String> {
70 facts
71 .facts
72 .includes
73 .iter()
74 .map(|include| canonical_include_statement(&include.path, include.quoted))
75 .collect()
76}
77
78pub fn canonical_include_statement(path: &str, quoted: bool) -> String {
80 if quoted {
81 format!("#include \"{path}\"")
82 } else {
83 format!("#include <{path}>")
84 }
85}
86
87impl IncludeTargetIndex {
88 pub fn build<'a>(files: impl IntoIterator<Item = &'a ProjectFile>) -> Self {
89 let mut by_rel_path: HashMap<PathBuf, Vec<ProjectFile>> = HashMap::default();
90 let mut by_file_name: HashMap<String, Vec<ProjectFile>> = HashMap::default();
91 for file in files {
92 by_rel_path
93 .entry(file.rel_path().to_path_buf())
94 .or_default()
95 .push(file.clone());
96 if let Some(file_name) = file.rel_path().file_name().and_then(|value| value.to_str()) {
97 by_file_name
98 .entry(file_name.to_string())
99 .or_default()
100 .push(file.clone());
101 }
102 }
103 Self {
104 by_rel_path,
105 by_file_name,
106 }
107 }
108
109 pub fn resolve_indexed(&self, include: &str) -> Vec<ProjectFile> {
110 let include_path = Path::new(include);
111 let mut matched = HashSet::default();
112 let mut resolved = Vec::new();
113 if let Some(targets) = self.by_rel_path.get(include_path) {
114 for target in targets {
115 if matched.insert(target.clone()) {
116 resolved.push(target.clone());
117 }
118 }
119 }
120 for suffix in string_suffixes(include) {
121 if let Some(targets) = self.by_file_name.get(suffix) {
122 for target in targets {
123 if matched.insert(target.clone()) {
124 resolved.push(target.clone());
125 }
126 }
127 }
128 }
129 resolved
130 }
131
132 pub fn names_indexed_file(&self, include: &str) -> bool {
141 Path::new(include)
142 .file_name()
143 .and_then(|name| name.to_str())
144 .is_some_and(|name| self.by_file_name.contains_key(name))
145 }
146
147 fn resolve_direct(&self, source_file: &ProjectFile, include: &str) -> Vec<ProjectFile> {
148 let mut matched = HashSet::default();
149 let mut resolved = Vec::new();
150 for rel_path in direct_include_candidate_paths(source_file.root(), source_file, include) {
151 self.extend_rel_path(&rel_path, &mut matched, &mut resolved);
152 }
153 resolved
154 }
155
156 fn extend_rel_path(
157 &self,
158 rel_path: &Path,
159 matched: &mut HashSet<ProjectFile>,
160 out: &mut Vec<ProjectFile>,
161 ) {
162 if let Some(targets) = self.by_rel_path.get(rel_path) {
163 for target in targets {
164 if matched.insert(target.clone()) {
165 out.push(target.clone());
166 }
167 }
168 }
169 }
170
171 fn resolve_unique_fallback(
172 &self,
173 source_file: &ProjectFile,
174 include: &str,
175 ) -> Vec<ProjectFile> {
176 let include_path = Path::new(include);
177 let indexed = self.resolve_indexed(include);
178 let matches: Vec<_> = indexed
179 .into_iter()
180 .filter(|file| {
181 if include_path.components().count() > 1 {
182 file.rel_path().ends_with(include_path)
183 } else {
184 file.rel_path()
185 .file_name()
186 .is_some_and(|name| name == include_path)
187 }
188 })
189 .collect();
190 if matches.len() == 1 {
191 return matches;
192 }
193 let source_reachable = matches
194 .into_iter()
195 .filter(|file| {
196 (0..include_path.components().count())
197 .try_fold(file.rel_path(), |path, _| path.parent())
198 .is_some_and(|root| source_file.rel_path().starts_with(root))
199 })
200 .collect::<Vec<_>>();
201 if source_reachable.len() == 1 {
202 return source_reachable;
203 }
204 Vec::new()
205 }
206
207 fn resolve_unique_basename_alias(&self, include: &str) -> Vec<ProjectFile> {
208 let include_path = Path::new(include);
209 if include_path.components().count() <= 1 {
210 return Vec::new();
211 }
212 let Some(file_name) = include_path.file_name() else {
213 return Vec::new();
214 };
215 let matches = self
216 .resolve_indexed(include)
217 .into_iter()
218 .filter(|file| file.rel_path().file_name() == Some(file_name))
219 .collect::<Vec<_>>();
220 if matches.len() == 1 {
221 matches
222 } else {
223 Vec::new()
224 }
225 }
226}
227
228fn string_suffixes(value: &str) -> impl Iterator<Item = &str> {
229 value.char_indices().map(|(index, _)| &value[index..])
230}
231
232fn parse_quoted_include(statement: &str) -> Option<String> {
236 let tree = crate::diagnostics::parse_cpp_tree(statement);
237 let mut cursor = tree.root_node().walk();
238 tree.root_node()
239 .named_children(&mut cursor)
240 .find_map(|node| {
241 let (path, quoted) = include_path_from_node(node, statement)?;
242 quoted.then(|| path.to_owned())
243 })
244}
245
246pub fn resolve_include_targets(
247 project: &dyn Project,
248 source_file: &ProjectFile,
249 include: &str,
250) -> Vec<ProjectFile> {
251 let mut candidates = Vec::new();
252 let source_root = project.root().to_path_buf();
253 for rel_path in direct_include_candidate_paths(project.root(), source_file, include) {
254 let file = ProjectFile::new(source_root.clone(), rel_path);
255 if file.exists() {
256 candidates.push(file);
257 }
258 }
259
260 candidates.sort();
261 candidates.dedup();
262 candidates
263}
264
265fn direct_include_candidate_paths(
266 root: &Path,
267 source_file: &ProjectFile,
268 include: &str,
269) -> Vec<PathBuf> {
270 let include_path = Path::new(include);
271 if include_path.is_absolute() {
272 return project_relative_include_path(root, include_path)
273 .into_iter()
274 .collect();
275 }
276 vec![
277 source_file.parent().join(include_path),
278 include_path.to_path_buf(),
279 ]
280}
281
282pub fn resolve_include_targets_with_index(
283 source_file: &ProjectFile,
284 include: &str,
285 include_targets: &IncludeTargetIndex,
286) -> Vec<ProjectFile> {
287 let mut candidates = include_targets.resolve_direct(source_file, include);
288 if !candidates.is_empty() {
289 return candidates;
290 }
291 if Path::new(include).is_absolute() {
292 return candidates;
293 }
294 candidates.extend(include_targets.resolve_unique_fallback(source_file, include));
295 if candidates.is_empty()
296 && let Some(template) = header_template_include_spelling(include)
297 {
298 candidates = include_targets.resolve_direct(source_file, &template);
299 if candidates.is_empty() {
300 candidates.extend(include_targets.resolve_unique_fallback(source_file, &template));
301 }
302 }
303 if candidates.is_empty() {
309 candidates = include_targets.resolve_unique_basename_alias(include);
310 }
311 candidates
312}
313
314fn header_template_include_spelling(include: &str) -> Option<String> {
322 let path = Path::new(include);
323 (path.extension() == Some(std::ffi::OsStr::new("h")))
324 .then(|| path.with_extension("hin").to_string_lossy().into_owned())
325}
326
327fn project_relative_include_path(project_root: &Path, include_path: &Path) -> Option<PathBuf> {
328 let canonical_root = project_root
329 .canonicalize()
330 .unwrap_or_else(|_| project_root.to_path_buf())
331 .normalize();
332 let canonical_include = include_path
333 .canonicalize()
334 .unwrap_or_else(|_| include_path.to_path_buf())
335 .normalize();
336 strip_path_prefix(&canonical_include, &canonical_root).or_else(|| {
337 strip_path_prefix(
338 &include_path.to_path_buf().normalize(),
339 &project_root.to_path_buf().normalize(),
340 )
341 })
342}
343
344pub fn included_claimable_files(
366 sources: &[(ProjectFile, Vec<ImportInfo>)],
367 claimable: &BTreeSet<ProjectFile>,
368) -> HashMap<ProjectFile, BTreeSet<ProjectFile>> {
369 let mut edges: HashMap<ProjectFile, BTreeSet<ProjectFile>> = HashMap::default();
370 if claimable.is_empty() || sources.is_empty() {
371 return edges;
372 }
373 let index = IncludeTargetIndex::build(claimable.iter());
374 for (source_file, imports) in sources {
375 let mut targets = BTreeSet::new();
376 for include in imports
377 .iter()
378 .filter_map(|import| parse_quoted_include(&import.raw_snippet))
379 {
380 targets.extend(resolve_include_targets_with_index(
381 source_file,
382 &include,
383 &index,
384 ));
385 }
386 if !targets.is_empty() {
387 edges.insert(source_file.clone(), targets);
388 }
389 }
390 edges
391}
392
393pub fn claimable_include_demand(
426 sources: &[(ProjectFile, Vec<ImportInfo>)],
427) -> HashMap<ProjectFile, BTreeSet<String>> {
428 let mut demand: HashMap<ProjectFile, BTreeSet<String>> = HashMap::default();
429 for (source_file, imports) in sources {
430 let mut keys = BTreeSet::new();
431 for include in imports
432 .iter()
433 .filter_map(|import| parse_quoted_include(&import.raw_snippet))
434 {
435 let template = header_template_include_spelling(&include);
436 for spelling in std::iter::once(include).chain(template) {
437 collect_include_demand_keys(source_file, &spelling, &mut keys);
438 }
439 }
440 if !keys.is_empty() {
441 demand.insert(source_file.clone(), keys);
442 }
443 }
444 demand
445}
446
447fn collect_include_demand_keys(
448 source_file: &ProjectFile,
449 include: &str,
450 keys: &mut BTreeSet<String>,
451) {
452 let include_path = Path::new(include);
453 let claimable_spelling = include_path
454 .extension()
455 .and_then(|extension| extension.to_str())
456 .is_none_or(|extension| !Language::is_source_extension(extension));
457 if !claimable_spelling {
458 return;
459 }
460 if include_path.is_absolute() {
461 if let Some(rel_path) = project_relative_include_path(source_file.root(), include_path)
465 && let Some(key) = path_suffix_key(&rel_path)
466 {
467 keys.insert(key);
468 }
469 return;
470 }
471 if let Some(key) = path_suffix_key(include_path) {
472 keys.insert(key);
473 }
474}
475
476pub(crate) fn include_path_literal_value<'a>(path: Node<'_>, source: &'a str) -> Option<&'a str> {
482 let text = source.get(path.byte_range())?;
483 match path.kind() {
484 "string_literal" => text.strip_prefix('"')?.strip_suffix('"'),
485 "system_lib_string" => text.strip_prefix('<')?.strip_suffix('>'),
486 _ => None,
487 }
488}
489
490pub fn include_path_from_node<'a>(node: Node<'_>, source: &'a str) -> Option<(&'a str, bool)> {
491 if node.kind() != "preproc_include" {
492 return None;
493 }
494 let path = node.child_by_field_name("path")?;
495 let quoted = path.kind() == "string_literal";
496 let value = include_path_literal_value(path, source).filter(|path| !path.is_empty())?;
497 Some((value, quoted))
498}
499
500pub fn extract_type_identifiers(node: Node<'_>, source: &str) -> BTreeSet<String> {
507 let mut identifiers = BTreeSet::new();
508 let mut stack = vec![node];
509 while let Some(current) = stack.pop() {
510 if matches!(
511 current.kind(),
512 "identifier" | "type_identifier" | "namespace_identifier"
513 ) && let Some(text) = source.get(current.byte_range())
514 && text
515 .chars()
516 .next()
517 .is_some_and(|character| character.is_ascii_uppercase())
518 {
519 identifiers.insert(text.to_owned());
520 }
521 let mut cursor = current.walk();
522 let children = current.named_children(&mut cursor).collect::<Vec<_>>();
523 stack.extend(children.into_iter().rev());
524 }
525 identifiers
526}
527
528pub fn receiver_query_supported(file: &ProjectFile) -> bool {
533 file.rel_path()
534 .extension()
535 .and_then(|extension| extension.to_str())
536 != Some("c")
537}
538
539#[cfg(windows)]
540fn strip_path_prefix(path: &Path, root: &Path) -> Option<PathBuf> {
541 let mut path_components = path.components();
542 for root_component in root.components() {
543 let path_component = path_components.next()?;
544 if !path_component
545 .as_os_str()
546 .eq_ignore_ascii_case(root_component.as_os_str())
547 {
548 return None;
549 }
550 }
551 Some(path_components.as_path().to_path_buf())
552}
553
554#[cfg(not(windows))]
555fn strip_path_prefix(path: &Path, root: &Path) -> Option<PathBuf> {
556 path.strip_prefix(root).ok().map(Path::to_path_buf)
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use brokk_bifrost_core::test_support::write_file;
563 use tempfile::TempDir;
564
565 #[test]
566 #[brokk_bifrost_test_macros::hardwon(
567 "db00de0653f1: quicksilver round 4 on brokk-bifrost-cpp: angle include comments fabricated quoted claim paths"
568 )]
569 fn quoted_include_uses_the_directive_path_node() {
570 assert_eq!(
571 parse_quoted_include("#include <fields.inc> // \"bogus.inc\""),
572 None
573 );
574 assert_eq!(
575 parse_quoted_include("#include /* \"bogus.inc\" */ \"real.inc\""),
576 Some("real.inc".to_string())
577 );
578 assert_eq!(parse_quoted_include("// #include \"bogus.inc\""), None);
579 }
580
581 #[test]
582 #[brokk_bifrost_test_macros::hardwon(
583 "4b224ec22cdb: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
584 )]
585 fn indexed_include_resolution_uses_unique_suffix_fallback() {
586 let temp = TempDir::new().unwrap();
587 let root = temp.path().canonicalize().unwrap();
588 let source = write_file(&root, "src/lib.c", "");
589 let target = write_file(&root, "include/git2/sys/credential.h", "");
590 let duplicate = write_file(&root, "vendor/credential.h", "");
591 let index = IncludeTargetIndex::build([&source, &target, &duplicate]);
592
593 let resolved = resolve_include_targets_with_index(&source, "git2/sys/credential.h", &index);
594 assert_eq!(resolved, vec![target]);
595
596 let ambiguous = resolve_include_targets_with_index(&source, "credential.h", &index);
597 assert!(ambiguous.is_empty());
598 }
599
600 #[test]
601 #[brokk_bifrost_test_macros::hardwon(
602 "cf45b06757c2: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
603 )]
604 fn indexed_include_resolution_prefers_unique_source_reachable_root() {
605 let temp = TempDir::new().unwrap();
606 let root = temp.path().canonicalize().unwrap();
607 let source = write_file(&root, "src/config/parse.c", "");
608 let target = write_file(&root, "src/config/parse.h", "");
609 let nested_decoy = write_file(&root, "src/build/config/parse.h", "");
610 let unrelated_source = write_file(&root, "app/main.c", "");
611 let index = IncludeTargetIndex::build([&source, &target, &nested_decoy, &unrelated_source]);
612
613 let resolved = resolve_include_targets_with_index(&source, "config/parse.h", &index);
614 assert_eq!(resolved, vec![target.clone()]);
615
616 let unrelated =
617 resolve_include_targets_with_index(&unrelated_source, "config/parse.h", &index);
618 assert!(unrelated.is_empty());
619
620 let ambiguous_source = write_file(&root, "src/config/deeper/main.c", "");
621 let second_reachable = write_file(&root, "src/config/config/parse.h", "");
622 let ambiguous_index = IncludeTargetIndex::build([
623 &ambiguous_source,
624 &target,
625 &nested_decoy,
626 &second_reachable,
627 ]);
628 let ambiguous = resolve_include_targets_with_index(
629 &ambiguous_source,
630 "config/parse.h",
631 &ambiguous_index,
632 );
633 assert!(ambiguous.is_empty());
634 }
635
636 #[test]
637 #[brokk_bifrost_test_macros::hardwon(
638 "d8cf28cfbabb: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
639 )]
640 fn indexed_include_resolution_accepts_one_unique_installed_prefix_alias() {
641 let temp = TempDir::new().unwrap();
642 let root = temp.path().canonicalize().unwrap();
643 let source = write_file(&root, "src/lib/asn1/asn1_obj.cpp", "");
644 let target = write_file(&root, "src/lib/asn1/Widget.h", "");
645 let index = IncludeTargetIndex::build([&source, &target]);
646
647 assert_eq!(
648 resolve_include_targets_with_index(&source, "botan/Widget.h", &index),
649 vec![target.clone()]
650 );
651
652 let duplicate = write_file(&root, "vendor/Widget.h", "");
653 let ambiguous = IncludeTargetIndex::build([&source, &target, &duplicate]);
654 assert!(
655 resolve_include_targets_with_index(&source, "botan/Widget.h", &ambiguous).is_empty()
656 );
657 }
658
659 #[test]
660 #[brokk_bifrost_test_macros::hardwon(
661 "53f52b136fc7: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
662 )]
663 fn unresolved_h_include_falls_back_to_hin_template() {
664 let temp = TempDir::new().unwrap();
665 let root = temp.path().canonicalize().unwrap();
666 let stub = write_file(&root, "src/include/krb5.h", "");
667 let template = write_file(&root, "src/include/krb5/krb5.hin", "");
668 let index = IncludeTargetIndex::build([&stub, &template]);
669
670 let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
671 assert_eq!(resolved, vec![template]);
672 }
673
674 #[test]
675 #[brokk_bifrost_test_macros::hardwon(
676 "53f52b136fc7: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
677 )]
678 fn real_header_wins_over_hin_template() {
679 let temp = TempDir::new().unwrap();
680 let root = temp.path().canonicalize().unwrap();
681 let stub = write_file(&root, "src/include/krb5.h", "");
682 let generated = write_file(&root, "src/include/krb5/krb5.h", "");
683 let template = write_file(&root, "src/include/krb5/krb5.hin", "");
684 let index = IncludeTargetIndex::build([&stub, &generated, &template]);
685
686 let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
687 assert_eq!(resolved, vec![generated]);
688 }
689
690 #[test]
691 fn include_literal_grammar_boundary() {
692 for (source, kind, quoted) in [
693 ("#include \"fields.inc\"\n", "string_literal", true),
694 ("#include <fields.inc>\n", "system_lib_string", false),
695 ] {
696 let mut parser = tree_sitter::Parser::new();
697 parser
698 .set_language(&tree_sitter_cpp::LANGUAGE.into())
699 .unwrap();
700 let tree = parser.parse(source, None).unwrap();
701 assert!(!tree.root_node().has_error());
702 let directive = tree.root_node().named_child(0).unwrap();
703 assert_eq!(directive.kind(), "preproc_include");
704 let path = directive.child_by_field_name("path").unwrap();
705 assert_eq!((path.kind(), path.byte_range()), (kind, 9..21));
706 assert_eq!(
707 include_path_from_node(directive, source),
708 Some(("fields.inc", quoted))
709 );
710 }
711 }
712}