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_utils::path_suffix_key;
13use regex::Regex;
14use std::collections::BTreeSet;
15use std::path::{Path, PathBuf};
16use std::sync::OnceLock;
17
18pub struct IncludeTargetIndex {
25 by_rel_path: HashMap<PathBuf, Vec<ProjectFile>>,
26 by_file_name: HashMap<String, Vec<ProjectFile>>,
27}
28
29impl IncludeTargetIndex {
30 pub fn build<'a>(files: impl IntoIterator<Item = &'a ProjectFile>) -> Self {
31 let mut by_rel_path: HashMap<PathBuf, Vec<ProjectFile>> = HashMap::default();
32 let mut by_file_name: HashMap<String, Vec<ProjectFile>> = HashMap::default();
33 for file in files {
34 by_rel_path
35 .entry(file.rel_path().to_path_buf())
36 .or_default()
37 .push(file.clone());
38 if let Some(file_name) = file.rel_path().file_name().and_then(|value| value.to_str()) {
39 by_file_name
40 .entry(file_name.to_string())
41 .or_default()
42 .push(file.clone());
43 }
44 }
45 Self {
46 by_rel_path,
47 by_file_name,
48 }
49 }
50
51 pub fn resolve_indexed(&self, include: &str) -> Vec<ProjectFile> {
52 let include_path = Path::new(include);
53 let mut matched = HashSet::default();
54 let mut resolved = Vec::new();
55 if let Some(targets) = self.by_rel_path.get(include_path) {
56 for target in targets {
57 if matched.insert(target.clone()) {
58 resolved.push(target.clone());
59 }
60 }
61 }
62 for suffix in string_suffixes(include) {
63 if let Some(targets) = self.by_file_name.get(suffix) {
64 for target in targets {
65 if matched.insert(target.clone()) {
66 resolved.push(target.clone());
67 }
68 }
69 }
70 }
71 resolved
72 }
73
74 fn resolve_direct(&self, source_file: &ProjectFile, include: &str) -> Vec<ProjectFile> {
75 let include_path = Path::new(include);
76 let mut matched = HashSet::default();
77 let mut resolved = Vec::new();
78 if include_path.is_absolute() {
79 if let Some(rel_path) = project_relative_include_path(source_file.root(), include_path)
80 {
81 self.extend_rel_path(&rel_path, &mut matched, &mut resolved);
82 }
83 return resolved;
84 }
85
86 let source_relative = ProjectFile::new(
87 source_file.root().to_path_buf(),
88 source_file.parent().join(include_path),
89 );
90 self.extend_rel_path(source_relative.rel_path(), &mut matched, &mut resolved);
91
92 let project_relative =
93 ProjectFile::new(source_file.root().to_path_buf(), include_path.to_path_buf());
94 self.extend_rel_path(project_relative.rel_path(), &mut matched, &mut resolved);
95 resolved
96 }
97
98 fn extend_rel_path(
99 &self,
100 rel_path: &Path,
101 matched: &mut HashSet<ProjectFile>,
102 out: &mut Vec<ProjectFile>,
103 ) {
104 if let Some(targets) = self.by_rel_path.get(rel_path) {
105 for target in targets {
106 if matched.insert(target.clone()) {
107 out.push(target.clone());
108 }
109 }
110 }
111 }
112
113 fn resolve_unique_fallback(
114 &self,
115 source_file: &ProjectFile,
116 include: &str,
117 ) -> Vec<ProjectFile> {
118 let include_path = Path::new(include);
119 let indexed = self.resolve_indexed(include);
120 let matches: Vec<_> = indexed
121 .into_iter()
122 .filter(|file| {
123 if include_path.components().count() > 1 {
124 file.rel_path().ends_with(include_path)
125 } else {
126 file.rel_path()
127 .file_name()
128 .is_some_and(|name| name == include_path)
129 }
130 })
131 .collect();
132 if matches.len() == 1 {
133 return matches;
134 }
135 let source_reachable = matches
136 .into_iter()
137 .filter(|file| {
138 (0..include_path.components().count())
139 .try_fold(file.rel_path(), |path, _| path.parent())
140 .is_some_and(|root| source_file.rel_path().starts_with(root))
141 })
142 .collect::<Vec<_>>();
143 if source_reachable.len() == 1 {
144 return source_reachable;
145 }
146 Vec::new()
147 }
148
149 fn resolve_unique_basename_alias(&self, include: &str) -> Vec<ProjectFile> {
150 let include_path = Path::new(include);
151 if include_path.components().count() <= 1 {
152 return Vec::new();
153 }
154 let Some(file_name) = include_path.file_name() else {
155 return Vec::new();
156 };
157 let matches = self
158 .resolve_indexed(include)
159 .into_iter()
160 .filter(|file| file.rel_path().file_name() == Some(file_name))
161 .collect::<Vec<_>>();
162 if matches.len() == 1 {
163 matches
164 } else {
165 Vec::new()
166 }
167 }
168}
169
170fn string_suffixes(value: &str) -> impl Iterator<Item = &str> {
171 value.char_indices().map(|(index, _)| &value[index..])
172}
173
174pub fn parse_quoted_include(line: &str) -> Option<String> {
175 let trimmed = line.trim();
176 let quote_start = trimmed.find('"')?;
177 let quote_end = trimmed[quote_start + 1..].find('"')?;
178 Some(trimmed[quote_start + 1..quote_start + 1 + quote_end].to_string())
179}
180
181pub fn parse_include_path(line: &str) -> Option<String> {
182 if let Some(path) = parse_quoted_include(line) {
183 return Some(path);
184 }
185 let trimmed = line.trim();
186 let angle_start = trimmed.find('<')?;
187 let angle_end = trimmed[angle_start + 1..].find('>')?;
188 Some(trimmed[angle_start + 1..angle_start + 1 + angle_end].to_string())
189}
190
191pub fn resolve_include_targets(
192 project: &dyn Project,
193 source_file: &ProjectFile,
194 include: &str,
195) -> Vec<ProjectFile> {
196 let mut candidates = Vec::new();
197 let include_path = Path::new(include);
198 let source_root = project.root().to_path_buf();
199 let relative_path = if include_path.is_absolute() {
200 match project_relative_include_path(project.root(), include_path) {
201 Some(path) => path,
202 None => return candidates,
203 }
204 } else {
205 source_file.parent().join(include_path)
206 };
207 let relative_file = ProjectFile::new(source_root.clone(), relative_path);
208 if relative_file.exists() {
209 candidates.push(relative_file);
210 }
211 if !include_path.is_absolute() {
212 let project_relative_file = ProjectFile::new(source_root.clone(), include_path);
213 if project_relative_file.exists() {
214 candidates.push(project_relative_file);
215 }
216 }
217
218 candidates.sort();
219 candidates.dedup();
220 candidates
221}
222
223pub fn resolve_include_targets_with_index(
224 source_file: &ProjectFile,
225 include: &str,
226 include_targets: &IncludeTargetIndex,
227) -> Vec<ProjectFile> {
228 let mut candidates = include_targets.resolve_direct(source_file, include);
229 if !candidates.is_empty() {
230 return candidates;
231 }
232 if Path::new(include).is_absolute() {
233 return candidates;
234 }
235 candidates.extend(include_targets.resolve_unique_fallback(source_file, include));
236 if candidates.is_empty()
237 && let Some(template) = header_template_include_spelling(include)
238 {
239 candidates = include_targets.resolve_direct(source_file, &template);
240 if candidates.is_empty() {
241 candidates.extend(include_targets.resolve_unique_fallback(source_file, &template));
242 }
243 }
244 if candidates.is_empty() {
250 candidates = include_targets.resolve_unique_basename_alias(include);
251 }
252 candidates
253}
254
255fn header_template_include_spelling(include: &str) -> Option<String> {
263 let path = Path::new(include);
264 (path.extension() == Some(std::ffi::OsStr::new("h")))
265 .then(|| path.with_extension("hin").to_string_lossy().into_owned())
266}
267
268pub fn resolve_direct_include_targets_with_index(
269 source_file: &ProjectFile,
270 include: &str,
271 include_targets: &IncludeTargetIndex,
272) -> Vec<ProjectFile> {
273 include_targets.resolve_direct(source_file, include)
274}
275
276fn project_relative_include_path(project_root: &Path, include_path: &Path) -> Option<PathBuf> {
277 let canonical_root = project_root
278 .canonicalize()
279 .unwrap_or_else(|_| project_root.to_path_buf());
280 let canonical_include = include_path
281 .canonicalize()
282 .unwrap_or_else(|_| include_path.to_path_buf());
283 canonical_include
284 .strip_prefix(&canonical_root)
285 .map(Path::to_path_buf)
286 .or_else(|_| {
287 include_path
288 .strip_prefix(project_root)
289 .map(Path::to_path_buf)
290 })
291 .ok()
292 .or_else(|| lexical_project_relative_include_path(&canonical_root, &canonical_include))
293 .or_else(|| lexical_project_relative_include_path(project_root, include_path))
294}
295
296pub fn included_claimable_files(
318 sources: &[(ProjectFile, Vec<ImportInfo>)],
319 claimable: &BTreeSet<ProjectFile>,
320) -> HashMap<ProjectFile, BTreeSet<ProjectFile>> {
321 let mut edges: HashMap<ProjectFile, BTreeSet<ProjectFile>> = HashMap::default();
322 if claimable.is_empty() || sources.is_empty() {
323 return edges;
324 }
325 let index = IncludeTargetIndex::build(claimable.iter());
326 for (source_file, imports) in sources {
327 let mut targets = BTreeSet::new();
328 for include in imports
329 .iter()
330 .filter_map(|import| parse_quoted_include(&import.raw_snippet))
331 {
332 targets.extend(resolve_include_targets_with_index(
333 source_file,
334 &include,
335 &index,
336 ));
337 }
338 if !targets.is_empty() {
339 edges.insert(source_file.clone(), targets);
340 }
341 }
342 edges
343}
344
345pub fn claimable_include_demand(
378 sources: &[(ProjectFile, Vec<ImportInfo>)],
379) -> HashMap<ProjectFile, BTreeSet<String>> {
380 let mut demand: HashMap<ProjectFile, BTreeSet<String>> = HashMap::default();
381 for (source_file, imports) in sources {
382 let mut keys = BTreeSet::new();
383 for include in imports
384 .iter()
385 .filter_map(|import| parse_quoted_include(&import.raw_snippet))
386 {
387 let template = header_template_include_spelling(&include);
388 for spelling in std::iter::once(include).chain(template) {
389 collect_include_demand_keys(source_file, &spelling, &mut keys);
390 }
391 }
392 if !keys.is_empty() {
393 demand.insert(source_file.clone(), keys);
394 }
395 }
396 demand
397}
398
399fn collect_include_demand_keys(
400 source_file: &ProjectFile,
401 include: &str,
402 keys: &mut BTreeSet<String>,
403) {
404 let include_path = Path::new(include);
405 let claimable_spelling = include_path
406 .extension()
407 .and_then(|extension| extension.to_str())
408 .is_none_or(|extension| !Language::is_source_extension(extension));
409 if !claimable_spelling {
410 return;
411 }
412 if include_path.is_absolute() {
413 if let Some(rel_path) = project_relative_include_path(source_file.root(), include_path)
417 && let Some(key) = path_suffix_key(&rel_path)
418 {
419 keys.insert(key);
420 }
421 return;
422 }
423 if let Some(key) = path_suffix_key(include_path) {
424 keys.insert(key);
425 }
426}
427
428pub fn quoted_include_paths(parsed: &[String]) -> Vec<String> {
429 parsed
430 .iter()
431 .filter_map(|line| parse_quoted_include(line))
432 .collect()
433}
434
435pub fn include_paths(parsed: &[String]) -> Vec<String> {
436 parsed
437 .iter()
438 .filter_map(|line| parse_include_path(line))
439 .collect()
440}
441
442pub fn extract_type_identifiers(source: &str) -> BTreeSet<String> {
453 static IDENT_RE: OnceLock<Regex> = OnceLock::new();
454 let regex =
455 IDENT_RE.get_or_init(|| Regex::new(r"[A-Za-z_][A-Za-z0-9_:<>]*").expect("valid regex"));
456 regex
457 .find_iter(source)
458 .map(|m| m.as_str())
459 .filter(|token| {
460 token
461 .chars()
462 .next()
463 .is_some_and(|ch| ch.is_ascii_uppercase())
464 })
465 .map(|token| token.trim_matches(':').to_string())
466 .collect()
467}
468
469pub fn receiver_query_supported(file: &ProjectFile) -> bool {
474 file.rel_path()
475 .extension()
476 .and_then(|extension| extension.to_str())
477 != Some("c")
478}
479
480fn lexical_project_relative_include_path(
481 project_root: &Path,
482 include_path: &Path,
483) -> Option<PathBuf> {
484 let root = slash_path(project_root);
485 let include = slash_path(include_path);
486 strip_slash_prefix(&include, &root).map(PathBuf::from)
487}
488
489fn slash_path(path: &Path) -> String {
490 let raw = path.to_string_lossy();
491 let raw = raw.strip_prefix(r"\\?\").unwrap_or(&raw);
492 raw.replace('\\', "/").trim_end_matches('/').to_string()
493}
494
495#[cfg(windows)]
496fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
497 if path.eq_ignore_ascii_case(root) {
498 return Some("");
499 }
500 if path.len() > root.len()
501 && path.as_bytes().get(root.len()) == Some(&b'/')
502 && path[..root.len()].eq_ignore_ascii_case(root)
503 {
504 return Some(&path[root.len() + 1..]);
505 }
506 None
507}
508
509#[cfg(not(windows))]
510fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
511 if path == root {
512 return Some("");
513 }
514 path.strip_prefix(root)
515 .and_then(|rest| rest.strip_prefix('/'))
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use std::fs;
522 use tempfile::TempDir;
523
524 fn write_file(root: &Path, rel: &str) -> ProjectFile {
525 let path = root.join(rel);
526 fs::create_dir_all(path.parent().expect("test file has parent")).unwrap();
527 fs::write(&path, "").unwrap();
528 ProjectFile::new(root.to_path_buf(), rel)
529 }
530
531 #[test]
532 fn indexed_include_resolution_uses_unique_suffix_fallback() {
533 let temp = TempDir::new().unwrap();
534 let root = temp.path().canonicalize().unwrap();
535 let source = write_file(&root, "src/lib.c");
536 let target = write_file(&root, "include/git2/sys/credential.h");
537 let duplicate = write_file(&root, "vendor/credential.h");
538 let index = IncludeTargetIndex::build([&source, &target, &duplicate]);
539
540 let resolved = resolve_include_targets_with_index(&source, "git2/sys/credential.h", &index);
541 assert_eq!(resolved, vec![target]);
542
543 let ambiguous = resolve_include_targets_with_index(&source, "credential.h", &index);
544 assert!(ambiguous.is_empty());
545 }
546
547 #[test]
548 fn indexed_include_resolution_prefers_unique_source_reachable_root() {
549 let temp = TempDir::new().unwrap();
550 let root = temp.path().canonicalize().unwrap();
551 let source = write_file(&root, "src/config/parse.c");
552 let target = write_file(&root, "src/config/parse.h");
553 let nested_decoy = write_file(&root, "src/build/config/parse.h");
554 let unrelated_source = write_file(&root, "app/main.c");
555 let index = IncludeTargetIndex::build([&source, &target, &nested_decoy, &unrelated_source]);
556
557 let resolved = resolve_include_targets_with_index(&source, "config/parse.h", &index);
558 assert_eq!(resolved, vec![target.clone()]);
559
560 let unrelated =
561 resolve_include_targets_with_index(&unrelated_source, "config/parse.h", &index);
562 assert!(unrelated.is_empty());
563
564 let ambiguous_source = write_file(&root, "src/config/deeper/main.c");
565 let second_reachable = write_file(&root, "src/config/config/parse.h");
566 let ambiguous_index = IncludeTargetIndex::build([
567 &ambiguous_source,
568 &target,
569 &nested_decoy,
570 &second_reachable,
571 ]);
572 let ambiguous = resolve_include_targets_with_index(
573 &ambiguous_source,
574 "config/parse.h",
575 &ambiguous_index,
576 );
577 assert!(ambiguous.is_empty());
578 }
579
580 #[test]
581 fn indexed_include_resolution_accepts_one_unique_installed_prefix_alias() {
582 let temp = TempDir::new().unwrap();
583 let root = temp.path().canonicalize().unwrap();
584 let source = write_file(&root, "src/lib/asn1/asn1_obj.cpp");
585 let target = write_file(&root, "src/lib/asn1/asn1_obj.h");
586 let index = IncludeTargetIndex::build([&source, &target]);
587
588 assert_eq!(
589 resolve_include_targets_with_index(&source, "botan/asn1_obj.h", &index),
590 vec![target.clone()]
591 );
592
593 let duplicate = write_file(&root, "vendor/asn1_obj.h");
594 let ambiguous = IncludeTargetIndex::build([&source, &target, &duplicate]);
595 assert!(
596 resolve_include_targets_with_index(&source, "botan/asn1_obj.h", &ambiguous).is_empty()
597 );
598 }
599
600 #[test]
601 fn unresolved_h_include_falls_back_to_hin_template() {
602 let temp = TempDir::new().unwrap();
603 let root = temp.path().canonicalize().unwrap();
604 let stub = write_file(&root, "src/include/krb5.h");
605 let template = write_file(&root, "src/include/krb5/krb5.hin");
606 let index = IncludeTargetIndex::build([&stub, &template]);
607
608 let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
609 assert_eq!(resolved, vec![template]);
610 }
611
612 #[test]
613 fn real_header_wins_over_hin_template() {
614 let temp = TempDir::new().unwrap();
615 let root = temp.path().canonicalize().unwrap();
616 let stub = write_file(&root, "src/include/krb5.h");
617 let generated = write_file(&root, "src/include/krb5/krb5.h");
618 let template = write_file(&root, "src/include/krb5/krb5.hin");
619 let index = IncludeTargetIndex::build([&stub, &generated, &template]);
620
621 let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
622 assert_eq!(resolved, vec![generated]);
623 }
624}