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 workspace_relative_paths(&self) -> Vec<String> {
111 let mut paths = self
112 .by_rel_path
113 .keys()
114 .map(|path| brokk_bifrost_core::path_utils::workspace_relative_slash_path(path))
115 .collect::<Vec<_>>();
116 paths.sort_unstable();
117 paths
118 }
119
120 pub fn resolve_indexed(&self, include: &str) -> Vec<ProjectFile> {
121 let include_path = Path::new(include);
122 let mut matched = HashSet::default();
123 let mut resolved = Vec::new();
124 if let Some(targets) = self.by_rel_path.get(include_path) {
125 for target in targets {
126 if matched.insert(target.clone()) {
127 resolved.push(target.clone());
128 }
129 }
130 }
131 for suffix in string_suffixes(include) {
132 if let Some(targets) = self.by_file_name.get(suffix) {
133 for target in targets {
134 if matched.insert(target.clone()) {
135 resolved.push(target.clone());
136 }
137 }
138 }
139 }
140 resolved
141 }
142
143 pub fn names_indexed_file(&self, include: &str) -> bool {
152 Path::new(include)
153 .file_name()
154 .and_then(|name| name.to_str())
155 .is_some_and(|name| self.by_file_name.contains_key(name))
156 }
157
158 fn resolve_direct(&self, source_file: &ProjectFile, include: &str) -> Vec<ProjectFile> {
159 let mut matched = HashSet::default();
160 let mut resolved = Vec::new();
161 for rel_path in direct_include_candidate_paths(source_file.root(), source_file, include) {
162 self.extend_rel_path(&rel_path, &mut matched, &mut resolved);
163 }
164 resolved
165 }
166
167 fn extend_rel_path(
168 &self,
169 rel_path: &Path,
170 matched: &mut HashSet<ProjectFile>,
171 out: &mut Vec<ProjectFile>,
172 ) {
173 if let Some(targets) = self.by_rel_path.get(rel_path) {
174 for target in targets {
175 if matched.insert(target.clone()) {
176 out.push(target.clone());
177 }
178 }
179 }
180 }
181
182 fn resolve_unique_fallback(
183 &self,
184 source_file: &ProjectFile,
185 include: &str,
186 ) -> Vec<ProjectFile> {
187 let include_path = Path::new(include);
188 let indexed = self.resolve_indexed(include);
189 let matches: Vec<_> = indexed
190 .into_iter()
191 .filter(|file| {
192 if include_path.components().count() > 1 {
193 file.rel_path().ends_with(include_path)
194 } else {
195 file.rel_path()
196 .file_name()
197 .is_some_and(|name| name == include_path)
198 }
199 })
200 .collect();
201 if matches.len() == 1 {
202 return matches;
203 }
204 let source_reachable = matches
205 .into_iter()
206 .filter(|file| {
207 (0..include_path.components().count())
208 .try_fold(file.rel_path(), |path, _| path.parent())
209 .is_some_and(|root| source_file.rel_path().starts_with(root))
210 })
211 .collect::<Vec<_>>();
212 if source_reachable.len() == 1 {
213 return source_reachable;
214 }
215 Vec::new()
216 }
217
218 fn resolve_unique_basename_alias(&self, include: &str) -> Vec<ProjectFile> {
219 let include_path = Path::new(include);
220 if include_path.components().count() <= 1 {
221 return Vec::new();
222 }
223 let Some(file_name) = include_path.file_name() else {
224 return Vec::new();
225 };
226 let matches = self
227 .resolve_indexed(include)
228 .into_iter()
229 .filter(|file| file.rel_path().file_name() == Some(file_name))
230 .collect::<Vec<_>>();
231 if matches.len() == 1 {
232 matches
233 } else {
234 Vec::new()
235 }
236 }
237}
238
239fn string_suffixes(value: &str) -> impl Iterator<Item = &str> {
240 value.char_indices().map(|(index, _)| &value[index..])
241}
242
243fn parse_quoted_include(statement: &str) -> Option<String> {
247 let tree = crate::diagnostics::parse_cpp_tree(statement);
248 let mut cursor = tree.root_node().walk();
249 tree.root_node()
250 .named_children(&mut cursor)
251 .find_map(|node| {
252 let (path, quoted) = include_path_from_node(node, statement)?;
253 quoted.then(|| path.to_owned())
254 })
255}
256
257pub fn resolve_include_targets(
258 project: &dyn Project,
259 source_file: &ProjectFile,
260 include: &str,
261) -> Vec<ProjectFile> {
262 let mut candidates = Vec::new();
263 let source_root = project.root().to_path_buf();
264 for rel_path in direct_include_candidate_paths(project.root(), source_file, include) {
265 let file = ProjectFile::new(source_root.clone(), rel_path);
266 if file.exists() {
267 candidates.push(file);
268 }
269 }
270
271 candidates.sort();
272 candidates.dedup();
273 candidates
274}
275
276fn direct_include_candidate_paths(
277 root: &Path,
278 source_file: &ProjectFile,
279 include: &str,
280) -> Vec<PathBuf> {
281 let include_path = Path::new(include);
282 if include_path.is_absolute() {
283 return project_relative_include_path(root, include_path)
284 .into_iter()
285 .collect();
286 }
287 vec![
288 source_file.parent().join(include_path),
289 include_path.to_path_buf(),
290 ]
291}
292
293pub fn resolve_include_targets_with_index(
294 source_file: &ProjectFile,
295 include: &str,
296 include_targets: &IncludeTargetIndex,
297) -> Vec<ProjectFile> {
298 let mut candidates = include_targets.resolve_direct(source_file, include);
299 if !candidates.is_empty() {
300 return candidates;
301 }
302 if Path::new(include).is_absolute() {
303 return candidates;
304 }
305 candidates.extend(include_targets.resolve_unique_fallback(source_file, include));
306 if candidates.is_empty()
307 && let Some(template) = header_template_include_spelling(include)
308 {
309 candidates = include_targets.resolve_direct(source_file, &template);
310 if candidates.is_empty() {
311 candidates.extend(include_targets.resolve_unique_fallback(source_file, &template));
312 }
313 }
314 if candidates.is_empty() {
320 candidates = include_targets.resolve_unique_basename_alias(include);
321 }
322 candidates
323}
324
325fn header_template_include_spelling(include: &str) -> Option<String> {
333 let path = Path::new(include);
334 (path.extension() == Some(std::ffi::OsStr::new("h")))
335 .then(|| path.with_extension("hin").to_string_lossy().into_owned())
336}
337
338fn project_relative_include_path(project_root: &Path, include_path: &Path) -> Option<PathBuf> {
339 let canonical_root = project_root
340 .canonicalize()
341 .unwrap_or_else(|_| project_root.to_path_buf())
342 .normalize();
343 let canonical_include = include_path
344 .canonicalize()
345 .unwrap_or_else(|_| include_path.to_path_buf())
346 .normalize();
347 strip_path_prefix(&canonical_include, &canonical_root).or_else(|| {
348 strip_path_prefix(
349 &include_path.to_path_buf().normalize(),
350 &project_root.to_path_buf().normalize(),
351 )
352 })
353}
354
355pub fn included_claimable_files(
377 sources: &[(ProjectFile, Vec<ImportInfo>)],
378 claimable: &BTreeSet<ProjectFile>,
379) -> HashMap<ProjectFile, BTreeSet<ProjectFile>> {
380 let mut edges: HashMap<ProjectFile, BTreeSet<ProjectFile>> = HashMap::default();
381 if claimable.is_empty() || sources.is_empty() {
382 return edges;
383 }
384 let index = IncludeTargetIndex::build(claimable.iter());
385 for (source_file, imports) in sources {
386 let mut targets = BTreeSet::new();
387 for include in imports
388 .iter()
389 .filter_map(|import| parse_quoted_include(&import.raw_snippet))
390 {
391 targets.extend(resolve_include_targets_with_index(
392 source_file,
393 &include,
394 &index,
395 ));
396 }
397 if !targets.is_empty() {
398 edges.insert(source_file.clone(), targets);
399 }
400 }
401 edges
402}
403
404pub fn claimable_include_demand(
437 sources: &[(ProjectFile, Vec<ImportInfo>)],
438) -> HashMap<ProjectFile, BTreeSet<String>> {
439 let mut demand: HashMap<ProjectFile, BTreeSet<String>> = HashMap::default();
440 for (source_file, imports) in sources {
441 let mut keys = BTreeSet::new();
442 for include in imports
443 .iter()
444 .filter_map(|import| parse_quoted_include(&import.raw_snippet))
445 {
446 let template = header_template_include_spelling(&include);
447 for spelling in std::iter::once(include).chain(template) {
448 collect_include_demand_keys(source_file, &spelling, &mut keys);
449 }
450 }
451 if !keys.is_empty() {
452 demand.insert(source_file.clone(), keys);
453 }
454 }
455 demand
456}
457
458fn collect_include_demand_keys(
459 source_file: &ProjectFile,
460 include: &str,
461 keys: &mut BTreeSet<String>,
462) {
463 let include_path = Path::new(include);
464 let claimable_spelling = include_path
465 .extension()
466 .and_then(|extension| extension.to_str())
467 .is_none_or(|extension| !Language::is_source_extension(extension));
468 if !claimable_spelling {
469 return;
470 }
471 if include_path.is_absolute() {
472 if let Some(rel_path) = project_relative_include_path(source_file.root(), include_path)
476 && let Some(key) = path_suffix_key(&rel_path)
477 {
478 keys.insert(key);
479 }
480 return;
481 }
482 if let Some(key) = path_suffix_key(include_path) {
483 keys.insert(key);
484 }
485}
486
487pub(crate) fn include_path_literal_value<'a>(path: Node<'_>, source: &'a str) -> Option<&'a str> {
493 let text = source.get(path.byte_range())?;
494 match path.kind() {
495 "string_literal" => text.strip_prefix('"')?.strip_suffix('"'),
496 "system_lib_string" => text.strip_prefix('<')?.strip_suffix('>'),
497 _ => None,
498 }
499}
500
501pub fn include_path_from_node<'a>(node: Node<'_>, source: &'a str) -> Option<(&'a str, bool)> {
502 if node.kind() != "preproc_include" {
503 return None;
504 }
505 let path = node.child_by_field_name("path")?;
506 let quoted = path.kind() == "string_literal";
507 let value = include_path_literal_value(path, source).filter(|path| !path.is_empty())?;
508 Some((value, quoted))
509}
510
511pub fn extract_type_identifiers(node: Node<'_>, source: &str) -> BTreeSet<String> {
518 let mut identifiers = BTreeSet::new();
519 let mut stack = vec![node];
520 while let Some(current) = stack.pop() {
521 if matches!(
522 current.kind(),
523 "identifier" | "type_identifier" | "namespace_identifier"
524 ) && let Some(text) = source.get(current.byte_range())
525 && text
526 .chars()
527 .next()
528 .is_some_and(|character| character.is_ascii_uppercase())
529 {
530 identifiers.insert(text.to_owned());
531 }
532 let mut cursor = current.walk();
533 let children = current.named_children(&mut cursor).collect::<Vec<_>>();
534 stack.extend(children.into_iter().rev());
535 }
536 identifiers
537}
538
539pub fn receiver_query_supported(file: &ProjectFile) -> bool {
544 file.rel_path()
545 .extension()
546 .and_then(|extension| extension.to_str())
547 != Some("c")
548}
549
550#[cfg(windows)]
551fn strip_path_prefix(path: &Path, root: &Path) -> Option<PathBuf> {
552 let mut path_components = path.components();
553 for root_component in root.components() {
554 let path_component = path_components.next()?;
555 if !path_component
556 .as_os_str()
557 .eq_ignore_ascii_case(root_component.as_os_str())
558 {
559 return None;
560 }
561 }
562 Some(path_components.as_path().to_path_buf())
563}
564
565#[cfg(not(windows))]
566fn strip_path_prefix(path: &Path, root: &Path) -> Option<PathBuf> {
567 path.strip_prefix(root).ok().map(Path::to_path_buf)
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use brokk_bifrost_core::test_support::write_file;
574 use tempfile::TempDir;
575
576 #[test]
577 #[brokk_bifrost_test_macros::hardwon(
578 "e3c20990a105: quicksilver round 4 on brokk-bifrost-cpp: angle include comments fabricated quoted claim paths"
579 )]
580 fn quoted_include_uses_the_directive_path_node() {
581 assert_eq!(
582 parse_quoted_include("#include <fields.inc> // \"bogus.inc\""),
583 None
584 );
585 assert_eq!(
586 parse_quoted_include("#include /* \"bogus.inc\" */ \"real.inc\""),
587 Some("real.inc".to_string())
588 );
589 assert_eq!(parse_quoted_include("// #include \"bogus.inc\""), None);
590 }
591
592 #[test]
593 #[brokk_bifrost_test_macros::hardwon(
594 "4b224ec22cdb: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
595 )]
596 fn indexed_include_resolution_uses_unique_suffix_fallback() {
597 let temp = TempDir::new().unwrap();
598 let root = temp.path().canonicalize().unwrap();
599 let source = write_file(&root, "src/lib.c", "");
600 let target = write_file(&root, "include/git2/sys/credential.h", "");
601 let duplicate = write_file(&root, "vendor/credential.h", "");
602 let index = IncludeTargetIndex::build([&source, &target, &duplicate]);
603
604 let resolved = resolve_include_targets_with_index(&source, "git2/sys/credential.h", &index);
605 assert_eq!(resolved, vec![target]);
606
607 let ambiguous = resolve_include_targets_with_index(&source, "credential.h", &index);
608 assert!(ambiguous.is_empty());
609 }
610
611 #[test]
612 #[brokk_bifrost_test_macros::hardwon(
613 "cf45b06757c2: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
614 )]
615 fn indexed_include_resolution_prefers_unique_source_reachable_root() {
616 let temp = TempDir::new().unwrap();
617 let root = temp.path().canonicalize().unwrap();
618 let source = write_file(&root, "src/config/parse.c", "");
619 let target = write_file(&root, "src/config/parse.h", "");
620 let nested_decoy = write_file(&root, "src/build/config/parse.h", "");
621 let unrelated_source = write_file(&root, "app/main.c", "");
622 let index = IncludeTargetIndex::build([&source, &target, &nested_decoy, &unrelated_source]);
623
624 let resolved = resolve_include_targets_with_index(&source, "config/parse.h", &index);
625 assert_eq!(resolved, vec![target.clone()]);
626
627 let unrelated =
628 resolve_include_targets_with_index(&unrelated_source, "config/parse.h", &index);
629 assert!(unrelated.is_empty());
630
631 let ambiguous_source = write_file(&root, "src/config/deeper/main.c", "");
632 let second_reachable = write_file(&root, "src/config/config/parse.h", "");
633 let ambiguous_index = IncludeTargetIndex::build([
634 &ambiguous_source,
635 &target,
636 &nested_decoy,
637 &second_reachable,
638 ]);
639 let ambiguous = resolve_include_targets_with_index(
640 &ambiguous_source,
641 "config/parse.h",
642 &ambiguous_index,
643 );
644 assert!(ambiguous.is_empty());
645 }
646
647 #[test]
648 #[brokk_bifrost_test_macros::hardwon(
649 "d8cf28cfbabb: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
650 )]
651 fn indexed_include_resolution_accepts_one_unique_installed_prefix_alias() {
652 let temp = TempDir::new().unwrap();
653 let root = temp.path().canonicalize().unwrap();
654 let source = write_file(&root, "src/lib/asn1/asn1_obj.cpp", "");
655 let target = write_file(&root, "src/lib/asn1/Widget.h", "");
656 let index = IncludeTargetIndex::build([&source, &target]);
657
658 assert_eq!(
659 resolve_include_targets_with_index(&source, "botan/Widget.h", &index),
660 vec![target.clone()]
661 );
662
663 let duplicate = write_file(&root, "vendor/Widget.h", "");
664 let ambiguous = IncludeTargetIndex::build([&source, &target, &duplicate]);
665 assert!(
666 resolve_include_targets_with_index(&source, "botan/Widget.h", &ambiguous).is_empty()
667 );
668 }
669
670 #[test]
671 #[brokk_bifrost_test_macros::hardwon(
672 "53f52b136fc7: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
673 )]
674 fn unresolved_h_include_falls_back_to_hin_template() {
675 let temp = TempDir::new().unwrap();
676 let root = temp.path().canonicalize().unwrap();
677 let stub = write_file(&root, "src/include/krb5.h", "");
678 let template = write_file(&root, "src/include/krb5/krb5.hin", "");
679 let index = IncludeTargetIndex::build([&stub, &template]);
680
681 let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
682 assert_eq!(resolved, vec![template]);
683 }
684
685 #[test]
686 #[brokk_bifrost_test_macros::hardwon(
687 "53f52b136fc7: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
688 )]
689 fn real_header_wins_over_hin_template() {
690 let temp = TempDir::new().unwrap();
691 let root = temp.path().canonicalize().unwrap();
692 let stub = write_file(&root, "src/include/krb5.h", "");
693 let generated = write_file(&root, "src/include/krb5/krb5.h", "");
694 let template = write_file(&root, "src/include/krb5/krb5.hin", "");
695 let index = IncludeTargetIndex::build([&stub, &generated, &template]);
696
697 let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
698 assert_eq!(resolved, vec![generated]);
699 }
700
701 #[test]
702 fn include_literal_grammar_boundary() {
703 for (source, kind, quoted) in [
704 ("#include \"fields.inc\"\n", "string_literal", true),
705 ("#include <fields.inc>\n", "system_lib_string", false),
706 ] {
707 let mut parser = tree_sitter::Parser::new();
708 parser
709 .set_language(&tree_sitter_cpp::LANGUAGE.into())
710 .unwrap();
711 let tree = parser.parse(source, None).unwrap();
712 assert!(!tree.root_node().has_error());
713 let directive = tree.root_node().named_child(0).unwrap();
714 assert_eq!(directive.kind(), "preproc_include");
715 let path = directive.child_by_field_name("path").unwrap();
716 assert_eq!((path.kind(), path.byte_range()), (kind, 9..21));
717 assert_eq!(
718 include_path_from_node(directive, source),
719 Some(("fields.inc", quoted))
720 );
721 }
722 }
723}