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 pub fn names_indexed_file(&self, include: &str) -> bool {
83 Path::new(include)
84 .file_name()
85 .and_then(|name| name.to_str())
86 .is_some_and(|name| self.by_file_name.contains_key(name))
87 }
88
89 fn resolve_direct(&self, source_file: &ProjectFile, include: &str) -> Vec<ProjectFile> {
90 let include_path = Path::new(include);
91 let mut matched = HashSet::default();
92 let mut resolved = Vec::new();
93 if include_path.is_absolute() {
94 if let Some(rel_path) = project_relative_include_path(source_file.root(), include_path)
95 {
96 self.extend_rel_path(&rel_path, &mut matched, &mut resolved);
97 }
98 return resolved;
99 }
100
101 let source_relative = ProjectFile::new(
102 source_file.root().to_path_buf(),
103 source_file.parent().join(include_path),
104 );
105 self.extend_rel_path(source_relative.rel_path(), &mut matched, &mut resolved);
106
107 let project_relative =
108 ProjectFile::new(source_file.root().to_path_buf(), include_path.to_path_buf());
109 self.extend_rel_path(project_relative.rel_path(), &mut matched, &mut resolved);
110 resolved
111 }
112
113 fn extend_rel_path(
114 &self,
115 rel_path: &Path,
116 matched: &mut HashSet<ProjectFile>,
117 out: &mut Vec<ProjectFile>,
118 ) {
119 if let Some(targets) = self.by_rel_path.get(rel_path) {
120 for target in targets {
121 if matched.insert(target.clone()) {
122 out.push(target.clone());
123 }
124 }
125 }
126 }
127
128 fn resolve_unique_fallback(
129 &self,
130 source_file: &ProjectFile,
131 include: &str,
132 ) -> Vec<ProjectFile> {
133 let include_path = Path::new(include);
134 let indexed = self.resolve_indexed(include);
135 let matches: Vec<_> = indexed
136 .into_iter()
137 .filter(|file| {
138 if include_path.components().count() > 1 {
139 file.rel_path().ends_with(include_path)
140 } else {
141 file.rel_path()
142 .file_name()
143 .is_some_and(|name| name == include_path)
144 }
145 })
146 .collect();
147 if matches.len() == 1 {
148 return matches;
149 }
150 let source_reachable = matches
151 .into_iter()
152 .filter(|file| {
153 (0..include_path.components().count())
154 .try_fold(file.rel_path(), |path, _| path.parent())
155 .is_some_and(|root| source_file.rel_path().starts_with(root))
156 })
157 .collect::<Vec<_>>();
158 if source_reachable.len() == 1 {
159 return source_reachable;
160 }
161 Vec::new()
162 }
163
164 fn resolve_unique_basename_alias(&self, include: &str) -> Vec<ProjectFile> {
165 let include_path = Path::new(include);
166 if include_path.components().count() <= 1 {
167 return Vec::new();
168 }
169 let Some(file_name) = include_path.file_name() else {
170 return Vec::new();
171 };
172 let matches = self
173 .resolve_indexed(include)
174 .into_iter()
175 .filter(|file| file.rel_path().file_name() == Some(file_name))
176 .collect::<Vec<_>>();
177 if matches.len() == 1 {
178 matches
179 } else {
180 Vec::new()
181 }
182 }
183}
184
185fn string_suffixes(value: &str) -> impl Iterator<Item = &str> {
186 value.char_indices().map(|(index, _)| &value[index..])
187}
188
189pub fn parse_quoted_include(line: &str) -> Option<String> {
190 let trimmed = line.trim();
191 let quote_start = trimmed.find('"')?;
192 let quote_end = trimmed[quote_start + 1..].find('"')?;
193 Some(trimmed[quote_start + 1..quote_start + 1 + quote_end].to_string())
194}
195
196pub fn parse_include_path(line: &str) -> Option<String> {
197 if let Some(path) = parse_quoted_include(line) {
198 return Some(path);
199 }
200 let trimmed = line.trim();
201 let angle_start = trimmed.find('<')?;
202 let angle_end = trimmed[angle_start + 1..].find('>')?;
203 Some(trimmed[angle_start + 1..angle_start + 1 + angle_end].to_string())
204}
205
206pub fn resolve_include_targets(
207 project: &dyn Project,
208 source_file: &ProjectFile,
209 include: &str,
210) -> Vec<ProjectFile> {
211 let mut candidates = Vec::new();
212 let include_path = Path::new(include);
213 let source_root = project.root().to_path_buf();
214 let relative_path = if include_path.is_absolute() {
215 match project_relative_include_path(project.root(), include_path) {
216 Some(path) => path,
217 None => return candidates,
218 }
219 } else {
220 source_file.parent().join(include_path)
221 };
222 let relative_file = ProjectFile::new(source_root.clone(), relative_path);
223 if relative_file.exists() {
224 candidates.push(relative_file);
225 }
226 if !include_path.is_absolute() {
227 let project_relative_file = ProjectFile::new(source_root.clone(), include_path);
228 if project_relative_file.exists() {
229 candidates.push(project_relative_file);
230 }
231 }
232
233 candidates.sort();
234 candidates.dedup();
235 candidates
236}
237
238pub fn resolve_include_targets_with_index(
239 source_file: &ProjectFile,
240 include: &str,
241 include_targets: &IncludeTargetIndex,
242) -> Vec<ProjectFile> {
243 let mut candidates = include_targets.resolve_direct(source_file, include);
244 if !candidates.is_empty() {
245 return candidates;
246 }
247 if Path::new(include).is_absolute() {
248 return candidates;
249 }
250 candidates.extend(include_targets.resolve_unique_fallback(source_file, include));
251 if candidates.is_empty()
252 && let Some(template) = header_template_include_spelling(include)
253 {
254 candidates = include_targets.resolve_direct(source_file, &template);
255 if candidates.is_empty() {
256 candidates.extend(include_targets.resolve_unique_fallback(source_file, &template));
257 }
258 }
259 if candidates.is_empty() {
265 candidates = include_targets.resolve_unique_basename_alias(include);
266 }
267 candidates
268}
269
270fn header_template_include_spelling(include: &str) -> Option<String> {
278 let path = Path::new(include);
279 (path.extension() == Some(std::ffi::OsStr::new("h")))
280 .then(|| path.with_extension("hin").to_string_lossy().into_owned())
281}
282
283pub fn resolve_direct_include_targets_with_index(
284 source_file: &ProjectFile,
285 include: &str,
286 include_targets: &IncludeTargetIndex,
287) -> Vec<ProjectFile> {
288 include_targets.resolve_direct(source_file, include)
289}
290
291fn project_relative_include_path(project_root: &Path, include_path: &Path) -> Option<PathBuf> {
292 let canonical_root = project_root
293 .canonicalize()
294 .unwrap_or_else(|_| project_root.to_path_buf());
295 let canonical_include = include_path
296 .canonicalize()
297 .unwrap_or_else(|_| include_path.to_path_buf());
298 canonical_include
299 .strip_prefix(&canonical_root)
300 .map(Path::to_path_buf)
301 .or_else(|_| {
302 include_path
303 .strip_prefix(project_root)
304 .map(Path::to_path_buf)
305 })
306 .ok()
307 .or_else(|| lexical_project_relative_include_path(&canonical_root, &canonical_include))
308 .or_else(|| lexical_project_relative_include_path(project_root, include_path))
309}
310
311pub fn included_claimable_files(
333 sources: &[(ProjectFile, Vec<ImportInfo>)],
334 claimable: &BTreeSet<ProjectFile>,
335) -> HashMap<ProjectFile, BTreeSet<ProjectFile>> {
336 let mut edges: HashMap<ProjectFile, BTreeSet<ProjectFile>> = HashMap::default();
337 if claimable.is_empty() || sources.is_empty() {
338 return edges;
339 }
340 let index = IncludeTargetIndex::build(claimable.iter());
341 for (source_file, imports) in sources {
342 let mut targets = BTreeSet::new();
343 for include in imports
344 .iter()
345 .filter_map(|import| parse_quoted_include(&import.raw_snippet))
346 {
347 targets.extend(resolve_include_targets_with_index(
348 source_file,
349 &include,
350 &index,
351 ));
352 }
353 if !targets.is_empty() {
354 edges.insert(source_file.clone(), targets);
355 }
356 }
357 edges
358}
359
360pub fn claimable_include_demand(
393 sources: &[(ProjectFile, Vec<ImportInfo>)],
394) -> HashMap<ProjectFile, BTreeSet<String>> {
395 let mut demand: HashMap<ProjectFile, BTreeSet<String>> = HashMap::default();
396 for (source_file, imports) in sources {
397 let mut keys = BTreeSet::new();
398 for include in imports
399 .iter()
400 .filter_map(|import| parse_quoted_include(&import.raw_snippet))
401 {
402 let template = header_template_include_spelling(&include);
403 for spelling in std::iter::once(include).chain(template) {
404 collect_include_demand_keys(source_file, &spelling, &mut keys);
405 }
406 }
407 if !keys.is_empty() {
408 demand.insert(source_file.clone(), keys);
409 }
410 }
411 demand
412}
413
414fn collect_include_demand_keys(
415 source_file: &ProjectFile,
416 include: &str,
417 keys: &mut BTreeSet<String>,
418) {
419 let include_path = Path::new(include);
420 let claimable_spelling = include_path
421 .extension()
422 .and_then(|extension| extension.to_str())
423 .is_none_or(|extension| !Language::is_source_extension(extension));
424 if !claimable_spelling {
425 return;
426 }
427 if include_path.is_absolute() {
428 if let Some(rel_path) = project_relative_include_path(source_file.root(), include_path)
432 && let Some(key) = path_suffix_key(&rel_path)
433 {
434 keys.insert(key);
435 }
436 return;
437 }
438 if let Some(key) = path_suffix_key(include_path) {
439 keys.insert(key);
440 }
441}
442
443pub fn quoted_include_paths(parsed: &[String]) -> Vec<String> {
444 parsed
445 .iter()
446 .filter_map(|line| parse_quoted_include(line))
447 .collect()
448}
449
450pub fn include_paths(parsed: &[String]) -> Vec<String> {
451 parsed
452 .iter()
453 .filter_map(|line| parse_include_path(line))
454 .collect()
455}
456
457pub fn extract_type_identifiers(source: &str) -> BTreeSet<String> {
468 static IDENT_RE: OnceLock<Regex> = OnceLock::new();
469 let regex =
470 IDENT_RE.get_or_init(|| Regex::new(r"[A-Za-z_][A-Za-z0-9_:<>]*").expect("valid regex"));
471 regex
472 .find_iter(source)
473 .map(|m| m.as_str())
474 .filter(|token| {
475 token
476 .chars()
477 .next()
478 .is_some_and(|ch| ch.is_ascii_uppercase())
479 })
480 .map(|token| token.trim_matches(':').to_string())
481 .collect()
482}
483
484pub fn receiver_query_supported(file: &ProjectFile) -> bool {
489 file.rel_path()
490 .extension()
491 .and_then(|extension| extension.to_str())
492 != Some("c")
493}
494
495fn lexical_project_relative_include_path(
496 project_root: &Path,
497 include_path: &Path,
498) -> Option<PathBuf> {
499 let root = slash_path(project_root);
500 let include = slash_path(include_path);
501 strip_slash_prefix(&include, &root).map(PathBuf::from)
502}
503
504fn slash_path(path: &Path) -> String {
505 let raw = path.to_string_lossy();
506 let raw = raw.strip_prefix(r"\\?\").unwrap_or(&raw);
507 raw.replace('\\', "/").trim_end_matches('/').to_string()
508}
509
510#[cfg(windows)]
511fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
512 if path.eq_ignore_ascii_case(root) {
513 return Some("");
514 }
515 if path.len() > root.len()
516 && path.as_bytes().get(root.len()) == Some(&b'/')
517 && path[..root.len()].eq_ignore_ascii_case(root)
518 {
519 return Some(&path[root.len() + 1..]);
520 }
521 None
522}
523
524#[cfg(not(windows))]
525fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
526 if path == root {
527 return Some("");
528 }
529 path.strip_prefix(root)
530 .and_then(|rest| rest.strip_prefix('/'))
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use std::fs;
537 use tempfile::TempDir;
538
539 fn write_file(root: &Path, rel: &str) -> ProjectFile {
540 let path = root.join(rel);
541 fs::create_dir_all(path.parent().expect("test file has parent")).unwrap();
542 fs::write(&path, "").unwrap();
543 ProjectFile::new(root.to_path_buf(), rel)
544 }
545
546 #[test]
547 fn indexed_include_resolution_uses_unique_suffix_fallback() {
548 let temp = TempDir::new().unwrap();
549 let root = temp.path().canonicalize().unwrap();
550 let source = write_file(&root, "src/lib.c");
551 let target = write_file(&root, "include/git2/sys/credential.h");
552 let duplicate = write_file(&root, "vendor/credential.h");
553 let index = IncludeTargetIndex::build([&source, &target, &duplicate]);
554
555 let resolved = resolve_include_targets_with_index(&source, "git2/sys/credential.h", &index);
556 assert_eq!(resolved, vec![target]);
557
558 let ambiguous = resolve_include_targets_with_index(&source, "credential.h", &index);
559 assert!(ambiguous.is_empty());
560 }
561
562 #[test]
563 fn indexed_include_resolution_prefers_unique_source_reachable_root() {
564 let temp = TempDir::new().unwrap();
565 let root = temp.path().canonicalize().unwrap();
566 let source = write_file(&root, "src/config/parse.c");
567 let target = write_file(&root, "src/config/parse.h");
568 let nested_decoy = write_file(&root, "src/build/config/parse.h");
569 let unrelated_source = write_file(&root, "app/main.c");
570 let index = IncludeTargetIndex::build([&source, &target, &nested_decoy, &unrelated_source]);
571
572 let resolved = resolve_include_targets_with_index(&source, "config/parse.h", &index);
573 assert_eq!(resolved, vec![target.clone()]);
574
575 let unrelated =
576 resolve_include_targets_with_index(&unrelated_source, "config/parse.h", &index);
577 assert!(unrelated.is_empty());
578
579 let ambiguous_source = write_file(&root, "src/config/deeper/main.c");
580 let second_reachable = write_file(&root, "src/config/config/parse.h");
581 let ambiguous_index = IncludeTargetIndex::build([
582 &ambiguous_source,
583 &target,
584 &nested_decoy,
585 &second_reachable,
586 ]);
587 let ambiguous = resolve_include_targets_with_index(
588 &ambiguous_source,
589 "config/parse.h",
590 &ambiguous_index,
591 );
592 assert!(ambiguous.is_empty());
593 }
594
595 #[test]
596 fn indexed_include_resolution_accepts_one_unique_installed_prefix_alias() {
597 let temp = TempDir::new().unwrap();
598 let root = temp.path().canonicalize().unwrap();
599 let source = write_file(&root, "src/lib/asn1/asn1_obj.cpp");
600 let target = write_file(&root, "src/lib/asn1/asn1_obj.h");
601 let index = IncludeTargetIndex::build([&source, &target]);
602
603 assert_eq!(
604 resolve_include_targets_with_index(&source, "botan/asn1_obj.h", &index),
605 vec![target.clone()]
606 );
607
608 let duplicate = write_file(&root, "vendor/asn1_obj.h");
609 let ambiguous = IncludeTargetIndex::build([&source, &target, &duplicate]);
610 assert!(
611 resolve_include_targets_with_index(&source, "botan/asn1_obj.h", &ambiguous).is_empty()
612 );
613 }
614
615 #[test]
616 fn unresolved_h_include_falls_back_to_hin_template() {
617 let temp = TempDir::new().unwrap();
618 let root = temp.path().canonicalize().unwrap();
619 let stub = write_file(&root, "src/include/krb5.h");
620 let template = write_file(&root, "src/include/krb5/krb5.hin");
621 let index = IncludeTargetIndex::build([&stub, &template]);
622
623 let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
624 assert_eq!(resolved, vec![template]);
625 }
626
627 #[test]
628 fn real_header_wins_over_hin_template() {
629 let temp = TempDir::new().unwrap();
630 let root = temp.path().canonicalize().unwrap();
631 let stub = write_file(&root, "src/include/krb5.h");
632 let generated = write_file(&root, "src/include/krb5/krb5.h");
633 let template = write_file(&root, "src/include/krb5/krb5.hin");
634 let index = IncludeTargetIndex::build([&stub, &generated, &template]);
635
636 let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
637 assert_eq!(resolved, vec![generated]);
638 }
639}