1use brokk_bifrost_core::analyzer::ProjectFile;
9use brokk_bifrost_core::analyzer::model::ImportInfo;
10use brokk_bifrost_core::analyzer::project::Project;
11use brokk_bifrost_core::hash::{HashMap, HashSet};
12use regex::Regex;
13use std::collections::BTreeSet;
14use std::path::{Path, PathBuf};
15use std::sync::OnceLock;
16
17pub struct IncludeTargetIndex {
24 by_rel_path: HashMap<PathBuf, Vec<ProjectFile>>,
25 by_file_name: HashMap<String, Vec<ProjectFile>>,
26}
27
28impl IncludeTargetIndex {
29 pub fn build<'a>(files: impl IntoIterator<Item = &'a ProjectFile>) -> Self {
30 let mut by_rel_path: HashMap<PathBuf, Vec<ProjectFile>> = HashMap::default();
31 let mut by_file_name: HashMap<String, Vec<ProjectFile>> = HashMap::default();
32 for file in files {
33 by_rel_path
34 .entry(file.rel_path().to_path_buf())
35 .or_default()
36 .push(file.clone());
37 if let Some(file_name) = file.rel_path().file_name().and_then(|value| value.to_str()) {
38 by_file_name
39 .entry(file_name.to_string())
40 .or_default()
41 .push(file.clone());
42 }
43 }
44 Self {
45 by_rel_path,
46 by_file_name,
47 }
48 }
49
50 pub fn resolve_indexed(&self, include: &str) -> Vec<ProjectFile> {
51 let include_path = Path::new(include);
52 let mut matched = HashSet::default();
53 let mut resolved = Vec::new();
54 if let Some(targets) = self.by_rel_path.get(include_path) {
55 for target in targets {
56 if matched.insert(target.clone()) {
57 resolved.push(target.clone());
58 }
59 }
60 }
61 for suffix in string_suffixes(include) {
62 if let Some(targets) = self.by_file_name.get(suffix) {
63 for target in targets {
64 if matched.insert(target.clone()) {
65 resolved.push(target.clone());
66 }
67 }
68 }
69 }
70 resolved
71 }
72
73 fn resolve_direct(&self, source_file: &ProjectFile, include: &str) -> Vec<ProjectFile> {
74 let include_path = Path::new(include);
75 let mut matched = HashSet::default();
76 let mut resolved = Vec::new();
77 if include_path.is_absolute() {
78 if let Some(rel_path) = project_relative_include_path(source_file.root(), include_path)
79 {
80 self.extend_rel_path(&rel_path, &mut matched, &mut resolved);
81 }
82 return resolved;
83 }
84
85 let source_relative = ProjectFile::new(
86 source_file.root().to_path_buf(),
87 source_file.parent().join(include_path),
88 );
89 self.extend_rel_path(source_relative.rel_path(), &mut matched, &mut resolved);
90
91 let project_relative =
92 ProjectFile::new(source_file.root().to_path_buf(), include_path.to_path_buf());
93 self.extend_rel_path(project_relative.rel_path(), &mut matched, &mut resolved);
94 resolved
95 }
96
97 fn extend_rel_path(
98 &self,
99 rel_path: &Path,
100 matched: &mut HashSet<ProjectFile>,
101 out: &mut Vec<ProjectFile>,
102 ) {
103 if let Some(targets) = self.by_rel_path.get(rel_path) {
104 for target in targets {
105 if matched.insert(target.clone()) {
106 out.push(target.clone());
107 }
108 }
109 }
110 }
111
112 fn resolve_unique_fallback(
113 &self,
114 source_file: &ProjectFile,
115 include: &str,
116 ) -> Vec<ProjectFile> {
117 let include_path = Path::new(include);
118 let matches: Vec<_> = self
119 .resolve_indexed(include)
120 .into_iter()
121 .filter(|file| {
122 if include_path.components().count() > 1 {
123 file.rel_path().ends_with(include_path)
124 } else {
125 file.rel_path()
126 .file_name()
127 .is_some_and(|name| name == include_path)
128 }
129 })
130 .collect();
131 if matches.len() == 1 {
132 return matches;
133 }
134 let source_reachable = matches
135 .into_iter()
136 .filter(|file| {
137 (0..include_path.components().count())
138 .try_fold(file.rel_path(), |path, _| path.parent())
139 .is_some_and(|root| source_file.rel_path().starts_with(root))
140 })
141 .collect::<Vec<_>>();
142 if source_reachable.len() == 1 {
143 source_reachable
144 } else {
145 Vec::new()
146 }
147 }
148}
149
150fn string_suffixes(value: &str) -> impl Iterator<Item = &str> {
151 value.char_indices().map(|(index, _)| &value[index..])
152}
153
154pub fn parse_quoted_include(line: &str) -> Option<String> {
155 let trimmed = line.trim();
156 let quote_start = trimmed.find('"')?;
157 let quote_end = trimmed[quote_start + 1..].find('"')?;
158 Some(trimmed[quote_start + 1..quote_start + 1 + quote_end].to_string())
159}
160
161pub fn parse_include_path(line: &str) -> Option<String> {
162 if let Some(path) = parse_quoted_include(line) {
163 return Some(path);
164 }
165 let trimmed = line.trim();
166 let angle_start = trimmed.find('<')?;
167 let angle_end = trimmed[angle_start + 1..].find('>')?;
168 Some(trimmed[angle_start + 1..angle_start + 1 + angle_end].to_string())
169}
170
171pub fn resolve_include_targets(
172 project: &dyn Project,
173 source_file: &ProjectFile,
174 include: &str,
175) -> Vec<ProjectFile> {
176 let mut candidates = Vec::new();
177 let include_path = Path::new(include);
178 let source_root = project.root().to_path_buf();
179 let relative_path = if include_path.is_absolute() {
180 match project_relative_include_path(project.root(), include_path) {
181 Some(path) => path,
182 None => return candidates,
183 }
184 } else {
185 source_file.parent().join(include_path)
186 };
187 let relative_file = ProjectFile::new(source_root.clone(), relative_path);
188 if relative_file.exists() {
189 candidates.push(relative_file);
190 }
191 if !include_path.is_absolute() {
192 let project_relative_file = ProjectFile::new(source_root.clone(), include_path);
193 if project_relative_file.exists() {
194 candidates.push(project_relative_file);
195 }
196 }
197
198 candidates.sort();
199 candidates.dedup();
200 candidates
201}
202
203pub fn resolve_include_targets_with_index(
204 source_file: &ProjectFile,
205 include: &str,
206 include_targets: &IncludeTargetIndex,
207) -> Vec<ProjectFile> {
208 let mut candidates = include_targets.resolve_direct(source_file, include);
209 if !candidates.is_empty() {
210 return candidates;
211 }
212 if Path::new(include).is_absolute() {
213 return candidates;
214 }
215 candidates.extend(include_targets.resolve_unique_fallback(source_file, include));
216 candidates
217}
218
219pub fn resolve_direct_include_targets_with_index(
220 source_file: &ProjectFile,
221 include: &str,
222 include_targets: &IncludeTargetIndex,
223) -> Vec<ProjectFile> {
224 include_targets.resolve_direct(source_file, include)
225}
226
227fn project_relative_include_path(project_root: &Path, include_path: &Path) -> Option<PathBuf> {
228 let canonical_root = project_root
229 .canonicalize()
230 .unwrap_or_else(|_| project_root.to_path_buf());
231 let canonical_include = include_path
232 .canonicalize()
233 .unwrap_or_else(|_| include_path.to_path_buf());
234 canonical_include
235 .strip_prefix(&canonical_root)
236 .map(Path::to_path_buf)
237 .or_else(|_| {
238 include_path
239 .strip_prefix(project_root)
240 .map(Path::to_path_buf)
241 })
242 .ok()
243 .or_else(|| lexical_project_relative_include_path(&canonical_root, &canonical_include))
244 .or_else(|| lexical_project_relative_include_path(project_root, include_path))
245}
246
247pub fn included_claimable_files(
269 sources: &[(ProjectFile, Vec<ImportInfo>)],
270 claimable: &BTreeSet<ProjectFile>,
271) -> HashMap<ProjectFile, BTreeSet<ProjectFile>> {
272 let mut edges: HashMap<ProjectFile, BTreeSet<ProjectFile>> = HashMap::default();
273 if claimable.is_empty() || sources.is_empty() {
274 return edges;
275 }
276 let index = IncludeTargetIndex::build(claimable.iter());
277 for (source_file, imports) in sources {
278 let mut targets = BTreeSet::new();
279 for include in imports
280 .iter()
281 .filter_map(|import| parse_quoted_include(&import.raw_snippet))
282 {
283 targets.extend(resolve_include_targets_with_index(
284 source_file,
285 &include,
286 &index,
287 ));
288 }
289 if !targets.is_empty() {
290 edges.insert(source_file.clone(), targets);
291 }
292 }
293 edges
294}
295
296pub fn quoted_include_paths(parsed: &[String]) -> Vec<String> {
297 parsed
298 .iter()
299 .filter_map(|line| parse_quoted_include(line))
300 .collect()
301}
302
303pub fn include_paths(parsed: &[String]) -> Vec<String> {
304 parsed
305 .iter()
306 .filter_map(|line| parse_include_path(line))
307 .collect()
308}
309
310pub fn extract_type_identifiers(source: &str) -> BTreeSet<String> {
321 static IDENT_RE: OnceLock<Regex> = OnceLock::new();
322 let regex =
323 IDENT_RE.get_or_init(|| Regex::new(r"[A-Za-z_][A-Za-z0-9_:<>]*").expect("valid regex"));
324 regex
325 .find_iter(source)
326 .map(|m| m.as_str())
327 .filter(|token| {
328 token
329 .chars()
330 .next()
331 .is_some_and(|ch| ch.is_ascii_uppercase())
332 })
333 .map(|token| token.trim_matches(':').to_string())
334 .collect()
335}
336
337pub fn receiver_query_supported(file: &ProjectFile) -> bool {
342 file.rel_path()
343 .extension()
344 .and_then(|extension| extension.to_str())
345 != Some("c")
346}
347
348fn lexical_project_relative_include_path(
349 project_root: &Path,
350 include_path: &Path,
351) -> Option<PathBuf> {
352 let root = slash_path(project_root);
353 let include = slash_path(include_path);
354 strip_slash_prefix(&include, &root).map(PathBuf::from)
355}
356
357fn slash_path(path: &Path) -> String {
358 let raw = path.to_string_lossy();
359 let raw = raw.strip_prefix(r"\\?\").unwrap_or(&raw);
360 raw.replace('\\', "/").trim_end_matches('/').to_string()
361}
362
363#[cfg(windows)]
364fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
365 if path.eq_ignore_ascii_case(root) {
366 return Some("");
367 }
368 if path.len() > root.len()
369 && path.as_bytes().get(root.len()) == Some(&b'/')
370 && path[..root.len()].eq_ignore_ascii_case(root)
371 {
372 return Some(&path[root.len() + 1..]);
373 }
374 None
375}
376
377#[cfg(not(windows))]
378fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
379 if path == root {
380 return Some("");
381 }
382 path.strip_prefix(root)
383 .and_then(|rest| rest.strip_prefix('/'))
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use std::fs;
390 use tempfile::TempDir;
391
392 fn write_file(root: &Path, rel: &str) -> ProjectFile {
393 let path = root.join(rel);
394 fs::create_dir_all(path.parent().expect("test file has parent")).unwrap();
395 fs::write(&path, "").unwrap();
396 ProjectFile::new(root.to_path_buf(), rel)
397 }
398
399 #[test]
400 fn indexed_include_resolution_uses_unique_suffix_fallback() {
401 let temp = TempDir::new().unwrap();
402 let root = temp.path().canonicalize().unwrap();
403 let source = write_file(&root, "src/lib.c");
404 let target = write_file(&root, "include/git2/sys/credential.h");
405 let duplicate = write_file(&root, "vendor/credential.h");
406 let index = IncludeTargetIndex::build([&source, &target, &duplicate]);
407
408 let resolved = resolve_include_targets_with_index(&source, "git2/sys/credential.h", &index);
409 assert_eq!(resolved, vec![target]);
410
411 let ambiguous = resolve_include_targets_with_index(&source, "credential.h", &index);
412 assert!(ambiguous.is_empty());
413 }
414
415 #[test]
416 fn indexed_include_resolution_prefers_unique_source_reachable_root() {
417 let temp = TempDir::new().unwrap();
418 let root = temp.path().canonicalize().unwrap();
419 let source = write_file(&root, "src/config/parse.c");
420 let target = write_file(&root, "src/config/parse.h");
421 let nested_decoy = write_file(&root, "src/build/config/parse.h");
422 let unrelated_source = write_file(&root, "app/main.c");
423 let index = IncludeTargetIndex::build([&source, &target, &nested_decoy, &unrelated_source]);
424
425 let resolved = resolve_include_targets_with_index(&source, "config/parse.h", &index);
426 assert_eq!(resolved, vec![target.clone()]);
427
428 let unrelated =
429 resolve_include_targets_with_index(&unrelated_source, "config/parse.h", &index);
430 assert!(unrelated.is_empty());
431
432 let ambiguous_source = write_file(&root, "src/config/deeper/main.c");
433 let second_reachable = write_file(&root, "src/config/config/parse.h");
434 let ambiguous_index = IncludeTargetIndex::build([
435 &ambiguous_source,
436 &target,
437 &nested_decoy,
438 &second_reachable,
439 ]);
440 let ambiguous = resolve_include_targets_with_index(
441 &ambiguous_source,
442 "config/parse.h",
443 &ambiguous_index,
444 );
445 assert!(ambiguous.is_empty());
446 }
447}