1use std::path::Path;
29
30use serde::{Deserialize, Serialize};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum Language {
36 Rust,
38 C,
40 Cpp,
42}
43
44impl Language {
45 #[must_use]
47 pub const fn name(self) -> &'static str {
48 match self {
49 Self::Rust => "rust",
50 Self::C => "c",
51 Self::Cpp => "cpp",
52 }
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum HeaderPolicy {
59 C,
61 Cpp,
63 #[default]
65 Detect,
66}
67
68impl HeaderPolicy {
69 #[must_use]
71 pub const fn name(self) -> &'static str {
72 match self {
73 Self::C => "c",
74 Self::Cpp => "cpp",
75 Self::Detect => "detect",
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
91pub struct HeaderEvidence {
92 c: usize,
93 cpp: usize,
94}
95
96impl HeaderEvidence {
97 pub const fn observe(&mut self, classification: Classification) {
100 if classification.provisional {
101 return;
102 }
103 match classification.language {
104 Language::C => self.c += 1,
105 Language::Cpp => self.cpp += 1,
106 Language::Rust => {}
107 }
108 }
109
110 #[must_use]
118 pub const fn verdict(self) -> Option<Language> {
119 match (self.c, self.cpp) {
120 (0, 0) => None,
121 (c, cpp) if cpp > c => Some(Language::Cpp),
122 _ => Some(Language::C),
123 }
124 }
125}
126
127pub(super) fn speaks_cpp(source: &str) -> bool {
142 let bytes = source.as_bytes();
143 let mut index = 0;
144 while index < bytes.len() {
145 let rest = &bytes[index..];
146 match rest {
147 [b'/', b'/', ..] => index += skip_until(rest, b"\n"),
148 [b'/', b'*', ..] => index += skip_until(&rest[2..], b"*/") + 2,
149 [b'"', ..] => index += skip_literal(rest, b'"'),
150 [b'\'', ..] => index += skip_literal(rest, b'\''),
151 [b':', b':', ..] => return true,
152 [b'#', ..] => {
153 let line = &rest[..skip_until(rest, b"\n")];
154 if bare_standard_include(line) {
155 return true;
156 }
157 index += line.len();
158 }
159 [first, ..] if first.is_ascii_alphabetic() || *first == b'_' => {
160 let word = word_at(rest);
161 let after = rest[word.len()..]
165 .iter()
166 .position(|byte| !byte.is_ascii_whitespace())
167 .map(|offset| rest[word.len() + offset]);
168 match (word, after) {
169 (b"template", Some(b'<')) => return true,
170 (b"namespace", Some(byte))
171 if byte.is_ascii_alphabetic() || byte == b'_' || byte == b'{' =>
172 {
173 return true;
174 }
175 _ => {}
176 }
177 index += word.len();
178 }
179 _ => index += 1,
180 }
181 }
182 false
183}
184
185fn skip_until(bytes: &[u8], needle: &[u8]) -> usize {
187 bytes
188 .windows(needle.len())
189 .position(|window| window == needle)
190 .map_or(bytes.len(), |offset| offset + needle.len())
191}
192
193fn skip_literal(bytes: &[u8], quote: u8) -> usize {
197 let mut index = 1;
198 while index < bytes.len() {
199 match bytes[index] {
200 b'\\' => index += 2,
201 byte if byte == quote => return index + 1,
202 _ => index += 1,
203 }
204 }
205 bytes.len()
206}
207
208fn word_at(bytes: &[u8]) -> &[u8] {
210 let end = bytes
211 .iter()
212 .position(|byte| !(byte.is_ascii_alphanumeric() || *byte == b'_'))
213 .unwrap_or(bytes.len());
214 &bytes[..end]
215}
216
217fn bare_standard_include(line: &[u8]) -> bool {
220 let Some(open) = line.iter().position(|byte| *byte == b'<') else {
221 return false;
222 };
223 let Some(close) = line[open..].iter().position(|byte| *byte == b'>') else {
224 return false;
225 };
226 let name = &line[open + 1..open + close];
227 !name.is_empty()
228 && !name.contains(&b'.')
229 && !name.contains(&b'/')
230 && name
231 .iter()
232 .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub struct LanguageSelection {
238 pub rust: bool,
240 pub c: bool,
242 pub cpp: bool,
244}
245
246impl Default for LanguageSelection {
247 fn default() -> Self {
248 Self {
249 rust: true,
250 c: true,
251 cpp: true,
252 }
253 }
254}
255
256impl LanguageSelection {
257 #[must_use]
259 pub const fn includes(self, language: Language) -> bool {
260 match language {
261 Language::Rust => self.rust,
262 Language::C => self.c,
263 Language::Cpp => self.cpp,
264 }
265 }
266
267 #[must_use]
269 pub fn enabled(self) -> Vec<Language> {
270 let mut out = Vec::new();
271 if self.rust {
272 out.push(Language::Rust);
273 }
274 if self.c {
275 out.push(Language::C);
276 }
277 if self.cpp {
278 out.push(Language::Cpp);
279 }
280 out
281 }
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286pub struct Classification {
287 pub language: Language,
289 pub is_header: bool,
292 pub provisional: bool,
296}
297
298impl Classification {
299 #[must_use]
302 pub const fn settled(self, language: Language) -> Self {
303 if self.provisional {
304 Self {
305 language,
306 is_header: self.is_header,
307 provisional: false,
308 }
309 } else {
310 self
311 }
312 }
313}
314
315#[must_use]
322pub(super) fn classify(path: &Path, header_policy: HeaderPolicy) -> Option<Classification> {
323 let ext = path.extension()?.to_str()?;
326 let (language, is_header, provisional) = match ext {
327 "rs" => (Language::Rust, false, false),
328 "c" => (Language::C, false, false),
329 "h" => match header_policy {
330 HeaderPolicy::C => (Language::C, true, false),
331 HeaderPolicy::Cpp => (Language::Cpp, true, false),
332 HeaderPolicy::Detect => (Language::C, true, true),
333 },
334 "cc" | "cpp" | "cxx" | "c++" | "C" => (Language::Cpp, false, false),
335 "hpp" | "hh" | "hxx" | "h++" | "H" | "tpp" | "ipp" | "inl" => (Language::Cpp, true, false),
336 _ => return None,
337 };
338 Some(Classification {
339 language,
340 is_header,
341 provisional,
342 })
343}
344
345#[cfg(test)]
346#[allow(clippy::expect_used, clippy::unwrap_used)]
347mod tests {
348 use super::*;
349 use std::path::PathBuf;
350
351 fn classify_str(name: &str, policy: HeaderPolicy) -> Option<Classification> {
352 classify(&PathBuf::from(name), policy)
353 }
354
355 #[test]
356 fn rust_and_c_sources_classify_by_extension() {
357 assert_eq!(
358 classify_str("a/b/main.rs", HeaderPolicy::C),
359 Some(Classification {
360 language: Language::Rust,
361 is_header: false,
362 provisional: false,
363 })
364 );
365 assert_eq!(
366 classify_str("lib.c", HeaderPolicy::C),
367 Some(Classification {
368 language: Language::C,
369 is_header: false,
370 provisional: false,
371 })
372 );
373 }
374
375 #[test]
376 fn cpp_extensions_are_cpp_regardless_of_policy() {
377 for name in ["a.cpp", "a.cc", "a.cxx", "a.C", "a.hpp", "a.H"] {
378 let cls = classify_str(name, HeaderPolicy::C).expect("classified");
379 assert_eq!(cls.language, Language::Cpp, "{name}");
380 }
381 }
382
383 #[test]
384 fn bare_h_follows_the_header_policy() {
385 assert_eq!(
386 classify_str("a.h", HeaderPolicy::C).map(|c| c.language),
387 Some(Language::C)
388 );
389 assert_eq!(
390 classify_str("a.h", HeaderPolicy::Cpp).map(|c| c.language),
391 Some(Language::Cpp)
392 );
393 assert!(classify_str("a.h", HeaderPolicy::C).is_some_and(|c| c.is_header));
394 }
395
396 #[test]
397 fn unsupported_and_extensionless_files_are_ignored() {
398 assert_eq!(classify_str("README.md", HeaderPolicy::C), None);
399 assert_eq!(classify_str("Makefile", HeaderPolicy::C), None);
400 assert_eq!(classify_str("a.py", HeaderPolicy::C), None);
401 }
402
403 #[test]
404 fn a_detected_header_is_provisional_until_it_is_settled() {
405 let header = classify_str("a.h", HeaderPolicy::Detect).expect("classified");
406 assert!(header.provisional, "the extension has not decided this");
407 assert!(header.is_header);
408
409 let settled = header.settled(Language::Cpp);
410 assert_eq!(settled.language, Language::Cpp);
411 assert!(!settled.provisional, "the verdict is final");
412 assert!(settled.is_header, "settling does not change what it is");
413 }
414
415 #[test]
416 fn settling_leaves_a_file_the_extension_already_named_alone() {
417 let cpp_header = classify_str("a.hpp", HeaderPolicy::Detect).expect("classified");
421 assert_eq!(cpp_header.settled(Language::C).language, Language::Cpp);
422 let c_source = classify_str("a.c", HeaderPolicy::Detect).expect("classified");
423 assert_eq!(c_source.settled(Language::Cpp).language, Language::C);
424 }
425
426 fn verdict_over(names: &[&str]) -> Option<Language> {
428 let mut evidence = HeaderEvidence::default();
429 for name in names {
430 if let Some(classification) = classify_str(name, HeaderPolicy::Detect) {
431 evidence.observe(classification);
432 }
433 }
434 evidence.verdict()
435 }
436
437 #[test]
438 fn a_tree_written_mostly_in_cpp_reads_its_bare_headers_as_cpp() {
439 assert_eq!(
440 verdict_over(&["a.cpp", "b.cc", "c.hpp", "vendored.c", "x.h", "y.h"]),
441 Some(Language::Cpp)
442 );
443 }
444
445 #[test]
446 fn a_tree_written_mostly_in_c_reads_its_bare_headers_as_c() {
447 assert_eq!(
449 verdict_over(&["a.c", "b.c", "c.c", "fuzz.cc", "bench.cc", "a.h"]),
450 Some(Language::C)
451 );
452 }
453
454 #[test]
455 fn a_tree_with_nothing_to_go_on_leaves_the_question_open() {
456 assert_eq!(verdict_over(&["main.rs", "lib.rs", "a.h"]), None);
461 assert_eq!(verdict_over(&[]), None);
462 assert_eq!(verdict_over(&["a.c", "b.cpp"]), Some(Language::C));
465 }
466
467 #[test]
468 fn the_headers_being_settled_do_not_vote_on_their_own_language() {
469 let mut evidence = HeaderEvidence::default();
473 for name in ["a.cpp", "one.h", "two.h", "three.h", "four.h"] {
474 evidence.observe(classify_str(name, HeaderPolicy::Detect).expect("classified"));
475 }
476 assert_eq!(evidence.verdict(), Some(Language::Cpp));
477 }
478
479 #[test]
480 fn a_header_that_spells_something_only_cpp_has_is_read_as_cpp() {
481 for source in [
482 "namespace spdlog {\nint f(void);\n}\n",
483 "template <typename T>\nT identity(T value) { return value; }\n",
484 "int width = detail::pad_to(8);\n",
485 "#include <memory>\n",
486 ] {
487 assert!(speaks_cpp(source), "missed C++ in {source:?}");
488 }
489 }
490
491 #[test]
492 fn a_c_header_is_not_talked_into_cpp_by_its_prose() {
493 for source in [
494 "/* A class of namespace, template :: style. */\nint f(void);\n",
497 "// namespace ::template\nint f(void);\n",
498 "static const char *doc = \"namespace x { template <int> };\";\n",
499 "#include <string.h>\n#include <sys/types.h>\n",
500 "struct s { int template; int namespace; };\n",
501 "struct s { unsigned a : 1; unsigned b : 1; };\n",
503 "static const char *unclosed = \"namespace\n",
506 ] {
507 assert!(!speaks_cpp(source), "read C++ into {source:?}");
508 }
509 }
510
511 #[test]
512 fn header_policy_names_are_stable() {
513 assert_eq!(HeaderPolicy::C.name(), "c");
514 assert_eq!(HeaderPolicy::Cpp.name(), "cpp");
515 assert_eq!(HeaderPolicy::Detect.name(), "detect");
516 assert_eq!(HeaderPolicy::default(), HeaderPolicy::Detect);
517 }
518
519 #[test]
520 fn selection_filters_and_enumerates_in_order() {
521 let selection = LanguageSelection {
522 rust: true,
523 c: false,
524 cpp: true,
525 };
526 assert!(selection.includes(Language::Rust));
527 assert!(!selection.includes(Language::C));
528 assert_eq!(selection.enabled(), vec![Language::Rust, Language::Cpp]);
529 }
530}