// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Repository language breakdown for the language bar. //! //! A pragmatic, dependency-free classifier: files are mapped to a language by //! filename or extension against a curated table (colors follow GitHub Linguist), //! then weighted by blob size. Unrecognized extensions and obvious non-code files //! (lock files, minified assets) are ignored, which keeps the bar focused on the //! languages a project is actually written in — it is an approximation, not //! Linguist. /// One language's share of a repository. #[derive(Debug, Clone, PartialEq)] pub(crate) struct LangStat { /// Display name, e.g. "Rust". pub name: &'static str, /// Hex color (Linguist palette), e.g. "#dea584". pub color: &'static str, /// Total bytes attributed to this language. pub bytes: u64, /// Percentage of the counted total, 0–100. pub percent: f64, } /// Compute the language breakdown from `(path, size)` pairs, largest first. /// Returns an empty vec when nothing is recognized. pub(crate) fn breakdown(files: &[(String, u64)]) -> Vec { use std::collections::HashMap; let mut totals: HashMap<&'static str, (&'static str, u64)> = HashMap::new(); let mut total: u64 = 0; for (path, size) in files { if *size == 0 || is_ignored(path) { continue; } if let Some((name, color)) = classify(path) { let entry = totals.entry(name).or_insert((color, 0)); entry.1 += *size; total += *size; } } if total == 0 { return Vec::new(); } let mut stats: Vec = totals .into_iter() .map(|(name, (color, bytes))| LangStat { name, color, bytes, #[allow(clippy::cast_precision_loss)] percent: (bytes as f64) * 100.0 / (total as f64), }) .collect(); // Largest first; ties broken by name for a stable order. stats.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(b.name))); stats } /// Files never counted toward language stats (lock files, minified/vendored /// assets, and generated maps). #[allow(clippy::case_sensitive_file_extension_comparisons)] // `name` is lowercased first. fn is_ignored(path: &str) -> bool { let name = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase(); name.ends_with(".min.js") || name.ends_with(".min.css") || name.ends_with(".map") || matches!( name.as_str(), "package-lock.json" | "yarn.lock" | "pnpm-lock.yaml" | "cargo.lock" | "poetry.lock" | "composer.lock" | "gemfile.lock" | "flake.lock" ) } /// Classify a path to `(language, color)` by exact filename first, then by the /// longest matching extension. fn classify(path: &str) -> Option<(&'static str, &'static str)> { let name = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase(); // A few languages are keyed by filename rather than extension. let by_name = match name.as_str() { "dockerfile" => Some(("Dockerfile", "#384d54")), "makefile" => Some(("Makefile", "#427819")), "cmakelists.txt" => Some(("CMake", "#DA3434")), _ => None, }; if by_name.is_some() { return by_name; } // Longest-suffix extension match (so `.tar.gz`-style compounds could extend). let ext = name.rsplit('.').next()?; // Guard against extension-less files (rsplit returns the whole name). if !name.contains('.') { return None; } Some(match ext { "rs" => ("Rust", "#dea584"), "py" | "pyi" => ("Python", "#3572A5"), "js" | "cjs" | "mjs" | "jsx" => ("JavaScript", "#f1e05a"), "ts" | "tsx" => ("TypeScript", "#3178c6"), "go" => ("Go", "#00ADD8"), "c" | "h" => ("C", "#555555"), "cc" | "cpp" | "cxx" | "hpp" | "hh" => ("C++", "#f34b7d"), "cs" => ("C#", "#178600"), "java" => ("Java", "#b07219"), "kt" | "kts" => ("Kotlin", "#A97BFF"), "swift" => ("Swift", "#F05138"), "rb" => ("Ruby", "#701516"), "php" => ("PHP", "#4F5D95"), "sh" | "bash" | "zsh" => ("Shell", "#89e051"), "html" | "htm" => ("HTML", "#e34c26"), "css" => ("CSS", "#563d7c"), "scss" | "sass" => ("SCSS", "#c6538c"), "vue" => ("Vue", "#41b883"), "svelte" => ("Svelte", "#ff3e00"), "astro" => ("Astro", "#ff5a03"), "md" | "markdown" => ("Markdown", "#083fa1"), "mdx" => ("MDX", "#fcb32c"), "json" => ("JSON", "#292929"), "yml" | "yaml" => ("YAML", "#cb171e"), "toml" => ("TOML", "#9c4221"), "nix" => ("Nix", "#7e7eff"), "lua" => ("Lua", "#000080"), "ex" | "exs" => ("Elixir", "#6e4a7e"), "erl" => ("Erlang", "#B83998"), "hs" => ("Haskell", "#5e5086"), "ml" | "mli" => ("OCaml", "#ef7a08"), "clj" | "cljs" => ("Clojure", "#db5855"), "scala" => ("Scala", "#c22d40"), "dart" => ("Dart", "#00B4AB"), "r" => ("R", "#198CE7"), "jl" => ("Julia", "#a270ba"), "zig" => ("Zig", "#ec915c"), "sql" => ("SQL", "#e38c00"), "vim" => ("Vim Script", "#199f4b"), "tf" => ("HCL", "#844FBA"), "proto" => ("Protocol Buffers", "#3765a3"), "gradle" => ("Gradle", "#02303a"), _ => return None, }) } #[cfg(test)] mod tests { use super::*; #[test] fn classifies_by_extension_and_name() { assert_eq!(classify("src/main.rs").map(|l| l.0), Some("Rust")); assert_eq!(classify("a/b/Dockerfile").map(|l| l.0), Some("Dockerfile")); assert_eq!(classify("weird_no_ext"), None); assert_eq!(classify("x.unknownext"), None); } #[test] fn breakdown_sums_and_ranks() { let files = vec![ ("a.rs".to_string(), 300), ("b.rs".to_string(), 100), ("c.py".to_string(), 100), ("Cargo.lock".to_string(), 9999), // ignored ("readme".to_string(), 50), // no extension → ignored ]; let stats = breakdown(&files); assert_eq!(stats.len(), 2); assert_eq!(stats[0].name, "Rust"); assert_eq!(stats[0].bytes, 400); assert!((stats[0].percent - 80.0).abs() < 0.01); assert_eq!(stats[1].name, "Python"); } #[test] fn empty_when_nothing_recognized() { assert!(breakdown(&[("data.bin".to_string(), 10)]).is_empty()); } }