brokk_bifrost_cpp/
imports.rs1use brokk_bifrost_core::analyzer::ProjectFile;
9use brokk_bifrost_core::analyzer::project::Project;
10use brokk_bifrost_core::hash::{HashMap, HashSet};
11use regex::Regex;
12use std::collections::BTreeSet;
13use std::path::{Path, PathBuf};
14use std::sync::OnceLock;
15
16pub struct IncludeTargetIndex {
23 by_rel_path: HashMap<PathBuf, Vec<ProjectFile>>,
24 by_file_name: HashMap<String, Vec<ProjectFile>>,
25}
26
27impl IncludeTargetIndex {
28 pub fn build<'a>(files: impl IntoIterator<Item = &'a ProjectFile>) -> Self {
29 let mut by_rel_path: HashMap<PathBuf, Vec<ProjectFile>> = HashMap::default();
30 let mut by_file_name: HashMap<String, Vec<ProjectFile>> = HashMap::default();
31 for file in files {
32 by_rel_path
33 .entry(file.rel_path().to_path_buf())
34 .or_default()
35 .push(file.clone());
36 if let Some(file_name) = file.rel_path().file_name().and_then(|value| value.to_str()) {
37 by_file_name
38 .entry(file_name.to_string())
39 .or_default()
40 .push(file.clone());
41 }
42 }
43 Self {
44 by_rel_path,
45 by_file_name,
46 }
47 }
48
49 pub fn resolve_indexed(&self, include: &str) -> Vec<ProjectFile> {
50 let include_path = Path::new(include);
51 let mut matched = HashSet::default();
52 let mut resolved = Vec::new();
53 if let Some(targets) = self.by_rel_path.get(include_path) {
54 for target in targets {
55 if matched.insert(target.clone()) {
56 resolved.push(target.clone());
57 }
58 }
59 }
60 for suffix in string_suffixes(include) {
61 if let Some(targets) = self.by_file_name.get(suffix) {
62 for target in targets {
63 if matched.insert(target.clone()) {
64 resolved.push(target.clone());
65 }
66 }
67 }
68 }
69 resolved
70 }
71
72 fn resolve_direct(&self, source_file: &ProjectFile, include: &str) -> Vec<ProjectFile> {
73 let include_path = Path::new(include);
74 let mut matched = HashSet::default();
75 let mut resolved = Vec::new();
76 if include_path.is_absolute() {
77 if let Some(rel_path) = project_relative_include_path(source_file.root(), include_path)
78 {
79 self.extend_rel_path(&rel_path, &mut matched, &mut resolved);
80 }
81 return resolved;
82 }
83
84 let source_relative = ProjectFile::new(
85 source_file.root().to_path_buf(),
86 source_file.parent().join(include_path),
87 );
88 self.extend_rel_path(source_relative.rel_path(), &mut matched, &mut resolved);
89
90 let project_relative =
91 ProjectFile::new(source_file.root().to_path_buf(), include_path.to_path_buf());
92 self.extend_rel_path(project_relative.rel_path(), &mut matched, &mut resolved);
93 resolved
94 }
95
96 fn extend_rel_path(
97 &self,
98 rel_path: &Path,
99 matched: &mut HashSet<ProjectFile>,
100 out: &mut Vec<ProjectFile>,
101 ) {
102 if let Some(targets) = self.by_rel_path.get(rel_path) {
103 for target in targets {
104 if matched.insert(target.clone()) {
105 out.push(target.clone());
106 }
107 }
108 }
109 }
110
111 fn resolve_unique_fallback(&self, include: &str) -> Vec<ProjectFile> {
112 let include_path = Path::new(include);
113 let matches: Vec<_> = self
114 .resolve_indexed(include)
115 .into_iter()
116 .filter(|file| {
117 if include_path.components().count() > 1 {
118 file.rel_path().ends_with(include_path)
119 } else {
120 file.rel_path()
121 .file_name()
122 .is_some_and(|name| name == include_path)
123 }
124 })
125 .collect();
126 if matches.len() == 1 {
127 matches
128 } else {
129 Vec::new()
130 }
131 }
132}
133
134fn string_suffixes(value: &str) -> impl Iterator<Item = &str> {
135 value.char_indices().map(|(index, _)| &value[index..])
136}
137
138pub fn parse_quoted_include(line: &str) -> Option<String> {
139 let trimmed = line.trim();
140 let quote_start = trimmed.find('"')?;
141 let quote_end = trimmed[quote_start + 1..].find('"')?;
142 Some(trimmed[quote_start + 1..quote_start + 1 + quote_end].to_string())
143}
144
145pub fn parse_include_path(line: &str) -> Option<String> {
146 if let Some(path) = parse_quoted_include(line) {
147 return Some(path);
148 }
149 let trimmed = line.trim();
150 let angle_start = trimmed.find('<')?;
151 let angle_end = trimmed[angle_start + 1..].find('>')?;
152 Some(trimmed[angle_start + 1..angle_start + 1 + angle_end].to_string())
153}
154
155pub fn resolve_include_targets(
156 project: &dyn Project,
157 source_file: &ProjectFile,
158 include: &str,
159) -> Vec<ProjectFile> {
160 let mut candidates = Vec::new();
161 let include_path = Path::new(include);
162 let source_root = project.root().to_path_buf();
163 let relative_path = if include_path.is_absolute() {
164 match project_relative_include_path(project.root(), include_path) {
165 Some(path) => path,
166 None => return candidates,
167 }
168 } else {
169 source_file.parent().join(include_path)
170 };
171 let relative_file = ProjectFile::new(source_root.clone(), relative_path);
172 if relative_file.exists() {
173 candidates.push(relative_file);
174 }
175 if !include_path.is_absolute() {
176 let project_relative_file = ProjectFile::new(source_root.clone(), include_path);
177 if project_relative_file.exists() {
178 candidates.push(project_relative_file);
179 }
180 }
181
182 candidates.sort();
183 candidates.dedup();
184 candidates
185}
186
187pub fn resolve_include_targets_with_index(
188 source_file: &ProjectFile,
189 include: &str,
190 include_targets: &IncludeTargetIndex,
191) -> Vec<ProjectFile> {
192 let mut candidates = include_targets.resolve_direct(source_file, include);
193 if !candidates.is_empty() {
194 return candidates;
195 }
196 if Path::new(include).is_absolute() {
197 return candidates;
198 }
199 candidates.extend(include_targets.resolve_unique_fallback(include));
200 candidates
201}
202
203pub fn resolve_direct_include_targets_with_index(
204 source_file: &ProjectFile,
205 include: &str,
206 include_targets: &IncludeTargetIndex,
207) -> Vec<ProjectFile> {
208 include_targets.resolve_direct(source_file, include)
209}
210
211fn project_relative_include_path(project_root: &Path, include_path: &Path) -> Option<PathBuf> {
212 let canonical_root = project_root
213 .canonicalize()
214 .unwrap_or_else(|_| project_root.to_path_buf());
215 let canonical_include = include_path
216 .canonicalize()
217 .unwrap_or_else(|_| include_path.to_path_buf());
218 canonical_include
219 .strip_prefix(&canonical_root)
220 .map(Path::to_path_buf)
221 .or_else(|_| {
222 include_path
223 .strip_prefix(project_root)
224 .map(Path::to_path_buf)
225 })
226 .ok()
227 .or_else(|| lexical_project_relative_include_path(&canonical_root, &canonical_include))
228 .or_else(|| lexical_project_relative_include_path(project_root, include_path))
229}
230
231pub fn quoted_include_paths(parsed: &[String]) -> Vec<String> {
232 parsed
233 .iter()
234 .filter_map(|line| parse_quoted_include(line))
235 .collect()
236}
237
238pub fn include_paths(parsed: &[String]) -> Vec<String> {
239 parsed
240 .iter()
241 .filter_map(|line| parse_include_path(line))
242 .collect()
243}
244
245pub fn extract_type_identifiers(source: &str) -> BTreeSet<String> {
256 static IDENT_RE: OnceLock<Regex> = OnceLock::new();
257 let regex =
258 IDENT_RE.get_or_init(|| Regex::new(r"[A-Za-z_][A-Za-z0-9_:<>]*").expect("valid regex"));
259 regex
260 .find_iter(source)
261 .map(|m| m.as_str())
262 .filter(|token| {
263 token
264 .chars()
265 .next()
266 .is_some_and(|ch| ch.is_ascii_uppercase())
267 })
268 .map(|token| token.trim_matches(':').to_string())
269 .collect()
270}
271
272pub fn receiver_query_supported(file: &ProjectFile) -> bool {
277 file.rel_path()
278 .extension()
279 .and_then(|extension| extension.to_str())
280 != Some("c")
281}
282
283fn lexical_project_relative_include_path(
284 project_root: &Path,
285 include_path: &Path,
286) -> Option<PathBuf> {
287 let root = slash_path(project_root);
288 let include = slash_path(include_path);
289 strip_slash_prefix(&include, &root).map(PathBuf::from)
290}
291
292fn slash_path(path: &Path) -> String {
293 let raw = path.to_string_lossy();
294 let raw = raw.strip_prefix(r"\\?\").unwrap_or(&raw);
295 raw.replace('\\', "/").trim_end_matches('/').to_string()
296}
297
298#[cfg(windows)]
299fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
300 if path.eq_ignore_ascii_case(root) {
301 return Some("");
302 }
303 if path.len() > root.len()
304 && path.as_bytes().get(root.len()) == Some(&b'/')
305 && path[..root.len()].eq_ignore_ascii_case(root)
306 {
307 return Some(&path[root.len() + 1..]);
308 }
309 None
310}
311
312#[cfg(not(windows))]
313fn strip_slash_prefix<'a>(path: &'a str, root: &str) -> Option<&'a str> {
314 if path == root {
315 return Some("");
316 }
317 path.strip_prefix(root)
318 .and_then(|rest| rest.strip_prefix('/'))
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use std::fs;
325 use tempfile::TempDir;
326
327 fn write_file(root: &Path, rel: &str) -> ProjectFile {
328 let path = root.join(rel);
329 fs::create_dir_all(path.parent().expect("test file has parent")).unwrap();
330 fs::write(&path, "").unwrap();
331 ProjectFile::new(root.to_path_buf(), rel)
332 }
333
334 #[test]
335 fn indexed_include_resolution_uses_unique_suffix_fallback() {
336 let temp = TempDir::new().unwrap();
337 let root = temp.path().canonicalize().unwrap();
338 let source = write_file(&root, "src/lib.c");
339 let target = write_file(&root, "include/git2/sys/credential.h");
340 let duplicate = write_file(&root, "vendor/credential.h");
341 let index = IncludeTargetIndex::build([&source, &target, &duplicate]);
342
343 let resolved = resolve_include_targets_with_index(&source, "git2/sys/credential.h", &index);
344 assert_eq!(resolved, vec![target]);
345
346 let ambiguous = resolve_include_targets_with_index(&source, "credential.h", &index);
347 assert!(ambiguous.is_empty());
348 }
349}