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
232pub fn parse_quoted_include(line: &str) -> Option<String> {
233 let trimmed = line.trim();
234 let quote_start = trimmed.find('"')?;
235 let quote_end = trimmed[quote_start + 1..].find('"')?;
236 Some(trimmed[quote_start + 1..quote_start + 1 + quote_end].to_string())
237}
238
239pub fn parse_include_path(line: &str) -> Option<String> {
240 if let Some(path) = parse_quoted_include(line) {
241 return Some(path);
242 }
243 let trimmed = line.trim();
244 let angle_start = trimmed.find('<')?;
245 let angle_end = trimmed[angle_start + 1..].find('>')?;
246 Some(trimmed[angle_start + 1..angle_start + 1 + angle_end].to_string())
247}
248
249pub fn resolve_include_targets(
250 project: &dyn Project,
251 source_file: &ProjectFile,
252 include: &str,
253) -> Vec<ProjectFile> {
254 let mut candidates = Vec::new();
255 let source_root = project.root().to_path_buf();
256 for rel_path in direct_include_candidate_paths(project.root(), source_file, include) {
257 let file = ProjectFile::new(source_root.clone(), rel_path);
258 if file.exists() {
259 candidates.push(file);
260 }
261 }
262
263 candidates.sort();
264 candidates.dedup();
265 candidates
266}
267
268fn direct_include_candidate_paths(
269 root: &Path,
270 source_file: &ProjectFile,
271 include: &str,
272) -> Vec<PathBuf> {
273 let include_path = Path::new(include);
274 if include_path.is_absolute() {
275 return project_relative_include_path(root, include_path)
276 .into_iter()
277 .collect();
278 }
279 vec![
280 source_file.parent().join(include_path),
281 include_path.to_path_buf(),
282 ]
283}
284
285pub fn resolve_include_targets_with_index(
286 source_file: &ProjectFile,
287 include: &str,
288 include_targets: &IncludeTargetIndex,
289) -> Vec<ProjectFile> {
290 let mut candidates = include_targets.resolve_direct(source_file, include);
291 if !candidates.is_empty() {
292 return candidates;
293 }
294 if Path::new(include).is_absolute() {
295 return candidates;
296 }
297 candidates.extend(include_targets.resolve_unique_fallback(source_file, include));
298 if candidates.is_empty()
299 && let Some(template) = header_template_include_spelling(include)
300 {
301 candidates = include_targets.resolve_direct(source_file, &template);
302 if candidates.is_empty() {
303 candidates.extend(include_targets.resolve_unique_fallback(source_file, &template));
304 }
305 }
306 if candidates.is_empty() {
312 candidates = include_targets.resolve_unique_basename_alias(include);
313 }
314 candidates
315}
316
317fn header_template_include_spelling(include: &str) -> Option<String> {
325 let path = Path::new(include);
326 (path.extension() == Some(std::ffi::OsStr::new("h")))
327 .then(|| path.with_extension("hin").to_string_lossy().into_owned())
328}
329
330pub fn resolve_direct_include_targets_with_index(
331 source_file: &ProjectFile,
332 include: &str,
333 include_targets: &IncludeTargetIndex,
334) -> Vec<ProjectFile> {
335 include_targets.resolve_direct(source_file, include)
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 fn quoted_include_paths(parsed: &[String]) -> Vec<String> {
488 parsed
489 .iter()
490 .filter_map(|line| parse_quoted_include(line))
491 .collect()
492}
493
494pub fn include_paths(parsed: &[String]) -> Vec<String> {
495 parsed
496 .iter()
497 .filter_map(|line| parse_include_path(line))
498 .collect()
499}
500
501pub(crate) fn include_path_literal_value<'a>(path: Node<'_>, source: &'a str) -> Option<&'a str> {
507 let text = source.get(path.byte_range())?;
508 match path.kind() {
509 "string_literal" => text.strip_prefix('"')?.strip_suffix('"'),
510 "system_lib_string" => text.strip_prefix('<')?.strip_suffix('>'),
511 _ => None,
512 }
513}
514
515pub fn include_path_from_node<'a>(node: Node<'_>, source: &'a str) -> Option<(&'a str, bool)> {
516 if node.kind() != "preproc_include" {
517 return None;
518 }
519 let path = node.child_by_field_name("path")?;
520 let quoted = path.kind() == "string_literal";
521 let value = include_path_literal_value(path, source).filter(|path| !path.is_empty())?;
522 Some((value, quoted))
523}
524
525pub fn extract_type_identifiers(node: Node<'_>, source: &str) -> BTreeSet<String> {
532 let mut identifiers = BTreeSet::new();
533 let mut stack = vec![node];
534 while let Some(current) = stack.pop() {
535 if matches!(
536 current.kind(),
537 "identifier" | "type_identifier" | "namespace_identifier"
538 ) && let Some(text) = source.get(current.byte_range())
539 && text
540 .chars()
541 .next()
542 .is_some_and(|character| character.is_ascii_uppercase())
543 {
544 identifiers.insert(text.to_owned());
545 }
546 let mut cursor = current.walk();
547 let children = current.named_children(&mut cursor).collect::<Vec<_>>();
548 stack.extend(children.into_iter().rev());
549 }
550 identifiers
551}
552
553pub fn receiver_query_supported(file: &ProjectFile) -> bool {
558 file.rel_path()
559 .extension()
560 .and_then(|extension| extension.to_str())
561 != Some("c")
562}
563
564#[cfg(windows)]
565fn strip_path_prefix(path: &Path, root: &Path) -> Option<PathBuf> {
566 let mut path_components = path.components();
567 for root_component in root.components() {
568 let path_component = path_components.next()?;
569 if !path_component
570 .as_os_str()
571 .eq_ignore_ascii_case(root_component.as_os_str())
572 {
573 return None;
574 }
575 }
576 Some(path_components.as_path().to_path_buf())
577}
578
579#[cfg(not(windows))]
580fn strip_path_prefix(path: &Path, root: &Path) -> Option<PathBuf> {
581 path.strip_prefix(root).ok().map(Path::to_path_buf)
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587 use brokk_bifrost_core::test_support::write_file;
588 use tempfile::TempDir;
589
590 #[test]
591 #[brokk_bifrost_test_macros::hardwon(
592 "4b224ec22cdb: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
593 )]
594 fn indexed_include_resolution_uses_unique_suffix_fallback() {
595 let temp = TempDir::new().unwrap();
596 let root = temp.path().canonicalize().unwrap();
597 let source = write_file(&root, "src/lib.c", "");
598 let target = write_file(&root, "include/git2/sys/credential.h", "");
599 let duplicate = write_file(&root, "vendor/credential.h", "");
600 let index = IncludeTargetIndex::build([&source, &target, &duplicate]);
601
602 let resolved = resolve_include_targets_with_index(&source, "git2/sys/credential.h", &index);
603 assert_eq!(resolved, vec![target]);
604
605 let ambiguous = resolve_include_targets_with_index(&source, "credential.h", &index);
606 assert!(ambiguous.is_empty());
607 }
608
609 #[test]
610 #[brokk_bifrost_test_macros::hardwon(
611 "cf45b06757c2: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
612 )]
613 fn indexed_include_resolution_prefers_unique_source_reachable_root() {
614 let temp = TempDir::new().unwrap();
615 let root = temp.path().canonicalize().unwrap();
616 let source = write_file(&root, "src/config/parse.c", "");
617 let target = write_file(&root, "src/config/parse.h", "");
618 let nested_decoy = write_file(&root, "src/build/config/parse.h", "");
619 let unrelated_source = write_file(&root, "app/main.c", "");
620 let index = IncludeTargetIndex::build([&source, &target, &nested_decoy, &unrelated_source]);
621
622 let resolved = resolve_include_targets_with_index(&source, "config/parse.h", &index);
623 assert_eq!(resolved, vec![target.clone()]);
624
625 let unrelated =
626 resolve_include_targets_with_index(&unrelated_source, "config/parse.h", &index);
627 assert!(unrelated.is_empty());
628
629 let ambiguous_source = write_file(&root, "src/config/deeper/main.c", "");
630 let second_reachable = write_file(&root, "src/config/config/parse.h", "");
631 let ambiguous_index = IncludeTargetIndex::build([
632 &ambiguous_source,
633 &target,
634 &nested_decoy,
635 &second_reachable,
636 ]);
637 let ambiguous = resolve_include_targets_with_index(
638 &ambiguous_source,
639 "config/parse.h",
640 &ambiguous_index,
641 );
642 assert!(ambiguous.is_empty());
643 }
644
645 #[test]
646 #[brokk_bifrost_test_macros::hardwon(
647 "d8cf28cfbabb: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
648 )]
649 fn indexed_include_resolution_accepts_one_unique_installed_prefix_alias() {
650 let temp = TempDir::new().unwrap();
651 let root = temp.path().canonicalize().unwrap();
652 let source = write_file(&root, "src/lib/asn1/asn1_obj.cpp", "");
653 let target = write_file(&root, "src/lib/asn1/asn1_obj.h", "");
654 let index = IncludeTargetIndex::build([&source, &target]);
655
656 assert_eq!(
657 resolve_include_targets_with_index(&source, "botan/asn1_obj.h", &index),
658 vec![target.clone()]
659 );
660
661 let duplicate = write_file(&root, "vendor/asn1_obj.h", "");
662 let ambiguous = IncludeTargetIndex::build([&source, &target, &duplicate]);
663 assert!(
664 resolve_include_targets_with_index(&source, "botan/asn1_obj.h", &ambiguous).is_empty()
665 );
666 }
667
668 #[test]
669 #[brokk_bifrost_test_macros::hardwon(
670 "53f52b136fc7: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
671 )]
672 fn unresolved_h_include_falls_back_to_hin_template() {
673 let temp = TempDir::new().unwrap();
674 let root = temp.path().canonicalize().unwrap();
675 let stub = write_file(&root, "src/include/krb5.h", "");
676 let template = write_file(&root, "src/include/krb5/krb5.hin", "");
677 let index = IncludeTargetIndex::build([&stub, &template]);
678
679 let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
680 assert_eq!(resolved, vec![template]);
681 }
682
683 #[test]
684 #[brokk_bifrost_test_macros::hardwon(
685 "53f52b136fc7: C/C++ include resolution missed the unique source-reachable header or selected a conflicting path"
686 )]
687 fn real_header_wins_over_hin_template() {
688 let temp = TempDir::new().unwrap();
689 let root = temp.path().canonicalize().unwrap();
690 let stub = write_file(&root, "src/include/krb5.h", "");
691 let generated = write_file(&root, "src/include/krb5/krb5.h", "");
692 let template = write_file(&root, "src/include/krb5/krb5.hin", "");
693 let index = IncludeTargetIndex::build([&stub, &generated, &template]);
694
695 let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
696 assert_eq!(resolved, vec![generated]);
697 }
698}