Skip to main content

codehelion_core/discovery/
language.rs

1//! Language classification of source files by extension.
2//!
3//! Classification is filename-based. The one genuinely ambiguous case is the
4//! bare `.h` extension, which C and C++ share; the caller picks a
5//! [`HeaderPolicy`] to resolve it. Extensions that unambiguously belong to C++
6//! by convention (capitalised `.C`/`.H`, `.hpp`, `.cxx`, ...) are classified
7//! directly regardless of the policy.
8//!
9//! # Why `.h` is worth resolving rather than assuming
10//!
11//! The grammar a header is read with decides what the analysis can see in it.
12//! Reading a C++ header with the C grammar does not merely lose the C++-only
13//! declarations: error recovery reshapes what surrounds them, so the damage
14//! spreads past the construct that caused it. Measured over one C++ project's
15//! 9,627 bare headers, the C grammar left 38.5% of their bytes inside error
16//! regions where the C++ grammar left 25.9%.
17//!
18//! [`HeaderPolicy::Detect`] therefore settles `.h` from the files whose
19//! extension is not in doubt — see [`HeaderEvidence`]. It is one decision per
20//! run, not one per file: a header's language is part of the build variant
21//! every result is attributed to, and two copies of the same code must not
22//! land in different languages because one of them happened to parse better.
23//!
24//! A header-only library offers no such files at all, and it is the case where
25//! guessing costs the most: every line of the project is in the headers. There
26//! the headers are read for something only C++ spells — see [`speaks_cpp`].
27
28use std::path::Path;
29
30use serde::{Deserialize, Serialize};
31
32/// A source language codehelion can enumerate.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum Language {
36    /// Rust.
37    Rust,
38    /// C.
39    C,
40    /// C++.
41    Cpp,
42}
43
44impl Language {
45    /// Stable lowercase identifier used in reports and fingerprints.
46    #[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/// How to classify a bare `.h` header, which C and C++ share.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum HeaderPolicy {
59    /// Treat `.h` as C.
60    C,
61    /// Treat `.h` as C++.
62    Cpp,
63    /// Settle `.h` from the rest of the tree, by [`HeaderEvidence`].
64    #[default]
65    Detect,
66}
67
68impl HeaderPolicy {
69    /// Stable lowercase identifier used in reports and configuration.
70    #[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/// A tally of the files whose extension names their language outright, used to
81/// settle the bare `.h` headers whose extension does not.
82///
83/// The rule is the plain reading of a mixed tree: a project written mostly in
84/// C++ spells its headers `.h` because the extension is conventional, not
85/// because those headers are C. A project written mostly in C means C.
86///
87/// Only unambiguous extensions count, so the headers being settled never vote
88/// on their own language, and the verdict does not depend on how many of them
89/// there are.
90#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
91pub struct HeaderEvidence {
92    c: usize,
93    cpp: usize,
94}
95
96impl HeaderEvidence {
97    /// Count one classified file, ignoring the ones that prove nothing:
98    /// Rust sources, and the `.h` headers awaiting this verdict.
99    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    /// The language to read bare `.h` headers as, when the tree says.
111    ///
112    /// C++ only when the tree holds strictly more unambiguous C++ files than C
113    /// ones. [`None`] when it holds neither, which is not a tie to be broken
114    /// but a question this tally cannot answer: a header-only library has no
115    /// files outside the headers, so there is nothing here to read the headers
116    /// against.
117    #[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
127/// Whether `source` spells something only C++ has.
128///
129/// The fallback for a tree whose extensions settle nothing. It is a spelling
130/// check and not a parse: comments and literals are skipped, and what is left
131/// is searched for four constructs C has no reading of — a scope resolution,
132/// a template's angle bracket, a named namespace, and an include of a standard
133/// header without an extension.
134///
135/// One header saying C++ settles the whole run, because the two mistakes are
136/// not the same size. C++ is nearly a superset, so a C header read with the
137/// C++ grammar parses; a C++ header read with the C grammar does not, and the
138/// error recovery spreads the damage past the construct that caused it. Where
139/// the evidence is this thin, the reading that survives being wrong is the one
140/// to take.
141pub(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                // `template` and `namespace` are ordinary identifiers in C, so
162                // it is what follows that makes them C++: an angle bracket
163                // opening a parameter list, a name or a brace opening a scope.
164                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
185/// Bytes up to and including the first `needle`, or all of `bytes`.
186fn 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
193/// Bytes of the literal starting at `bytes[0]`, closing on an unescaped
194/// `quote`. An unterminated literal swallows the rest, which is the reading
195/// that cannot loop.
196fn 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
208/// The identifier at the start of `bytes`.
209fn 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
217/// Whether a preprocessor line includes an angle-bracketed header with no
218/// extension: `<memory>` is a C++ standard header, `<string.h>` is C's.
219fn 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/// The languages a discovery run is allowed to enumerate.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub struct LanguageSelection {
238    /// Include Rust sources.
239    pub rust: bool,
240    /// Include C sources.
241    pub c: bool,
242    /// Include C++ sources.
243    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    /// Whether `language` is enabled in this selection.
258    #[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    /// The enabled languages in a fixed order, for stable serialisation.
268    #[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/// The result of classifying one file: its language and whether it is a header.
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286pub struct Classification {
287    /// The detected language.
288    pub language: Language,
289    /// Whether the file is a header (declarations) rather than a translation
290    /// unit. Always `false` for Rust.
291    pub is_header: bool,
292    /// Whether [`language`](Self::language) is a placeholder awaiting the
293    /// tree-wide verdict, rather than a reading of the extension. Only ever
294    /// true for a bare `.h` under [`HeaderPolicy::Detect`].
295    pub provisional: bool,
296}
297
298impl Classification {
299    /// The same classification with a settled language, for a header the
300    /// policy left to detection. Anything already settled is returned as is.
301    #[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/// Classify `path` by its extension, returning `None` for unsupported files.
316///
317/// The bare `.h` extension is resolved with `header_policy`; all other
318/// extensions map unambiguously. Under [`HeaderPolicy::Detect`] a `.h` comes
319/// back `provisional`, carrying C as a placeholder until
320/// [`HeaderEvidence::verdict`] settles it.
321#[must_use]
322pub(super) fn classify(path: &Path, header_policy: HeaderPolicy) -> Option<Classification> {
323    // Match the raw extension: capitalisation is meaningful (`.C`/`.H` are the
324    // classic C++ spellings), so it is not lowercased first.
325    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        // A `.hpp` is C++ whatever the tree says, so the verdict must not
418        // reach it. Otherwise a mostly-C project would rewrite its C++
419        // headers into C.
420        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    /// Tally `names` the way discovery does, and return the verdict.
427    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        // Two vendored C++ fuzz harnesses do not make a C project C++.
448        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        // No C or C++ translation unit anywhere: a lone header in a Rust
457        // tree, or a header-only library shipped by itself. The tally has
458        // nothing to say, and saying so is what sends the caller to read the
459        // headers instead of settling them by default.
460        assert_eq!(verdict_over(&["main.rs", "lib.rs", "a.h"]), None);
461        assert_eq!(verdict_over(&[]), None);
462        // A tie between translation units is evidence, and it reads as C: a
463        // project with as many C files as C++ ones is not a C++ project.
464        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        // Under `Detect` a `.h` is provisionally C. If it counted, a C++
470        // project with more headers than sources would settle on C by its own
471        // placeholder.
472        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            // The words are all C++, and every one of them is in a comment,
495            // a string or a name C is entitled to use.
496            "/* 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            // A bitfield's colon, twice, is not a scope resolution.
502            "struct s { unsigned a : 1; unsigned b : 1; };\n",
503            // An unterminated literal must not read past itself into a
504            // verdict, and must not loop.
505            "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}