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
20pub struct IncludeTargetIndex {
27 by_rel_path: HashMap<PathBuf, Vec<ProjectFile>>,
28 by_file_name: HashMap<String, Vec<ProjectFile>>,
29}
30
31pub fn canonical_include_paths(facts: &CppFileSourceFacts) -> Vec<String> {
37 facts
38 .facts
39 .includes
40 .iter()
41 .map(|include| include.path.clone())
42 .collect()
43}
44
45pub fn canonical_include_statements(facts: &CppFileSourceFacts) -> Vec<String> {
49 facts
50 .facts
51 .includes
52 .iter()
53 .map(|include| canonical_include_statement(&include.path, include.quoted))
54 .collect()
55}
56
57pub fn canonical_include_statement(path: &str, quoted: bool) -> String {
59 if quoted {
60 format!("#include \"{path}\"")
61 } else {
62 format!("#include <{path}>")
63 }
64}
65
66impl IncludeTargetIndex {
67 pub fn build<'a>(files: impl IntoIterator<Item = &'a ProjectFile>) -> Self {
68 let mut by_rel_path: HashMap<PathBuf, Vec<ProjectFile>> = HashMap::default();
69 let mut by_file_name: HashMap<String, Vec<ProjectFile>> = HashMap::default();
70 for file in files {
71 by_rel_path
72 .entry(file.rel_path().to_path_buf())
73 .or_default()
74 .push(file.clone());
75 if let Some(file_name) = file.rel_path().file_name().and_then(|value| value.to_str()) {
76 by_file_name
77 .entry(file_name.to_string())
78 .or_default()
79 .push(file.clone());
80 }
81 }
82 Self {
83 by_rel_path,
84 by_file_name,
85 }
86 }
87
88 pub fn resolve_indexed(&self, include: &str) -> Vec<ProjectFile> {
89 let include_path = Path::new(include);
90 let mut matched = HashSet::default();
91 let mut resolved = Vec::new();
92 if let Some(targets) = self.by_rel_path.get(include_path) {
93 for target in targets {
94 if matched.insert(target.clone()) {
95 resolved.push(target.clone());
96 }
97 }
98 }
99 for suffix in string_suffixes(include) {
100 if let Some(targets) = self.by_file_name.get(suffix) {
101 for target in targets {
102 if matched.insert(target.clone()) {
103 resolved.push(target.clone());
104 }
105 }
106 }
107 }
108 resolved
109 }
110
111 pub fn names_indexed_file(&self, include: &str) -> bool {
120 Path::new(include)
121 .file_name()
122 .and_then(|name| name.to_str())
123 .is_some_and(|name| self.by_file_name.contains_key(name))
124 }
125
126 fn resolve_direct(&self, source_file: &ProjectFile, include: &str) -> Vec<ProjectFile> {
127 let include_path = Path::new(include);
128 let mut matched = HashSet::default();
129 let mut resolved = Vec::new();
130 if include_path.is_absolute() {
131 if let Some(rel_path) = project_relative_include_path(source_file.root(), include_path)
132 {
133 self.extend_rel_path(&rel_path, &mut matched, &mut resolved);
134 }
135 return resolved;
136 }
137
138 let source_relative = ProjectFile::new(
139 source_file.root().to_path_buf(),
140 source_file.parent().join(include_path),
141 );
142 self.extend_rel_path(source_relative.rel_path(), &mut matched, &mut resolved);
143
144 let project_relative =
145 ProjectFile::new(source_file.root().to_path_buf(), include_path.to_path_buf());
146 self.extend_rel_path(project_relative.rel_path(), &mut matched, &mut resolved);
147 resolved
148 }
149
150 fn extend_rel_path(
151 &self,
152 rel_path: &Path,
153 matched: &mut HashSet<ProjectFile>,
154 out: &mut Vec<ProjectFile>,
155 ) {
156 if let Some(targets) = self.by_rel_path.get(rel_path) {
157 for target in targets {
158 if matched.insert(target.clone()) {
159 out.push(target.clone());
160 }
161 }
162 }
163 }
164
165 fn resolve_unique_fallback(
166 &self,
167 source_file: &ProjectFile,
168 include: &str,
169 ) -> Vec<ProjectFile> {
170 let include_path = Path::new(include);
171 let indexed = self.resolve_indexed(include);
172 let matches: Vec<_> = indexed
173 .into_iter()
174 .filter(|file| {
175 if include_path.components().count() > 1 {
176 file.rel_path().ends_with(include_path)
177 } else {
178 file.rel_path()
179 .file_name()
180 .is_some_and(|name| name == include_path)
181 }
182 })
183 .collect();
184 if matches.len() == 1 {
185 return matches;
186 }
187 let source_reachable = matches
188 .into_iter()
189 .filter(|file| {
190 (0..include_path.components().count())
191 .try_fold(file.rel_path(), |path, _| path.parent())
192 .is_some_and(|root| source_file.rel_path().starts_with(root))
193 })
194 .collect::<Vec<_>>();
195 if source_reachable.len() == 1 {
196 return source_reachable;
197 }
198 Vec::new()
199 }
200
201 fn resolve_unique_basename_alias(&self, include: &str) -> Vec<ProjectFile> {
202 let include_path = Path::new(include);
203 if include_path.components().count() <= 1 {
204 return Vec::new();
205 }
206 let Some(file_name) = include_path.file_name() else {
207 return Vec::new();
208 };
209 let matches = self
210 .resolve_indexed(include)
211 .into_iter()
212 .filter(|file| file.rel_path().file_name() == Some(file_name))
213 .collect::<Vec<_>>();
214 if matches.len() == 1 {
215 matches
216 } else {
217 Vec::new()
218 }
219 }
220}
221
222fn string_suffixes(value: &str) -> impl Iterator<Item = &str> {
223 value.char_indices().map(|(index, _)| &value[index..])
224}
225
226pub fn parse_quoted_include(line: &str) -> Option<String> {
227 let trimmed = line.trim();
228 let quote_start = trimmed.find('"')?;
229 let quote_end = trimmed[quote_start + 1..].find('"')?;
230 Some(trimmed[quote_start + 1..quote_start + 1 + quote_end].to_string())
231}
232
233pub fn parse_include_path(line: &str) -> Option<String> {
234 if let Some(path) = parse_quoted_include(line) {
235 return Some(path);
236 }
237 let trimmed = line.trim();
238 let angle_start = trimmed.find('<')?;
239 let angle_end = trimmed[angle_start + 1..].find('>')?;
240 Some(trimmed[angle_start + 1..angle_start + 1 + angle_end].to_string())
241}
242
243pub fn resolve_include_targets(
244 project: &dyn Project,
245 source_file: &ProjectFile,
246 include: &str,
247) -> Vec<ProjectFile> {
248 let mut candidates = Vec::new();
249 let include_path = Path::new(include);
250 let source_root = project.root().to_path_buf();
251 let relative_path = if include_path.is_absolute() {
252 match project_relative_include_path(project.root(), include_path) {
253 Some(path) => path,
254 None => return candidates,
255 }
256 } else {
257 source_file.parent().join(include_path)
258 };
259 let relative_file = ProjectFile::new(source_root.clone(), relative_path);
260 if relative_file.exists() {
261 candidates.push(relative_file);
262 }
263 if !include_path.is_absolute() {
264 let project_relative_file = ProjectFile::new(source_root.clone(), include_path);
265 if project_relative_file.exists() {
266 candidates.push(project_relative_file);
267 }
268 }
269
270 candidates.sort();
271 candidates.dedup();
272 candidates
273}
274
275pub fn resolve_include_targets_with_index(
276 source_file: &ProjectFile,
277 include: &str,
278 include_targets: &IncludeTargetIndex,
279) -> Vec<ProjectFile> {
280 let mut candidates = include_targets.resolve_direct(source_file, include);
281 if !candidates.is_empty() {
282 return candidates;
283 }
284 if Path::new(include).is_absolute() {
285 return candidates;
286 }
287 candidates.extend(include_targets.resolve_unique_fallback(source_file, include));
288 if candidates.is_empty()
289 && let Some(template) = header_template_include_spelling(include)
290 {
291 candidates = include_targets.resolve_direct(source_file, &template);
292 if candidates.is_empty() {
293 candidates.extend(include_targets.resolve_unique_fallback(source_file, &template));
294 }
295 }
296 if candidates.is_empty() {
302 candidates = include_targets.resolve_unique_basename_alias(include);
303 }
304 candidates
305}
306
307fn header_template_include_spelling(include: &str) -> Option<String> {
315 let path = Path::new(include);
316 (path.extension() == Some(std::ffi::OsStr::new("h")))
317 .then(|| path.with_extension("hin").to_string_lossy().into_owned())
318}
319
320pub fn resolve_direct_include_targets_with_index(
321 source_file: &ProjectFile,
322 include: &str,
323 include_targets: &IncludeTargetIndex,
324) -> Vec<ProjectFile> {
325 include_targets.resolve_direct(source_file, include)
326}
327
328fn project_relative_include_path(project_root: &Path, include_path: &Path) -> Option<PathBuf> {
329 let canonical_root = project_root
330 .canonicalize()
331 .unwrap_or_else(|_| project_root.to_path_buf())
332 .normalize();
333 let canonical_include = include_path
334 .canonicalize()
335 .unwrap_or_else(|_| include_path.to_path_buf())
336 .normalize();
337 strip_path_prefix(&canonical_include, &canonical_root).or_else(|| {
338 strip_path_prefix(
339 &include_path.to_path_buf().normalize(),
340 &project_root.to_path_buf().normalize(),
341 )
342 })
343}
344
345pub fn included_claimable_files(
367 sources: &[(ProjectFile, Vec<ImportInfo>)],
368 claimable: &BTreeSet<ProjectFile>,
369) -> HashMap<ProjectFile, BTreeSet<ProjectFile>> {
370 let mut edges: HashMap<ProjectFile, BTreeSet<ProjectFile>> = HashMap::default();
371 if claimable.is_empty() || sources.is_empty() {
372 return edges;
373 }
374 let index = IncludeTargetIndex::build(claimable.iter());
375 for (source_file, imports) in sources {
376 let mut targets = BTreeSet::new();
377 for include in imports
378 .iter()
379 .filter_map(|import| parse_quoted_include(&import.raw_snippet))
380 {
381 targets.extend(resolve_include_targets_with_index(
382 source_file,
383 &include,
384 &index,
385 ));
386 }
387 if !targets.is_empty() {
388 edges.insert(source_file.clone(), targets);
389 }
390 }
391 edges
392}
393
394pub fn claimable_include_demand(
427 sources: &[(ProjectFile, Vec<ImportInfo>)],
428) -> HashMap<ProjectFile, BTreeSet<String>> {
429 let mut demand: HashMap<ProjectFile, BTreeSet<String>> = HashMap::default();
430 for (source_file, imports) in sources {
431 let mut keys = BTreeSet::new();
432 for include in imports
433 .iter()
434 .filter_map(|import| parse_quoted_include(&import.raw_snippet))
435 {
436 let template = header_template_include_spelling(&include);
437 for spelling in std::iter::once(include).chain(template) {
438 collect_include_demand_keys(source_file, &spelling, &mut keys);
439 }
440 }
441 if !keys.is_empty() {
442 demand.insert(source_file.clone(), keys);
443 }
444 }
445 demand
446}
447
448fn collect_include_demand_keys(
449 source_file: &ProjectFile,
450 include: &str,
451 keys: &mut BTreeSet<String>,
452) {
453 let include_path = Path::new(include);
454 let claimable_spelling = include_path
455 .extension()
456 .and_then(|extension| extension.to_str())
457 .is_none_or(|extension| !Language::is_source_extension(extension));
458 if !claimable_spelling {
459 return;
460 }
461 if include_path.is_absolute() {
462 if let Some(rel_path) = project_relative_include_path(source_file.root(), include_path)
466 && let Some(key) = path_suffix_key(&rel_path)
467 {
468 keys.insert(key);
469 }
470 return;
471 }
472 if let Some(key) = path_suffix_key(include_path) {
473 keys.insert(key);
474 }
475}
476
477pub fn quoted_include_paths(parsed: &[String]) -> Vec<String> {
478 parsed
479 .iter()
480 .filter_map(|line| parse_quoted_include(line))
481 .collect()
482}
483
484pub fn include_paths(parsed: &[String]) -> Vec<String> {
485 parsed
486 .iter()
487 .filter_map(|line| parse_include_path(line))
488 .collect()
489}
490
491pub fn include_path_from_node<'a>(node: Node<'_>, source: &'a str) -> Option<(&'a str, bool)> {
497 if node.kind() != "preproc_include" {
498 return None;
499 }
500 let path = node.child_by_field_name("path")?;
501 let text = source.get(path.byte_range())?;
502 match path.kind() {
503 "string_literal" => Some((
504 text.strip_prefix('"')?
505 .strip_suffix('"')
506 .filter(|path| !path.is_empty())?,
507 true,
508 )),
509 "system_lib_string" => Some((
510 text.strip_prefix('<')?
511 .strip_suffix('>')
512 .filter(|path| !path.is_empty())?,
513 false,
514 )),
515 _ => None,
516 }
517}
518
519pub fn extract_type_identifiers(node: Node<'_>, source: &str) -> BTreeSet<String> {
526 let mut identifiers = BTreeSet::new();
527 let mut stack = vec![node];
528 while let Some(current) = stack.pop() {
529 if matches!(
530 current.kind(),
531 "identifier" | "type_identifier" | "namespace_identifier"
532 ) && let Some(text) = source.get(current.byte_range())
533 && text
534 .chars()
535 .next()
536 .is_some_and(|character| character.is_ascii_uppercase())
537 {
538 identifiers.insert(text.to_owned());
539 }
540 let mut cursor = current.walk();
541 let children = current.named_children(&mut cursor).collect::<Vec<_>>();
542 stack.extend(children.into_iter().rev());
543 }
544 identifiers
545}
546
547pub fn receiver_query_supported(file: &ProjectFile) -> bool {
552 file.rel_path()
553 .extension()
554 .and_then(|extension| extension.to_str())
555 != Some("c")
556}
557
558#[cfg(windows)]
559fn strip_path_prefix(path: &Path, root: &Path) -> Option<PathBuf> {
560 let mut path_components = path.components();
561 for root_component in root.components() {
562 let path_component = path_components.next()?;
563 if !path_component
564 .as_os_str()
565 .eq_ignore_ascii_case(root_component.as_os_str())
566 {
567 return None;
568 }
569 }
570 Some(path_components.as_path().to_path_buf())
571}
572
573#[cfg(not(windows))]
574fn strip_path_prefix(path: &Path, root: &Path) -> Option<PathBuf> {
575 path.strip_prefix(root).ok().map(Path::to_path_buf)
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581 use std::fs;
582 use tempfile::TempDir;
583
584 fn write_file(root: &Path, rel: &str) -> ProjectFile {
585 let path = root.join(rel);
586 fs::create_dir_all(path.parent().expect("test file has parent")).unwrap();
587 fs::write(&path, "").unwrap();
588 ProjectFile::new(root.to_path_buf(), rel)
589 }
590
591 #[test]
592 fn indexed_include_resolution_uses_unique_suffix_fallback() {
593 let temp = TempDir::new().unwrap();
594 let root = temp.path().canonicalize().unwrap();
595 let source = write_file(&root, "src/lib.c");
596 let target = write_file(&root, "include/git2/sys/credential.h");
597 let duplicate = write_file(&root, "vendor/credential.h");
598 let index = IncludeTargetIndex::build([&source, &target, &duplicate]);
599
600 let resolved = resolve_include_targets_with_index(&source, "git2/sys/credential.h", &index);
601 assert_eq!(resolved, vec![target]);
602
603 let ambiguous = resolve_include_targets_with_index(&source, "credential.h", &index);
604 assert!(ambiguous.is_empty());
605 }
606
607 #[test]
608 fn indexed_include_resolution_prefers_unique_source_reachable_root() {
609 let temp = TempDir::new().unwrap();
610 let root = temp.path().canonicalize().unwrap();
611 let source = write_file(&root, "src/config/parse.c");
612 let target = write_file(&root, "src/config/parse.h");
613 let nested_decoy = write_file(&root, "src/build/config/parse.h");
614 let unrelated_source = write_file(&root, "app/main.c");
615 let index = IncludeTargetIndex::build([&source, &target, &nested_decoy, &unrelated_source]);
616
617 let resolved = resolve_include_targets_with_index(&source, "config/parse.h", &index);
618 assert_eq!(resolved, vec![target.clone()]);
619
620 let unrelated =
621 resolve_include_targets_with_index(&unrelated_source, "config/parse.h", &index);
622 assert!(unrelated.is_empty());
623
624 let ambiguous_source = write_file(&root, "src/config/deeper/main.c");
625 let second_reachable = write_file(&root, "src/config/config/parse.h");
626 let ambiguous_index = IncludeTargetIndex::build([
627 &ambiguous_source,
628 &target,
629 &nested_decoy,
630 &second_reachable,
631 ]);
632 let ambiguous = resolve_include_targets_with_index(
633 &ambiguous_source,
634 "config/parse.h",
635 &ambiguous_index,
636 );
637 assert!(ambiguous.is_empty());
638 }
639
640 #[test]
641 fn indexed_include_resolution_accepts_one_unique_installed_prefix_alias() {
642 let temp = TempDir::new().unwrap();
643 let root = temp.path().canonicalize().unwrap();
644 let source = write_file(&root, "src/lib/asn1/asn1_obj.cpp");
645 let target = write_file(&root, "src/lib/asn1/asn1_obj.h");
646 let index = IncludeTargetIndex::build([&source, &target]);
647
648 assert_eq!(
649 resolve_include_targets_with_index(&source, "botan/asn1_obj.h", &index),
650 vec![target.clone()]
651 );
652
653 let duplicate = write_file(&root, "vendor/asn1_obj.h");
654 let ambiguous = IncludeTargetIndex::build([&source, &target, &duplicate]);
655 assert!(
656 resolve_include_targets_with_index(&source, "botan/asn1_obj.h", &ambiguous).is_empty()
657 );
658 }
659
660 #[test]
661 fn unresolved_h_include_falls_back_to_hin_template() {
662 let temp = TempDir::new().unwrap();
663 let root = temp.path().canonicalize().unwrap();
664 let stub = write_file(&root, "src/include/krb5.h");
665 let template = write_file(&root, "src/include/krb5/krb5.hin");
666 let index = IncludeTargetIndex::build([&stub, &template]);
667
668 let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
669 assert_eq!(resolved, vec![template]);
670 }
671
672 #[test]
673 fn real_header_wins_over_hin_template() {
674 let temp = TempDir::new().unwrap();
675 let root = temp.path().canonicalize().unwrap();
676 let stub = write_file(&root, "src/include/krb5.h");
677 let generated = write_file(&root, "src/include/krb5/krb5.h");
678 let template = write_file(&root, "src/include/krb5/krb5.hin");
679 let index = IncludeTargetIndex::build([&stub, &generated, &template]);
680
681 let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
682 assert_eq!(resolved, vec![generated]);
683 }
684}