#![deny(unsafe_code)]
mod extensions;
mod io;
mod scanner;
mod signatures;
use std::collections::BTreeMap;
use std::path::Path;
use itertools::Itertools;
use crate::workspace::Workspace;
pub use extensions::extension_to_language;
use extensions::is_non_primary_language;
const MAX_SECONDARY_LANGUAGES: usize = 6;
const MIN_FILES_FOR_DETECTION: usize = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectStack {
pub(crate) primary_language: String,
pub(crate) secondary_languages: Vec<String>,
pub(crate) frameworks: Vec<String>,
pub(crate) has_tests: bool,
pub(crate) test_framework: Option<String>,
pub(crate) package_manager: Option<String>,
}
impl Default for ProjectStack {
fn default() -> Self {
Self {
primary_language: "Unknown".to_string(),
secondary_languages: Vec::new(),
frameworks: Vec::new(),
has_tests: false,
test_framework: None,
package_manager: None,
}
}
}
impl ProjectStack {
pub(crate) fn is_rust(&self) -> bool {
self.primary_language == "Rust" || self.secondary_languages.iter().any(|l| l == "Rust")
}
pub(crate) fn is_python(&self) -> bool {
self.primary_language == "Python" || self.secondary_languages.iter().any(|l| l == "Python")
}
pub(crate) fn is_javascript_or_typescript(&self) -> bool {
matches!(self.primary_language.as_str(), "JavaScript" | "TypeScript")
|| self
.secondary_languages
.iter()
.any(|l| l == "JavaScript" || l == "TypeScript")
}
pub(crate) fn is_go(&self) -> bool {
self.primary_language == "Go" || self.secondary_languages.iter().any(|l| l == "Go")
}
pub(crate) fn summary(&self) -> String {
let secondary = (!self.secondary_languages.is_empty())
.then_some(format!("(+{})", self.secondary_languages.join(", ")));
let frameworks =
(!self.frameworks.is_empty()).then_some(format!("[{}]", self.frameworks.join(", ")));
let tests = self.has_tests.then_some(
self.test_framework
.as_ref()
.map(|tf| format!("tests:{tf}"))
.unwrap_or_else(|| "tests:yes".to_string()),
);
std::iter::once(self.primary_language.clone())
.chain(secondary)
.chain(frameworks)
.chain(tests)
.collect::<Vec<_>>()
.join(" ")
}
}
pub fn detect_stack(root: &Path) -> std::io::Result<ProjectStack> {
use crate::workspace::WorkspaceFs;
let workspace = WorkspaceFs::new(root.to_path_buf());
detect_stack_with_workspace(&workspace, Path::new(""))
}
#[must_use]
pub fn detect_stack_summary(root: &Path) -> String {
detect_stack(root).map_or_else(|_| "Unknown".to_string(), |stack| stack.summary())
}
#[cfg(test)]
mod tests;
pub fn detect_stack_with_workspace(
workspace: &dyn Workspace,
root: &Path,
) -> std::io::Result<ProjectStack> {
let extension_counts = count_extensions_with_workspace(workspace, root)?;
let lang_pairs: Vec<(String, usize)> = extension_counts
.iter()
.filter_map(|(ext, count)| {
extension_to_language(ext).map(|lang| (lang.to_string(), *count))
})
.collect();
let language_counts: BTreeMap<String, usize> = lang_pairs
.iter()
.map(|(lang, _)| lang.clone())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.map(|lang| {
let total: usize = lang_pairs
.iter()
.filter(|(l, _)| *l == lang)
.map(|(_, c)| *c)
.sum();
(lang, total)
})
.collect();
let language_vec: Vec<_> = language_counts
.into_iter()
.filter(|(_, count)| *count >= MIN_FILES_FOR_DETECTION)
.map(|(lang, count)| (count, lang))
.sorted_by(|a, b| b.0.cmp(&a.0))
.map(|(count, lang)| (lang, count))
.collect();
let primary_language = language_vec
.iter()
.find(|(lang, _)| !is_non_primary_language(lang))
.or_else(|| language_vec.first())
.map_or_else(|| "Unknown".to_string(), |(lang, _)| (*lang).to_string());
let secondary_languages: Vec<String> = language_vec
.iter()
.filter(|(lang, _)| *lang != primary_language.as_str())
.take(MAX_SECONDARY_LANGUAGES)
.map(|(lang, _)| (*lang).to_string())
.collect();
let (frameworks, test_framework, package_manager) =
signatures::detect_signature_files_with_workspace(workspace, root);
let has_tests =
test_framework.is_some() || detect_tests_with_workspace(workspace, root, &primary_language);
Ok(ProjectStack {
primary_language,
secondary_languages,
frameworks,
has_tests,
test_framework,
package_manager,
})
}
pub fn count_extensions_with_workspace(
workspace: &dyn Workspace,
root: &Path,
) -> std::io::Result<std::collections::HashMap<String, usize>> {
io::count_extensions_with_workspace(workspace, root)
}
pub fn detect_tests_with_workspace(
workspace: &dyn Workspace,
root: &Path,
primary_lang: &str,
) -> bool {
io::detect_tests_with_workspace(workspace, root, primary_lang)
}
fn collect_signature_files_with_workspace(
workspace: &dyn Workspace,
root: &Path,
) -> signatures::SignatureFiles {
io::collect_signature_files_with_workspace(workspace, root)
}
#[cfg(test)]
mod workspace_tests {
use super::*;
use crate::workspace::MemoryWorkspace;
#[test]
fn test_detect_stack_with_workspace_rust_project() {
let workspace = MemoryWorkspace::new_test()
.with_file(
"Cargo.toml",
r#"
[package]
name = "test"
[dependencies]
axum = "0.7"
[dev-dependencies]
"#,
)
.with_file("src/main.rs", "fn main() {}")
.with_file("src/lib.rs", "pub mod foo;")
.with_file("tests/integration.rs", "#[test] fn test() {}");
let stack = detect_stack_with_workspace(&workspace, Path::new("")).unwrap();
assert_eq!(stack.primary_language, "Rust");
assert!(stack.frameworks.contains(&"Axum".to_string()));
assert!(stack.has_tests);
assert_eq!(stack.package_manager, Some("Cargo".to_string()));
}
#[test]
fn test_detect_stack_with_workspace_js_project() {
let workspace = MemoryWorkspace::new_test()
.with_file(
"package.json",
r#"
{
"dependencies": { "react": "^18.0.0" },
"devDependencies": { "jest": "^29.0.0" }
}
"#,
)
.with_file("src/index.js", "export default {}")
.with_file("src/App.jsx", "export function App() {}")
.with_file("src/utils.js", "export const foo = 1");
let stack = detect_stack_with_workspace(&workspace, Path::new("")).unwrap();
assert_eq!(stack.primary_language, "JavaScript");
assert!(stack.frameworks.contains(&"React".to_string()));
assert_eq!(stack.test_framework, Some("Jest".to_string()));
}
}