anvilsign in

collin/anvil

main / crates / anvil-core / src / language.rs
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 compute_language_stats(counts, total)
61}
62
63/// Detect languages in a bare git repository by reading from a branch.
64/// Returns primary language + JSON-encoded percentages.
65pub fn detect_from_git(repo_path: &Path, branch: &str) -> Result<LanguageDetection> {
66 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
67 let mut total = 0u64;
68
69 // Open the repository and traverse the tree at the given branch
70 let repo = gix::open(repo_path)
71 .map_err(|e| crate::error::Error::Storage(format!("open repository: {e}")))?;
72 let tree = repo
73 .rev_parse_single(branch)
74 .map_err(|e| crate::error::Error::Storage(format!("resolve {branch}: {e}")))?
75 .object()
76 .map_err(|e| crate::error::Error::Storage(format!("get object: {e}")))?
77 .peel_to_commit()
78 .map_err(|e| crate::error::Error::Storage(format!("peel to commit: {e}")))?
79 .tree()
80 .map_err(|e| crate::error::Error::Storage(format!("get tree: {e}")))?;
81 collect_languages(&tree, String::new(), &mut counts, &mut total)?;
82
83 compute_language_stats(counts, total)
84}
85
86/// Recursively walk a git tree and count files by language
87fn collect_languages(
88 tree: &gix::Tree<'_>,
89 path_prefix: String,
90 counts: &mut BTreeMap<String, u64>,
91 total: &mut u64,
92) -> Result<()> {
93 for entry in tree.iter() {
94 let e = entry.map_err(|e| crate::error::Error::Storage(format!("read tree entry: {e}")))?;
95 let file_name = e.filename();
96 let name = std::str::from_utf8(file_name).unwrap_or("");
97
98 let new_path = if path_prefix.is_empty() {
99 name.to_string()
100 } else {
101 format!("{}/{}", path_prefix, name)
102 };
103
104 // Skip hidden files and directories
105 if name.starts_with('.') {
106 continue;
107 }
108
109 let mode = e.mode();
110 if mode.is_tree() {
111 // Recursively walk subdirectories
112 let subtree = e
113 .object()
114 .map_err(|e| crate::error::Error::Storage(format!("get subtree object: {e}")))?
115 .peel_to_tree()
116 .map_err(|e| crate::error::Error::Storage(format!("peel to tree: {e}")))?;
117 collect_languages(&subtree, new_path, counts, total)?;
118 } else if mode.is_blob() {
119 // Count files by extension
120 if let Some(ext) = Path::new(&new_path).extension().and_then(|s| s.to_str())
121 && let Some(lang) = ext_to_language(ext)
122 {
123 *counts.entry(lang.to_string()).or_insert(0) += 1;
124 *total += 1;
125 }
126 }
127 }
128 Ok(())
129}
130
131fn compute_language_stats(counts: BTreeMap<String, u64>, total: u64) -> Result<LanguageDetection> {
132 let primary = if total == 0 {
133 String::new()
134 } else {
135 let mut max_lang = String::new();
136 let mut max_count = 0u64;
137 for (lang, count) in &counts {
138 if *count > max_count {
139 max_count = *count;
140 max_lang = lang.clone();
141 }
142 }
143 max_lang
144 };
145
146 let json = if counts.is_empty() {
147 "[]".to_string()
148 } else {
149 let mut entries: Vec<_> = counts.iter().collect();
150 entries.sort_by(|a, b| b.1.cmp(a.1));
151
152 let percentages: Vec<String> = entries
153 .iter()
154 .map(|(lang, count)| {
155 let percent = (**count as f64 / total as f64) * 100.0;
156 format!(r#"{{"lang":"{}","percent":{:.1}}}"#, lang, percent)
157 })
158 .collect();
159 format!("[{}]", percentages.join(","))
160 };
161
162 Ok(LanguageDetection { primary, json })
163}
164
165fn walk_dir(counts: &mut BTreeMap<String, u64>, total: &mut u64, entries: std::fs::ReadDir) {
166 for entry in entries.flatten() {
167 let Ok(metadata) = entry.metadata() else {
168 continue;
169 };
170
171 let path = entry.path();
172 let file_name = entry.file_name();
173 let name = file_name.to_string_lossy();
174
175 // Skip .git and hidden directories.
176 if name.starts_with('.') {
177 continue;
178 }
179
180 if metadata.is_dir() {
181 if let Ok(subentries) = std::fs::read_dir(&path) {
182 walk_dir(counts, total, subentries);
183 }
184 } else if metadata.is_file()
185 && let Some(ext) = path.extension().and_then(|s| s.to_str())
186 && let Some(lang) = ext_to_language(ext)
187 {
188 *counts.entry(lang.to_string()).or_insert(0) += 1;
189 *total += 1;
190 }
191 }
192}