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