Skip to main content

code_moniker_workspace/
cbuild.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 = 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: &quoted[..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}