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 matches: Vec<_> = self
120 .resolve_indexed(include)
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 source_reachable
145 } else {
146 Vec::new()
147 }
148 }
149}
150
151fn string_suffixes(value: &str) -> impl Iterator<Item = &str> {
152 value.char_indices().map(|(index, _)| &value[index..])
153}
154
155pub fn parse_quoted_include(line: &str) -> Option<String> {
156 let trimmed = line.trim();
157 let quote_start = trimmed.find('"')?;
158 let quote_end = trimmed[quote_start + 1..].find('"')?;
159 Some(trimmed[quote_start + 1..quote_start + 1 + quote_end].to_string())
160}
161
162pub fn parse_include_path(line: &str) -> Option<String> {
163 if let Some(path) = parse_quoted_include(line) {
164 return Some(path);
165 }
166 let trimmed = line.trim();
167 let angle_start = trimmed.find('<')?;
168 let angle_end = trimmed[angle_start + 1..].find('>')?;
169 Some(trimmed[angle_start + 1..angle_start + 1 + angle_end].to_string())
170}
171
172pub fn resolve_include_targets(
173 project: &dyn Project,
174 source_file: &ProjectFile,
175 include: &str,
176) -> Vec<ProjectFile> {
177 let mut candidates = Vec::new();
178 let include_path = Path::new(include);
179 let source_root = project.root().to_path_buf();
180 let relative_path = if include_path.is_absolute() {
181 match project_relative_include_path(project.root(), include_path) {
182 Some(path) => path,
183 None => return candidates,
184 }
185 } else {
186 source_file.parent().join(include_path)
187 };
188 let relative_file = ProjectFile::new(source_root.clone(), relative_path);
189 if relative_file.exists() {
190 candidates.push(relative_file);
191 }
192 if !include_path.is_absolute() {
193 let project_relative_file = ProjectFile::new(source_root.clone(), include_path);
194 if project_relative_file.exists() {
195 candidates.push(project_relative_file);
196 }
197 }
198
199 candidates.sort();
200 candidates.dedup();
201 candidates
202}
203
204pub fn resolve_include_targets_with_index(
205 source_file: &ProjectFile,
206 include: &str,
207 include_targets: &IncludeTargetIndex,
208) -> Vec<ProjectFile> {
209 let mut candidates = include_targets.resolve_direct(source_file, include);
210 if !candidates.is_empty() {
211 return candidates;
212 }
213 if Path::new(include).is_absolute() {
214 return candidates;
215 }
216 candidates.extend(include_targets.resolve_unique_fallback(source_file, include));
217 if candidates.is_empty()
218 && let Some(template) = header_template_include_spelling(include)
219 {
220 candidates = include_targets.resolve_direct(source_file, &template);
221 if candidates.is_empty() {
222 candidates.extend(include_targets.resolve_unique_fallback(source_file, &template));
223 }
224 }
225 candidates
226}
227
228fn header_template_include_spelling(include: &str) -> Option<String> {
236 let path = Path::new(include);
237 (path.extension() == Some(std::ffi::OsStr::new("h")))
238 .then(|| path.with_extension("hin").to_string_lossy().into_owned())
239}
240
241pub fn resolve_direct_include_targets_with_index(
242 source_file: &ProjectFile,
243 include: &str,
244 include_targets: &IncludeTargetIndex,
245) -> Vec<ProjectFile> {
246 include_targets.resolve_direct(source_file, include)
247}
248
249fn project_relative_include_path(project_root: &Path, include_path: &Path) -> Option<PathBuf> {
250 let canonical_root = project_root
251 .canonicalize()
252 .unwrap_or_else(|_| project_root.to_path_buf());
253 let canonical_include = include_path
254 .canonicalize()
255 .unwrap_or_else(|_| include_path.to_path_buf());
256 canonical_include
257 .strip_prefix(&canonical_root)
258 .map(Path::to_path_buf)
259 .or_else(|_| {
260 include_path
261 .strip_prefix(project_root)
262 .map(Path::to_path_buf)
263 })
264 .ok()
265 .or_else(|| lexical_project_relative_include_path(&canonical_root, &canonical_include))
266 .or_else(|| lexical_project_relative_include_path(project_root, include_path))
267}
268
269pub fn included_claimable_files(
291 sources: &[(ProjectFile, Vec<ImportInfo>)],
292 claimable: &BTreeSet<ProjectFile>,
293) -> HashMap<ProjectFile, BTreeSet<ProjectFile>> {
294 let mut edges: HashMap<ProjectFile, BTreeSet<ProjectFile>> = HashMap::default();
295 if claimable.is_empty() || sources.is_empty() {
296 return edges;
297 }
298 let index = IncludeTargetIndex::build(claimable.iter());
299 for (source_file, imports) in sources {
300 let mut targets = BTreeSet::new();
301 for include in imports
302 .iter()
303 .filter_map(|import| parse_quoted_include(&import.raw_snippet))
304 {
305 targets.extend(resolve_include_targets_with_index(
306 source_file,
307 &include,
308 &index,
309 ));
310 }
311 if !targets.is_empty() {
312 edges.insert(source_file.clone(), targets);
313 }
314 }
315 edges
316}
317
318pub fn claimable_include_demand(
351 sources: &[(ProjectFile, Vec<ImportInfo>)],
352) -> HashMap<ProjectFile, BTreeSet<String>> {
353 let mut demand: HashMap<ProjectFile, BTreeSet<String>> = HashMap::default();
354 for (source_file, imports) in sources {
355 let mut keys = BTreeSet::new();
356 for include in imports
357 .iter()
358 .filter_map(|import| parse_quoted_include(&import.raw_snippet))
359 {
360 let template = header_template_include_spelling(&include);
361 for spelling in std::iter::once(include).chain(template) {
362 collect_include_demand_keys(source_file, &spelling, &mut keys);
363 }
364 }
365 if !keys.is_empty() {
366 demand.insert(source_file.clone(), keys);
367 }
368 }
369 demand
370}
371
372fn collect_include_demand_keys(
373 source_file: &ProjectFile,
374 include: &str,
375 keys: &mut BTreeSet<String>,
376) {
377 let include_path = Path::new(include);
378 let claimable_spelling = include_path
379 .extension()
380 .and_then(|extension| extension.to_str())
381 .is_none_or(|extension| !Language::is_source_extension(extension));
382 if !claimable_spelling {
383 return;
384 }
385 if include_path.is_absolute() {
386 if let Some(rel_path) = project_relative_include_path(source_file.root(), include_path)
390 && let Some(key) = path_suffix_key(&rel_path)
391 {
392 keys.insert(key);
393 }
394 return;
395 }
396 if let Some(key) = path_suffix_key(include_path) {
397 keys.insert(key);
398 }
399}
400
401pub fn quoted_include_paths(parsed: &[String]) -> Vec<String> {
402 parsed
403 .iter()
404 .filter_map(|line| parse_quoted_include(line))
405 .collect()
406}
407
408pub fn include_paths(parsed: &[String]) -> Vec<String> {
409 parsed
410 .iter()
411 .filter_map(|line| parse_include_path(line))
412 .collect()
413}
414
415pub fn extract_type_identifiers(source: &str) -> BTreeSet<String> {
426 static IDENT_RE: OnceLock<Regex> = OnceLock::new();
427 let regex =
428 IDENT_RE.get_or_init(|| Regex::new(r"[A-Za-z_][A-Za-z0-9_:<>]*").expect("valid regex"));
429 regex
430 .find_iter(source)
431 .map(|m| m.as_str())
432 .filter(|token| {
433 token
434 .chars()
435 .next()
436 .is_some_and(|ch| ch.is_ascii_uppercase())
437 })
438 .map(|token| token.trim_matches(':').to_string())
439 .collect()
440}
441
442pub fn receiver_query_supported(file: &ProjectFile) -> bool {
447 file.rel_path()
448 .extension()
449 .and_then(|extension| extension.to_str())
450 != Some("c")
451}
452
453fn lexical_project_relative_include_path(
454 project_root: &Path,
455 include_path: &Path,
456) -> Option<PathBuf> {
457 let root = slash_path(project_root);
458 let include = slash_path(include_path);
459 strip_slash_prefix(&include, &root).map(PathBuf::from)
460}
461
462fn slash_path(path: &Path) -> String {
463 let raw = path.to_string_lossy();
464 let raw = raw.strip_prefix(r"\\?\").unwrap_or(&raw);
465 raw.replace('\\', "/").trim_end_matches('/').to_string()
466}
467
468#[cfg(windows)]
469fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
470 if path.eq_ignore_ascii_case(root) {
471 return Some("");
472 }
473 if path.len() > root.len()
474 && path.as_bytes().get(root.len()) == Some(&b'/')
475 && path[..root.len()].eq_ignore_ascii_case(root)
476 {
477 return Some(&path[root.len() + 1..]);
478 }
479 None
480}
481
482#[cfg(not(windows))]
483fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
484 if path == root {
485 return Some("");
486 }
487 path.strip_prefix(root)
488 .and_then(|rest| rest.strip_prefix('/'))
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use std::fs;
495 use tempfile::TempDir;
496
497 fn write_file(root: &Path, rel: &str) -> ProjectFile {
498 let path = root.join(rel);
499 fs::create_dir_all(path.parent().expect("test file has parent")).unwrap();
500 fs::write(&path, "").unwrap();
501 ProjectFile::new(root.to_path_buf(), rel)
502 }
503
504 #[test]
505 fn indexed_include_resolution_uses_unique_suffix_fallback() {
506 let temp = TempDir::new().unwrap();
507 let root = temp.path().canonicalize().unwrap();
508 let source = write_file(&root, "src/lib.c");
509 let target = write_file(&root, "include/git2/sys/credential.h");
510 let duplicate = write_file(&root, "vendor/credential.h");
511 let index = IncludeTargetIndex::build([&source, &target, &duplicate]);
512
513 let resolved = resolve_include_targets_with_index(&source, "git2/sys/credential.h", &index);
514 assert_eq!(resolved, vec![target]);
515
516 let ambiguous = resolve_include_targets_with_index(&source, "credential.h", &index);
517 assert!(ambiguous.is_empty());
518 }
519
520 #[test]
521 fn indexed_include_resolution_prefers_unique_source_reachable_root() {
522 let temp = TempDir::new().unwrap();
523 let root = temp.path().canonicalize().unwrap();
524 let source = write_file(&root, "src/config/parse.c");
525 let target = write_file(&root, "src/config/parse.h");
526 let nested_decoy = write_file(&root, "src/build/config/parse.h");
527 let unrelated_source = write_file(&root, "app/main.c");
528 let index = IncludeTargetIndex::build([&source, &target, &nested_decoy, &unrelated_source]);
529
530 let resolved = resolve_include_targets_with_index(&source, "config/parse.h", &index);
531 assert_eq!(resolved, vec![target.clone()]);
532
533 let unrelated =
534 resolve_include_targets_with_index(&unrelated_source, "config/parse.h", &index);
535 assert!(unrelated.is_empty());
536
537 let ambiguous_source = write_file(&root, "src/config/deeper/main.c");
538 let second_reachable = write_file(&root, "src/config/config/parse.h");
539 let ambiguous_index = IncludeTargetIndex::build([
540 &ambiguous_source,
541 &target,
542 &nested_decoy,
543 &second_reachable,
544 ]);
545 let ambiguous = resolve_include_targets_with_index(
546 &ambiguous_source,
547 "config/parse.h",
548 &ambiguous_index,
549 );
550 assert!(ambiguous.is_empty());
551 }
552
553 #[test]
554 fn unresolved_h_include_falls_back_to_hin_template() {
555 let temp = TempDir::new().unwrap();
556 let root = temp.path().canonicalize().unwrap();
557 let stub = write_file(&root, "src/include/krb5.h");
558 let template = write_file(&root, "src/include/krb5/krb5.hin");
559 let index = IncludeTargetIndex::build([&stub, &template]);
560
561 let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
562 assert_eq!(resolved, vec![template]);
563 }
564
565 #[test]
566 fn real_header_wins_over_hin_template() {
567 let temp = TempDir::new().unwrap();
568 let root = temp.path().canonicalize().unwrap();
569 let stub = write_file(&root, "src/include/krb5.h");
570 let generated = write_file(&root, "src/include/krb5/krb5.h");
571 let template = write_file(&root, "src/include/krb5/krb5.hin");
572 let index = IncludeTargetIndex::build([&stub, &generated, &template]);
573
574 let resolved = resolve_include_targets_with_index(&stub, "krb5/krb5.h", &index);
575 assert_eq!(resolved, vec![generated]);
576 }
577}