1use std::collections::{BTreeSet, HashMap, HashSet};
2use std::path::{Component, Path, PathBuf};
3use std::sync::Arc;
4
5use code_moniker_core::lang::c::Presets;
6
7#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
8struct HeaderUsage {
9 c: bool,
10 cpp: bool,
11}
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14enum TranslationUnitLanguage {
15 C,
16 Cpp,
17}
18
19#[derive(Clone, Debug, Default)]
23pub struct CBuildContext {
24 root: PathBuf,
25 header_usage: HashMap<PathBuf, HeaderUsage>,
26 include_paths: Vec<PathBuf>,
27 workspace_files: Arc<BTreeSet<String>>,
28 external_include_package: Option<String>,
29 has_c_translation_unit: bool,
30 has_cpp_translation_unit: bool,
31}
32
33impl CBuildContext {
34 pub fn load(root: &Path) -> Self {
35 let root = absolute_normalized(root);
36 let entries = ignore::WalkBuilder::new(&root)
37 .build()
38 .filter_map(Result::ok)
39 .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()))
40 .map(|entry| entry.into_path())
41 .collect::<Vec<_>>();
42 let workspace_files = entries
43 .iter()
44 .filter_map(|path| project_relative_path(&root, path))
45 .collect::<BTreeSet<_>>();
46 let makefile = load_makefile_hints(&root);
47 let mut context = Self {
48 include_paths: makefile.include_paths,
49 root: root.clone(),
50 header_usage: HashMap::new(),
51 workspace_files: Arc::new(workspace_files),
52 external_include_package: makefile.external_include_package,
53 has_c_translation_unit: false,
54 has_cpp_translation_unit: false,
55 };
56 if !context.include_paths.contains(&root) {
57 context.include_paths.push(root.clone());
58 }
59 let mut visited_c = HashSet::new();
60 let mut visited_cpp = HashSet::new();
61 for path in entries {
62 let Some(language) = translation_unit_language(&path) else {
63 continue;
64 };
65 match language {
66 TranslationUnitLanguage::C => context.has_c_translation_unit = true,
67 TranslationUnitLanguage::Cpp => context.has_cpp_translation_unit = true,
68 }
69 let visited = match language {
70 TranslationUnitLanguage::C => &mut visited_c,
71 TranslationUnitLanguage::Cpp => &mut visited_cpp,
72 };
73 context.record_translation_unit(&path, language, visited);
74 }
75 context
76 }
77
78 pub fn extraction_presets(&self) -> Presets {
79 Presets {
80 include_paths: self
81 .include_paths
82 .iter()
83 .filter_map(|path| project_relative_path(&self.root, path))
84 .collect(),
85 workspace_files: Arc::clone(&self.workspace_files),
86 external_include_package: self.external_include_package.clone(),
87 }
88 }
89
90 pub fn should_index_as_c(&self, path: &Path) -> bool {
91 if !has_extension(path, "h") {
92 return true;
93 }
94 if self.has_cpp_translation_unit && !self.has_c_translation_unit {
95 return false;
96 }
97 let path = absolute_normalized(path);
98 !self
99 .header_usage
100 .get(&path)
101 .is_some_and(|usage| usage.cpp && !usage.c)
102 }
103
104 fn record_translation_unit(
105 &mut self,
106 path: &Path,
107 language: TranslationUnitLanguage,
108 visited: &mut HashSet<PathBuf>,
109 ) {
110 self.record_includes(path, language, visited);
111 }
112
113 fn record_includes(
114 &mut self,
115 path: &Path,
116 language: TranslationUnitLanguage,
117 visited: &mut HashSet<PathBuf>,
118 ) {
119 let path = absolute_normalized(path);
120 if !visited.insert(path.clone()) {
121 return;
122 }
123 let Ok(source) = std::fs::read(&path) else {
124 return;
125 };
126 let source = String::from_utf8_lossy(&source);
127 for include in includes(&source) {
128 let Some(header) = self.resolve_include(&path, include) else {
129 continue;
130 };
131 let usage = self.header_usage.entry(header.clone()).or_default();
132 match language {
133 TranslationUnitLanguage::C => usage.c = true,
134 TranslationUnitLanguage::Cpp => usage.cpp = true,
135 }
136 self.record_includes(&header, language, visited);
137 }
138 }
139
140 fn resolve_include(&self, source: &Path, include: IncludeDirective<'_>) -> Option<PathBuf> {
141 if include.quoted {
142 let relative = source.parent().unwrap_or(&self.root).join(include.path);
143 if relative.is_file() {
144 return Some(absolute_normalized(&relative));
145 }
146 }
147 self.include_paths
148 .iter()
149 .map(|base| base.join(include.path))
150 .find(|candidate| candidate.is_file())
151 .map(|candidate| absolute_normalized(&candidate))
152 }
153}
154
155fn translation_unit_language(path: &Path) -> Option<TranslationUnitLanguage> {
156 let extension = path.extension()?.to_str()?.to_ascii_lowercase();
157 match extension.as_str() {
158 "c" => Some(TranslationUnitLanguage::C),
159 "cc" | "cpp" | "cxx" | "c++" => Some(TranslationUnitLanguage::Cpp),
160 _ => None,
161 }
162}
163
164fn has_extension(path: &Path, expected: &str) -> bool {
165 path.extension()
166 .and_then(|extension| extension.to_str())
167 .is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
168}
169
170#[derive(Clone, Copy)]
171struct IncludeDirective<'a> {
172 path: &'a str,
173 quoted: bool,
174}
175
176fn includes(source: &str) -> impl Iterator<Item = IncludeDirective<'_>> {
177 source.lines().filter_map(|line| {
178 let directive = line.trim_start().strip_prefix('#')?.trim_start();
179 let rest = directive.strip_prefix("include")?;
180 if rest
181 .chars()
182 .next()
183 .is_some_and(|character| character.is_alphanumeric() || character == '_')
184 {
185 return None;
186 }
187 let rest = rest.trim_start();
188 if let Some(quoted) = rest.strip_prefix('"') {
189 let end = quoted.find('"')?;
190 return Some(IncludeDirective {
191 path: "ed[..end],
192 quoted: true,
193 });
194 }
195 let system = rest.strip_prefix('<')?;
196 let end = system.find('>')?;
197 Some(IncludeDirective {
198 path: &system[..end],
199 quoted: false,
200 })
201 })
202}
203
204#[derive(Default)]
205struct MakefileHints {
206 include_paths: Vec<PathBuf>,
207 external_include_package: Option<String>,
208}
209
210fn load_makefile_hints(root: &Path) -> MakefileHints {
211 let Ok(bytes) = std::fs::read(root.join("Makefile")) else {
212 return MakefileHints::default();
213 };
214 let text = String::from_utf8_lossy(&bytes).replace("\\\n", " ");
215 let mut paths = Vec::new();
216 for line in text.lines() {
217 let Some((_, value)) = line.split_once('=') else {
218 continue;
219 };
220 let mut tokens = value.split_whitespace().peekable();
221 while let Some(token) = tokens.next() {
222 let raw = if token == "-I" {
223 tokens.next()
224 } else {
225 token.strip_prefix("-I")
226 };
227 let Some(raw) = raw else {
228 continue;
229 };
230 let raw = raw.trim_matches(['\'', '"']);
231 if raw.is_empty() || raw.contains('$') || raw.contains('`') || raw.starts_with('-') {
232 continue;
233 }
234 let path = absolute_normalized(&root.join(raw));
235 if path.is_dir() && !paths.contains(&path) {
236 paths.push(path);
237 }
238 }
239 }
240 let external_include_package = (text.contains("PGXS")
241 && (text.contains("PG_CONFIG") || text.contains("pg_config")))
242 .then(|| "postgresql".to_string());
243 MakefileHints {
244 include_paths: paths,
245 external_include_package,
246 }
247}
248
249fn absolute_normalized(path: &Path) -> PathBuf {
250 let absolute = if path.is_absolute() {
251 path.to_path_buf()
252 } else {
253 std::env::current_dir()
254 .unwrap_or_else(|_| PathBuf::from("."))
255 .join(path)
256 };
257 let mut normalized = PathBuf::new();
258 for component in absolute.components() {
259 match component {
260 Component::CurDir => {}
261 Component::ParentDir => {
262 normalized.pop();
263 }
264 other => normalized.push(other.as_os_str()),
265 }
266 }
267 normalized
268}
269
270fn project_relative_path(root: &Path, path: &Path) -> Option<String> {
271 let relative = absolute_normalized(path)
272 .strip_prefix(root)
273 .ok()?
274 .to_path_buf();
275 Some(
276 relative
277 .components()
278 .filter_map(|component| component.as_os_str().to_str())
279 .collect::<Vec<_>>()
280 .join("/"),
281 )
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use std::fs;
288
289 fn write(root: &Path, relative: &str, body: &str) {
290 let path = root.join(relative);
291 if let Some(parent) = path.parent() {
292 fs::create_dir_all(parent).unwrap();
293 }
294 fs::write(path, body).unwrap();
295 }
296
297 #[test]
298 fn cpp_only_header_is_not_indexed_as_c() {
299 let temp = tempfile::tempdir().unwrap();
300 write(
301 temp.path(),
302 "generated/model.pb.cc",
303 "#include \"model.pb.h\"\n",
304 );
305 write(
306 temp.path(),
307 "generated/model.pb.h",
308 "namespace generated {}\n",
309 );
310
311 let context = CBuildContext::load(temp.path());
312
313 assert!(!context.should_index_as_c(&temp.path().join("generated/model.pb.h")));
314 }
315
316 #[test]
317 fn header_shared_with_c_translation_unit_remains_c_indexable() {
318 let temp = tempfile::tempdir().unwrap();
319 write(temp.path(), "main.c", "#include \"shared.h\"\n");
320 write(temp.path(), "main.cpp", "#include \"shared.h\"\n");
321 write(temp.path(), "shared.h", "int shared(void);\n");
322
323 let context = CBuildContext::load(temp.path());
324
325 assert!(context.should_index_as_c(&temp.path().join("shared.h")));
326 }
327
328 #[test]
329 fn cpp_only_project_does_not_treat_orphan_headers_as_c() {
330 let temp = tempfile::tempdir().unwrap();
331 write(temp.path(), "main.cpp", "int main() { return 0; }\n");
332 write(temp.path(), "orphan.h", "class Orphan {};\n");
333
334 let context = CBuildContext::load(temp.path());
335
336 assert!(!context.should_index_as_c(&temp.path().join("orphan.h")));
337 }
338
339 #[test]
340 fn transitive_cpp_headers_are_not_indexed_as_c() {
341 let temp = tempfile::tempdir().unwrap();
342 write(temp.path(), "main.cpp", "#include \"first.hpp\"\n");
343 write(temp.path(), "first.hpp", "#include \"second.h\"\n");
344 write(temp.path(), "second.h", "class Second {};\n");
345
346 let context = CBuildContext::load(temp.path());
347
348 assert!(!context.should_index_as_c(&temp.path().join("second.h")));
349 }
350
351 #[test]
352 fn makefile_include_path_resolves_cpp_header_provenance() {
353 let temp = tempfile::tempdir().unwrap();
354 write(temp.path(), "Makefile", "CXXFLAGS += -I./include\n");
355 write(
356 temp.path(),
357 "src/model.cpp",
358 "#include \"generated/model.h\"\n",
359 );
360 write(
361 temp.path(),
362 "include/generated/model.h",
363 "class Model {};\n",
364 );
365
366 let context = CBuildContext::load(temp.path());
367
368 assert!(!context.should_index_as_c(&temp.path().join("include/generated/model.h")));
369 }
370
371 #[test]
372 fn angle_include_through_makefile_path_marks_header_as_c() {
373 let temp = tempfile::tempdir().unwrap();
374 write(temp.path(), "Makefile", "CPPFLAGS += -I./include\n");
375 write(temp.path(), "main.cpp", "#include \"shared.h\"\n");
376 write(temp.path(), "main.c", "#include <shared.h>\n");
377 write(temp.path(), "include/shared.h", "int shared(void);\n");
378
379 let context = CBuildContext::load(temp.path());
380
381 assert!(context.should_index_as_c(&temp.path().join("include/shared.h")));
382 }
383
384 #[test]
385 fn pgxs_makefile_declares_postgresql_header_provenance() {
386 let temp = tempfile::tempdir().unwrap();
387 write(
388 temp.path(),
389 "Makefile",
390 "USE_PGXS = 1\nPG_CONFIG = pg_config\nPGXS := $(shell $(PG_CONFIG) --pgxs)\ninclude $(PGXS)\n",
391 );
392
393 let context = CBuildContext::load(temp.path());
394
395 assert_eq!(
396 context
397 .extraction_presets()
398 .external_include_package
399 .as_deref(),
400 Some("postgresql")
401 );
402 }
403}