anvilsign in

collin/anvil

1//! Language detection for repositories by file-type counting.
2
3use std::{
4 collections::BTreeMap,
5 path::Path,
6};
7
8use crate::error::Result;
9
10/// Language percentages for a repository.
11#[derive(Clone, Debug)]
12pub struct LanguageDetection {
13 /// Primary language by count, or empty if no files.
14 pub primary: String,
15 /// JSON array: [{"lang": "Rust", "percent": 75.5}, ...]
16 pub json: String,
17}
18
19/// Map of file extensions to language names.
20fn ext_to_language(ext: &str) -> Option<&'static str> {
21 match ext {
22 "rs" => Some("Rust"),
23 "go" => Some("Go"),
24 "py" => Some("Python"),
25 "js" | "jsx" => Some("JavaScript"),
26 "ts" | "tsx" => Some("TypeScript"),
27 "java" => Some("Java"),
28 "cpp" | "cc" | "cxx" | "c++" => Some("C++"),
29 "c" => Some("C"),
30 "h" | "hpp" => Some("C/C++"),
31 "cs" => Some("C#"),
32 "rb" => Some("Ruby"),
33 "php" => Some("PHP"),
34 "swift" => Some("Swift"),
35 "kt" => Some("Kotlin"),
36 "sh" | "bash" => Some("Shell"),
37 "yml" | "yaml" => Some("YAML"),
38 "json" => Some("JSON"),
39 "toml" => Some("TOML"),
40 "md" => Some("Markdown"),
41 "html" | "htm" => Some("HTML"),
42 "css" | "scss" | "sass" => Some("CSS"),
43 "sql" => Some("SQL"),
44 "xml" => Some("XML"),
45 _ => None,
46 }
47}
48
49/// Detect languages in a repository by walking the file tree and counting by extension.
50/// Returns primary language + JSON-encoded percentages.
51pub async fn detect(repo_path: &Path) -> Result<LanguageDetection> {
52 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
53 let mut total = 0u64;
54
55 // Walk the repository tree, skipping .git.
56 if let Ok(entries) = std::fs::read_dir(repo_path) {
57 walk_dir(&mut counts, &mut total, entries);
58 }
59
60 let primary = if total == 0 {
61 String::new()
62 } else {
63 let mut max_lang = String::new();
64 let mut max_count = 0u64;
65 for (lang, count) in &counts {
66 if *count > max_count {
67 max_count = *count;
68 max_lang = lang.clone();
69 }
70 }
71 max_lang
72 };
73
74 let json = if counts.is_empty() {
75 "[]".to_string()
76 } else {
77 let mut entries: Vec<_> = counts.iter().collect();
78 entries.sort_by(|a, b| b.1.cmp(a.1));
79
80 let percentages: Vec<String> = entries
81 .iter()
82 .map(|(lang, count)| {
83 let percent = (**count as f64 / total as f64) * 100.0;
84 format!(r#"{{"lang":"{}","percent":{:.1}}}"#, lang, percent)
85 })
86 .collect();
87 format!("[{}]", percentages.join(","))
88 };
89
90 Ok(LanguageDetection { primary, json })
91}
92
93fn walk_dir(counts: &mut BTreeMap<String, u64>, total: &mut u64, entries: std::fs::ReadDir) {
94 for entry in entries.flatten() {
95 let Ok(metadata) = entry.metadata() else {
96 continue;
97 };
98
99 let path = entry.path();
100 let file_name = entry.file_name();
101 let name = file_name.to_string_lossy();
102
103 // Skip .git and hidden directories.
104 if name.starts_with('.') {
105 continue;
106 }
107
108 if metadata.is_dir() {
109 if let Ok(subentries) = std::fs::read_dir(&path) {
110 walk_dir(counts, total, subentries);
111 }
112 } else if metadata.is_file()
113 && let Some(ext) = path.extension().and_then(|s| s.to_str())
114 && let Some(lang) = ext_to_language(ext)
115 {
116 *counts.entry(lang.to_string()).or_insert(0) += 1;
117 *total += 1;
118 }
119 }
120}