fabrica

hanna/fabrica

6802 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Repository language breakdown for the language bar.
6//!
7//! A pragmatic, dependency-free classifier: files are mapped to a language by
8//! filename or extension against a curated table (colors follow GitHub Linguist),
9//! then weighted by blob size. Unrecognized extensions and obvious non-code files
10//! (lock files, minified assets) are ignored, which keeps the bar focused on the
11//! languages a project is actually written in — it is an approximation, not
12//! Linguist.
13
14/// One language's share of a repository.
15#[derive(Debug, Clone, PartialEq)]
16pub(crate) struct LangStat {
17 /// Display name, e.g. "Rust".
18 pub name: &'static str,
19 /// Hex color (Linguist palette), e.g. "#dea584".
20 pub color: &'static str,
21 /// Total bytes attributed to this language.
22 pub bytes: u64,
23 /// Percentage of the counted total, 0–100.
24 pub percent: f64,
25}
26
27/// Compute the language breakdown from `(path, size)` pairs, largest first.
28/// Returns an empty vec when nothing is recognized.
29pub(crate) fn breakdown(files: &[(String, u64)]) -> Vec<LangStat> {
30 use std::collections::HashMap;
31 let mut totals: HashMap<&'static str, (&'static str, u64)> = HashMap::new();
32 let mut total: u64 = 0;
33 for (path, size) in files {
34 if *size == 0 || is_ignored(path) {
35 continue;
36 }
37 if let Some((name, color)) = classify(path) {
38 let entry = totals.entry(name).or_insert((color, 0));
39 entry.1 += *size;
40 total += *size;
41 }
42 }
43 if total == 0 {
44 return Vec::new();
45 }
46 let mut stats: Vec<LangStat> = totals
47 .into_iter()
48 .map(|(name, (color, bytes))| LangStat {
49 name,
50 color,
51 bytes,
52 #[allow(clippy::cast_precision_loss)]
53 percent: (bytes as f64) * 100.0 / (total as f64),
54 })
55 .collect();
56 // Largest first; ties broken by name for a stable order.
57 stats.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(b.name)));
58 stats
59}
60
61/// Files never counted toward language stats (lock files, minified/vendored
62/// assets, and generated maps).
63#[allow(clippy::case_sensitive_file_extension_comparisons)] // `name` is lowercased first.
64fn is_ignored(path: &str) -> bool {
65 let name = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
66 name.ends_with(".min.js")
67 || name.ends_with(".min.css")
68 || name.ends_with(".map")
69 || matches!(
70 name.as_str(),
71 "package-lock.json"
72 | "yarn.lock"
73 | "pnpm-lock.yaml"
74 | "cargo.lock"
75 | "poetry.lock"
76 | "composer.lock"
77 | "gemfile.lock"
78 | "flake.lock"
79 )
80}
81
82/// Classify a path to `(language, color)` by exact filename first, then by the
83/// longest matching extension.
84fn classify(path: &str) -> Option<(&'static str, &'static str)> {
85 let name = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
86 // A few languages are keyed by filename rather than extension.
87 let by_name = match name.as_str() {
88 "dockerfile" => Some(("Dockerfile", "#384d54")),
89 "makefile" => Some(("Makefile", "#427819")),
90 "cmakelists.txt" => Some(("CMake", "#DA3434")),
91 _ => None,
92 };
93 if by_name.is_some() {
94 return by_name;
95 }
96 // Longest-suffix extension match (so `.tar.gz`-style compounds could extend).
97 let ext = name.rsplit('.').next()?;
98 // Guard against extension-less files (rsplit returns the whole name).
99 if !name.contains('.') {
100 return None;
101 }
102 Some(match ext {
103 "rs" => ("Rust", "#dea584"),
104 "py" | "pyi" => ("Python", "#3572A5"),
105 "js" | "cjs" | "mjs" | "jsx" => ("JavaScript", "#f1e05a"),
106 "ts" | "tsx" => ("TypeScript", "#3178c6"),
107 "go" => ("Go", "#00ADD8"),
108 "c" | "h" => ("C", "#555555"),
109 "cc" | "cpp" | "cxx" | "hpp" | "hh" => ("C++", "#f34b7d"),
110 "cs" => ("C#", "#178600"),
111 "java" => ("Java", "#b07219"),
112 "kt" | "kts" => ("Kotlin", "#A97BFF"),
113 "swift" => ("Swift", "#F05138"),
114 "rb" => ("Ruby", "#701516"),
115 "php" => ("PHP", "#4F5D95"),
116 "sh" | "bash" | "zsh" => ("Shell", "#89e051"),
117 "html" | "htm" => ("HTML", "#e34c26"),
118 "css" => ("CSS", "#563d7c"),
119 "scss" | "sass" => ("SCSS", "#c6538c"),
120 "vue" => ("Vue", "#41b883"),
121 "svelte" => ("Svelte", "#ff3e00"),
122 "astro" => ("Astro", "#ff5a03"),
123 "md" | "markdown" => ("Markdown", "#083fa1"),
124 "mdx" => ("MDX", "#fcb32c"),
125 "json" => ("JSON", "#292929"),
126 "yml" | "yaml" => ("YAML", "#cb171e"),
127 "toml" => ("TOML", "#9c4221"),
128 "nix" => ("Nix", "#7e7eff"),
129 "lua" => ("Lua", "#000080"),
130 "ex" | "exs" => ("Elixir", "#6e4a7e"),
131 "erl" => ("Erlang", "#B83998"),
132 "hs" => ("Haskell", "#5e5086"),
133 "ml" | "mli" => ("OCaml", "#ef7a08"),
134 "clj" | "cljs" => ("Clojure", "#db5855"),
135 "scala" => ("Scala", "#c22d40"),
136 "dart" => ("Dart", "#00B4AB"),
137 "r" => ("R", "#198CE7"),
138 "jl" => ("Julia", "#a270ba"),
139 "zig" => ("Zig", "#ec915c"),
140 "sql" => ("SQL", "#e38c00"),
141 "vim" => ("Vim Script", "#199f4b"),
142 "tf" => ("HCL", "#844FBA"),
143 "proto" => ("Protocol Buffers", "#3765a3"),
144 "gradle" => ("Gradle", "#02303a"),
145 _ => return None,
146 })
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn classifies_by_extension_and_name() {
155 assert_eq!(classify("src/main.rs").map(|l| l.0), Some("Rust"));
156 assert_eq!(classify("a/b/Dockerfile").map(|l| l.0), Some("Dockerfile"));
157 assert_eq!(classify("weird_no_ext"), None);
158 assert_eq!(classify("x.unknownext"), None);
159 }
160
161 #[test]
162 fn breakdown_sums_and_ranks() {
163 let files = vec![
164 ("a.rs".to_string(), 300),
165 ("b.rs".to_string(), 100),
166 ("c.py".to_string(), 100),
167 ("Cargo.lock".to_string(), 9999), // ignored
168 ("readme".to_string(), 50), // no extension → ignored
169 ];
170 let stats = breakdown(&files);
171 assert_eq!(stats.len(), 2);
172 assert_eq!(stats[0].name, "Rust");
173 assert_eq!(stats[0].bytes, 400);
174 assert!((stats[0].percent - 80.0).abs() < 0.01);
175 assert_eq!(stats[1].name, "Python");
176 }
177
178 #[test]
179 fn empty_when_nothing_recognized() {
180 assert!(breakdown(&[("data.bin".to_string(), 10)]).is_empty());
181 }
182}