Skip to main content

code_moniker_workspace/sources/
c.rs

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/// Build facts that affect the meaning of C-family source files. A `.h` suffix
20/// is not a language declaration: until C++ extraction exists, headers reached
21/// exclusively from C++ translation units must not be parsed as C.
22#[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: &quoted[..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}