use crate::core::counter::comment_patterns;
use crate::core::patterns::PatternMatcher;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct SherlockLanguage {
pub name: String,
pub color: String,
pub file_count: usize,
pub files: Vec<String>,
pub percentage: f64,
pub total_bytes: u64,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct SherlockSummary {
pub languages_detected: usize,
pub total_bytes: u64,
pub total_files: usize,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct SherlockResult {
pub languages: Vec<SherlockLanguage>,
pub summary: SherlockSummary,
pub unknown_files: Vec<String>,
}
impl SherlockResult {
pub fn empty() -> Self {
Self {
languages: Vec::new(),
summary: SherlockSummary {
languages_detected: 0,
total_bytes: 0,
total_files: 0,
},
unknown_files: Vec::new(),
}
}
pub fn is_empty(&self) -> bool {
self.languages.is_empty()
}
}
fn normalize_key(raw: &str) -> String {
let unified = raw.replace('\\', "/");
let trimmed = unified.trim_start_matches("./").trim_start_matches('/');
trimmed.to_string()
}
#[derive(Debug, Default, Clone)]
struct SherlockIndex {
by_path: HashSet<String>,
language_by_extension: HashMap<String, String>,
}
impl SherlockIndex {
fn build(result: &SherlockResult, root: Option<&Path>) -> Self {
let root_key = root.map(|r| normalize_key(&r.to_string_lossy()));
let root_leaf = root
.and_then(|r| r.file_name())
.map(|n| n.to_string_lossy().to_string());
let mut by_path = HashSet::new();
let mut language_by_extension = HashMap::new();
for language in &result.languages {
for file in &language.files {
let key = normalize_key(file);
for prefix in [root_key.as_deref(), root_leaf.as_deref()]
.into_iter()
.flatten()
{
if let Some(rel) = key.strip_prefix(prefix) {
let rel = rel.trim_start_matches('/');
if !rel.is_empty() && rel != key {
by_path.insert(rel.to_string());
}
}
}
if let Some(ext) = Path::new(&key).extension().and_then(|e| e.to_str()) {
language_by_extension
.entry(ext.to_lowercase())
.or_insert_with(|| language.name.clone());
}
by_path.insert(key);
}
}
Self {
by_path,
language_by_extension,
}
}
fn contains(&self, relative: &str, absolute: &str) -> bool {
self.by_path.contains(relative) || self.by_path.contains(&normalize_key(absolute))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Classification {
Source,
Rejected,
Unknown,
}
pub struct DetectionJob {
child: std::process::Child,
}
impl DetectionJob {
fn start(path: &Path) -> std::io::Result<Self> {
Command::new("sherlock")
.arg(path.as_os_str())
.arg("--format")
.arg("json")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map(|child| Self { child })
}
pub fn finish(self) -> std::result::Result<SherlockResult, Box<dyn std::error::Error>> {
let output = self.child.wait_with_output()?;
if !output.status.success() {
return Err(format!(
"sherlock exited with {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)
.into());
}
Ok(serde_json::from_slice(&output.stdout)?)
}
pub fn cancel(mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[derive(Debug, Clone)]
pub struct FileDetector {
pattern_matcher: PatternMatcher,
sherlock_result: Option<SherlockResult>,
sherlock_index: SherlockIndex,
root: Option<PathBuf>,
}
impl Default for FileDetector {
fn default() -> Self {
Self::new()
}
}
impl FileDetector {
pub fn new() -> Self {
Self {
pattern_matcher: PatternMatcher::new(),
sherlock_result: None,
sherlock_index: SherlockIndex::default(),
root: None,
}
}
pub fn with_root(mut self, root: impl Into<PathBuf>) -> Self {
self.root = Some(root.into());
self.reindex();
self
}
pub fn with_sherlock_result(mut self, sherlock_result: SherlockResult) -> Self {
self.sherlock_result = Some(sherlock_result);
self.reindex();
self
}
fn reindex(&mut self) {
self.sherlock_index = match &self.sherlock_result {
Some(result) => SherlockIndex::build(result, self.root.as_deref()),
None => SherlockIndex::default(),
};
}
pub fn root(&self) -> Option<&Path> {
self.root.as_deref()
}
pub fn sherlock_result(&self) -> Option<&SherlockResult> {
self.sherlock_result.as_ref()
}
pub fn classify(&self, path: &Path) -> Classification {
let relative = self
.pattern_matcher
.relative_path(path, self.root.as_deref());
if self.pattern_matcher.is_excluded_path(&relative) {
return Classification::Rejected;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if self.pattern_matcher.is_generated_file(name)
|| PatternMatcher::is_boilerplate_file(name)
{
return Classification::Rejected;
}
}
if Self::is_recognized_source(path) || Self::is_script_by_shebang(path) {
return Classification::Source;
}
Classification::Unknown
}
pub fn is_user_created_file(&self, path: &Path) -> bool {
match self.classify(path) {
Classification::Source => true,
Classification::Rejected => false,
Classification::Unknown => self.detected_as_source(path),
}
}
pub fn detected_as_source(&self, path: &Path) -> bool {
if self.sherlock_index.by_path.is_empty() {
return false;
}
let relative = self
.pattern_matcher
.relative_path(path, self.root.as_deref());
self.sherlock_index
.contains(&relative, &path.to_string_lossy())
}
pub fn is_code_file(&self, path: &Path) -> bool {
Self::is_recognized_source(path) || Self::is_script_by_shebang(path)
}
fn is_script_by_shebang(path: &Path) -> bool {
if path.extension().is_some() {
return false;
}
let mut head = [0u8; 128];
let Ok(mut file) = fs::File::open(path) else {
return false;
};
let Ok(read) = file.read(&mut head) else {
return false;
};
crate::core::counter::shebang_key(&head[..read]).is_some()
}
pub fn is_recognized_source(path: &Path) -> bool {
comment_patterns::is_known(&crate::core::counter::classify_key(path))
}
pub fn is_code_extension(ext: &str) -> bool {
if ext.bytes().any(|b| b.is_ascii_uppercase()) {
comment_patterns::is_known(&ext.to_lowercase())
} else {
comment_patterns::is_known(ext)
}
}
pub fn detection_available() -> bool {
Command::new("sherlock")
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn detect_languages(
&self,
path: &Path,
) -> std::result::Result<SherlockResult, Box<dyn std::error::Error>> {
DetectionJob::start(path)?.finish()
}
pub fn start_detection(path: &Path) -> std::io::Result<DetectionJob> {
DetectionJob::start(path)
}
pub fn get_language_from_extension(
&self,
extension: &str,
sherlock_result: &SherlockResult,
) -> Option<String> {
let key = extension.to_lowercase();
if let Some(name) = self.sherlock_index.language_by_extension.get(&key) {
return Some(name.clone());
}
for language in &sherlock_result.languages {
for file in &language.files {
if Path::new(file)
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case(&key))
{
return Some(language.name.clone());
}
}
}
Self::language_name_for_extension(&key)
}
pub fn language_name_for_extension(extension: &str) -> Option<String> {
let name = match extension.to_lowercase().as_str() {
"rs" => "Rust",
"py" => "Python",
"js" => "JavaScript",
"ts" => "TypeScript",
"java" => "Java",
"cpp" | "cc" | "cxx" => "C++",
"c" => "C",
"go" => "Go",
"rb" => "Ruby",
"php" => "PHP",
"cs" => "C#",
"swift" => "Swift",
"kt" => "Kotlin",
"html" => "HTML",
"css" => "CSS",
"scss" | "sass" => "Sass",
"json" => "JSON",
"yaml" | "yml" => "YAML",
"toml" => "TOML",
"md" => "Markdown",
_ => return None,
};
Some(name.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn result_with(files: &[&str]) -> SherlockResult {
SherlockResult {
languages: vec![SherlockLanguage {
name: "Rust".to_string(),
color: "#000".to_string(),
file_count: files.len(),
files: files.iter().map(|f| f.to_string()).collect(),
percentage: 100.0,
total_bytes: 0,
}],
summary: SherlockSummary {
languages_detected: 1,
total_bytes: 0,
total_files: files.len(),
},
unknown_files: Vec::new(),
}
}
#[test]
fn code_extensions_are_recognized_case_insensitively() {
let d = FileDetector::new();
let root = Path::new("/p");
for name in ["a.rs", "a.RS", "b.Py", "c.TS"] {
assert!(
d.clone()
.with_root(root)
.is_user_created_file(&root.join(name)),
"{name} should count"
);
}
}
#[test]
fn non_code_extensions_are_rejected() {
let root = Path::new("/p");
let d = FileDetector::new().with_root(root);
for name in ["a.png", "a.exe", "LICENSE"] {
assert!(
!d.is_user_created_file(&root.join(name)),
"{name} must not count"
);
}
}
#[test]
fn hostile_ancestor_directories_do_not_exclude_sources() {
for root in [
"/tmp/checkout",
"/build/workspace",
"/home/u/env/proj",
"/var/log/proj",
"/x/bin/proj",
"/x/target/proj",
"/x/vendor/proj",
] {
let root = Path::new(root);
let detector = FileDetector::new().with_root(root);
assert!(
detector.is_user_created_file(&root.join("src/main.rs")),
"sources under {root:?} were excluded by an ancestor directory name"
);
}
}
#[test]
fn build_directories_inside_the_project_are_still_excluded() {
let root = Path::new("/tmp/checkout");
let detector = FileDetector::new().with_root(root);
for rel in [
"node_modules/dep/index.js",
"target/debug/x.rs",
"__pycache__/m.py",
".git/hooks/pre-commit.sh",
] {
assert!(
!detector.is_user_created_file(&root.join(rel)),
"{rel} should be excluded"
);
}
}
#[test]
fn sherlock_detections_are_honoured_for_unknown_extensions() {
let root = Path::new("/p");
let detector = FileDetector::new()
.with_root(root)
.with_sherlock_result(result_with(&["/p/weird/thing.customext"]));
assert!(detector.is_user_created_file(&root.join("weird/thing.customext")));
assert!(!detector.is_user_created_file(&root.join("weird/other.customext")));
}
#[test]
fn sherlock_paths_match_under_relative_and_absolute_forms() {
let root = Path::new("/p/corpus");
for reported in [
"./corpus/deep/thing.customext",
"/p/corpus/deep/thing.customext",
"deep/thing.customext",
] {
let detector = FileDetector::new()
.with_root(root)
.with_sherlock_result(result_with(&[reported]));
assert!(
detector.is_user_created_file(&root.join("deep/thing.customext")),
"reported form {reported:?} did not match"
);
}
}
#[test]
fn sherlock_cannot_override_build_exclusions() {
let root = Path::new("/p");
let detector = FileDetector::new()
.with_root(root)
.with_sherlock_result(result_with(&["/p/node_modules/dep/index.js"]));
assert!(!detector.is_user_created_file(&root.join("node_modules/dep/index.js")));
}
#[test]
fn extension_fallback_matches_sherlock_for_known_languages() {
let root = Path::new("/p");
let files = [
"src/main.rs",
"app/util.py",
"web/index.js",
"svc/handler.go",
];
let reported: Vec<String> = files.iter().map(|f| format!("/p/{f}")).collect();
let reported_refs: Vec<&str> = reported.iter().map(String::as_str).collect();
let with_detection = FileDetector::new()
.with_root(root)
.with_sherlock_result(result_with(&reported_refs));
let without_detection = FileDetector::new().with_root(root);
for f in files {
let p = root.join(f);
assert_eq!(
with_detection.is_user_created_file(&p),
without_detection.is_user_created_file(&p),
"{f} classified differently with and without language detection"
);
}
}
#[test]
fn language_names_resolve_from_extension() {
assert_eq!(
FileDetector::language_name_for_extension("rs").as_deref(),
Some("Rust")
);
assert_eq!(
FileDetector::language_name_for_extension("RS").as_deref(),
Some("Rust")
);
assert_eq!(FileDetector::language_name_for_extension("qqq"), None);
}
#[test]
fn empty_result_is_inert() {
let root = Path::new("/p");
let detector = FileDetector::new()
.with_root(root)
.with_sherlock_result(SherlockResult::empty());
assert!(detector.is_user_created_file(&root.join("src/main.rs")));
assert!(!detector.is_user_created_file(&root.join("src/thing.customext")));
}
}