use std::path::Path;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Language {
Rust,
C,
Cpp,
}
impl Language {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Rust => "rust",
Self::C => "c",
Self::Cpp => "cpp",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HeaderPolicy {
C,
Cpp,
#[default]
Detect,
}
impl HeaderPolicy {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::C => "c",
Self::Cpp => "cpp",
Self::Detect => "detect",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct HeaderEvidence {
c: usize,
cpp: usize,
}
impl HeaderEvidence {
pub const fn observe(&mut self, classification: Classification) {
if classification.provisional {
return;
}
match classification.language {
Language::C => self.c += 1,
Language::Cpp => self.cpp += 1,
Language::Rust => {}
}
}
#[must_use]
pub const fn verdict(self) -> Option<Language> {
match (self.c, self.cpp) {
(0, 0) => None,
(c, cpp) if cpp > c => Some(Language::Cpp),
_ => Some(Language::C),
}
}
}
pub(super) fn speaks_cpp(source: &str) -> bool {
let bytes = source.as_bytes();
let mut index = 0;
while index < bytes.len() {
let rest = &bytes[index..];
match rest {
[b'/', b'/', ..] => index += skip_until(rest, b"\n"),
[b'/', b'*', ..] => index += skip_until(&rest[2..], b"*/") + 2,
[b'"', ..] => index += skip_literal(rest, b'"'),
[b'\'', ..] => index += skip_literal(rest, b'\''),
[b':', b':', ..] => return true,
[b'#', ..] => {
let line = &rest[..skip_until(rest, b"\n")];
if bare_standard_include(line) {
return true;
}
index += line.len();
}
[first, ..] if first.is_ascii_alphabetic() || *first == b'_' => {
let word = word_at(rest);
let after = rest[word.len()..]
.iter()
.position(|byte| !byte.is_ascii_whitespace())
.map(|offset| rest[word.len() + offset]);
match (word, after) {
(b"template", Some(b'<')) => return true,
(b"namespace", Some(byte))
if byte.is_ascii_alphabetic() || byte == b'_' || byte == b'{' =>
{
return true;
}
_ => {}
}
index += word.len();
}
_ => index += 1,
}
}
false
}
fn skip_until(bytes: &[u8], needle: &[u8]) -> usize {
bytes
.windows(needle.len())
.position(|window| window == needle)
.map_or(bytes.len(), |offset| offset + needle.len())
}
fn skip_literal(bytes: &[u8], quote: u8) -> usize {
let mut index = 1;
while index < bytes.len() {
match bytes[index] {
b'\\' => index += 2,
byte if byte == quote => return index + 1,
_ => index += 1,
}
}
bytes.len()
}
fn word_at(bytes: &[u8]) -> &[u8] {
let end = bytes
.iter()
.position(|byte| !(byte.is_ascii_alphanumeric() || *byte == b'_'))
.unwrap_or(bytes.len());
&bytes[..end]
}
fn bare_standard_include(line: &[u8]) -> bool {
let Some(open) = line.iter().position(|byte| *byte == b'<') else {
return false;
};
let Some(close) = line[open..].iter().position(|byte| *byte == b'>') else {
return false;
};
let name = &line[open + 1..open + close];
!name.is_empty()
&& !name.contains(&b'.')
&& !name.contains(&b'/')
&& name
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LanguageSelection {
pub rust: bool,
pub c: bool,
pub cpp: bool,
}
impl Default for LanguageSelection {
fn default() -> Self {
Self {
rust: true,
c: true,
cpp: true,
}
}
}
impl LanguageSelection {
#[must_use]
pub const fn includes(self, language: Language) -> bool {
match language {
Language::Rust => self.rust,
Language::C => self.c,
Language::Cpp => self.cpp,
}
}
#[must_use]
pub fn enabled(self) -> Vec<Language> {
let mut out = Vec::new();
if self.rust {
out.push(Language::Rust);
}
if self.c {
out.push(Language::C);
}
if self.cpp {
out.push(Language::Cpp);
}
out
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Classification {
pub language: Language,
pub is_header: bool,
pub provisional: bool,
}
impl Classification {
#[must_use]
pub const fn settled(self, language: Language) -> Self {
if self.provisional {
Self {
language,
is_header: self.is_header,
provisional: false,
}
} else {
self
}
}
}
#[must_use]
pub(super) fn classify(path: &Path, header_policy: HeaderPolicy) -> Option<Classification> {
let ext = path.extension()?.to_str()?;
let (language, is_header, provisional) = match ext {
"rs" => (Language::Rust, false, false),
"c" => (Language::C, false, false),
"h" => match header_policy {
HeaderPolicy::C => (Language::C, true, false),
HeaderPolicy::Cpp => (Language::Cpp, true, false),
HeaderPolicy::Detect => (Language::C, true, true),
},
"cc" | "cpp" | "cxx" | "c++" | "C" => (Language::Cpp, false, false),
"hpp" | "hh" | "hxx" | "h++" | "H" | "tpp" | "ipp" | "inl" => (Language::Cpp, true, false),
_ => return None,
};
Some(Classification {
language,
is_header,
provisional,
})
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use std::path::PathBuf;
fn classify_str(name: &str, policy: HeaderPolicy) -> Option<Classification> {
classify(&PathBuf::from(name), policy)
}
#[test]
fn rust_and_c_sources_classify_by_extension() {
assert_eq!(
classify_str("a/b/main.rs", HeaderPolicy::C),
Some(Classification {
language: Language::Rust,
is_header: false,
provisional: false,
})
);
assert_eq!(
classify_str("lib.c", HeaderPolicy::C),
Some(Classification {
language: Language::C,
is_header: false,
provisional: false,
})
);
}
#[test]
fn cpp_extensions_are_cpp_regardless_of_policy() {
for name in ["a.cpp", "a.cc", "a.cxx", "a.C", "a.hpp", "a.H"] {
let cls = classify_str(name, HeaderPolicy::C).expect("classified");
assert_eq!(cls.language, Language::Cpp, "{name}");
}
}
#[test]
fn bare_h_follows_the_header_policy() {
assert_eq!(
classify_str("a.h", HeaderPolicy::C).map(|c| c.language),
Some(Language::C)
);
assert_eq!(
classify_str("a.h", HeaderPolicy::Cpp).map(|c| c.language),
Some(Language::Cpp)
);
assert!(classify_str("a.h", HeaderPolicy::C).is_some_and(|c| c.is_header));
}
#[test]
fn unsupported_and_extensionless_files_are_ignored() {
assert_eq!(classify_str("README.md", HeaderPolicy::C), None);
assert_eq!(classify_str("Makefile", HeaderPolicy::C), None);
assert_eq!(classify_str("a.py", HeaderPolicy::C), None);
}
#[test]
fn a_detected_header_is_provisional_until_it_is_settled() {
let header = classify_str("a.h", HeaderPolicy::Detect).expect("classified");
assert!(header.provisional, "the extension has not decided this");
assert!(header.is_header);
let settled = header.settled(Language::Cpp);
assert_eq!(settled.language, Language::Cpp);
assert!(!settled.provisional, "the verdict is final");
assert!(settled.is_header, "settling does not change what it is");
}
#[test]
fn settling_leaves_a_file_the_extension_already_named_alone() {
let cpp_header = classify_str("a.hpp", HeaderPolicy::Detect).expect("classified");
assert_eq!(cpp_header.settled(Language::C).language, Language::Cpp);
let c_source = classify_str("a.c", HeaderPolicy::Detect).expect("classified");
assert_eq!(c_source.settled(Language::Cpp).language, Language::C);
}
fn verdict_over(names: &[&str]) -> Option<Language> {
let mut evidence = HeaderEvidence::default();
for name in names {
if let Some(classification) = classify_str(name, HeaderPolicy::Detect) {
evidence.observe(classification);
}
}
evidence.verdict()
}
#[test]
fn a_tree_written_mostly_in_cpp_reads_its_bare_headers_as_cpp() {
assert_eq!(
verdict_over(&["a.cpp", "b.cc", "c.hpp", "vendored.c", "x.h", "y.h"]),
Some(Language::Cpp)
);
}
#[test]
fn a_tree_written_mostly_in_c_reads_its_bare_headers_as_c() {
assert_eq!(
verdict_over(&["a.c", "b.c", "c.c", "fuzz.cc", "bench.cc", "a.h"]),
Some(Language::C)
);
}
#[test]
fn a_tree_with_nothing_to_go_on_leaves_the_question_open() {
assert_eq!(verdict_over(&["main.rs", "lib.rs", "a.h"]), None);
assert_eq!(verdict_over(&[]), None);
assert_eq!(verdict_over(&["a.c", "b.cpp"]), Some(Language::C));
}
#[test]
fn the_headers_being_settled_do_not_vote_on_their_own_language() {
let mut evidence = HeaderEvidence::default();
for name in ["a.cpp", "one.h", "two.h", "three.h", "four.h"] {
evidence.observe(classify_str(name, HeaderPolicy::Detect).expect("classified"));
}
assert_eq!(evidence.verdict(), Some(Language::Cpp));
}
#[test]
fn a_header_that_spells_something_only_cpp_has_is_read_as_cpp() {
for source in [
"namespace spdlog {\nint f(void);\n}\n",
"template <typename T>\nT identity(T value) { return value; }\n",
"int width = detail::pad_to(8);\n",
"#include <memory>\n",
] {
assert!(speaks_cpp(source), "missed C++ in {source:?}");
}
}
#[test]
fn a_c_header_is_not_talked_into_cpp_by_its_prose() {
for source in [
"/* A class of namespace, template :: style. */\nint f(void);\n",
"// namespace ::template\nint f(void);\n",
"static const char *doc = \"namespace x { template <int> };\";\n",
"#include <string.h>\n#include <sys/types.h>\n",
"struct s { int template; int namespace; };\n",
"struct s { unsigned a : 1; unsigned b : 1; };\n",
"static const char *unclosed = \"namespace\n",
] {
assert!(!speaks_cpp(source), "read C++ into {source:?}");
}
}
#[test]
fn header_policy_names_are_stable() {
assert_eq!(HeaderPolicy::C.name(), "c");
assert_eq!(HeaderPolicy::Cpp.name(), "cpp");
assert_eq!(HeaderPolicy::Detect.name(), "detect");
assert_eq!(HeaderPolicy::default(), HeaderPolicy::Detect);
}
#[test]
fn selection_filters_and_enumerates_in_order() {
let selection = LanguageSelection {
rust: true,
c: false,
cpp: true,
};
assert!(selection.includes(Language::Rust));
assert!(!selection.includes(Language::C));
assert_eq!(selection.enabled(), vec![Language::Rust, Language::Cpp]);
}
}