use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroUsize;
use std::path::Path;
use indoc::formatdoc;
use serde_json::{Value, json};
use sprawl_guard_lib::classification::{ClassifiedSource, discover_sources};
use sprawl_guard_lib::config::{
Config, LanguageConfig, RustCfgTestExclusion, built_in_config, supported_language_ids,
};
use sprawl_guard_lib::{GlobPattern, LanguageId};
use crate::error::{CliError, Result};
const SOURCE_COUNT_INCREMENT: usize = 1;
pub(super) fn supported_languages_report() -> LanguagesReport {
let config = built_in_config();
LanguagesReport {
mode: LanguageReportMode::Supported,
languages: language_entries(&config, LanguageSelection::All, BTreeMap::new()),
}
}
pub(super) fn detected_languages_report(root: &Path) -> Result<LanguagesReport> {
let config = config_for_init_style_detection()?;
let sources = discover_sources(root, &config)?;
let detected_counts = source_counts_by_language(&sources);
Ok(LanguagesReport {
mode: LanguageReportMode::Detected,
languages: language_entries(&config, LanguageSelection::DetectedOnly, detected_counts),
})
}
fn config_for_init_style_detection() -> Result<Config> {
let mut config = built_in_config();
config.enable_only_languages(supported_language_ids())?;
Ok(config)
}
fn source_counts_by_language(sources: &[ClassifiedSource]) -> BTreeMap<LanguageId, NonZeroUsize> {
let mut counts = BTreeMap::new();
for source in sources {
counts
.entry(source.language)
.and_modify(|count: &mut NonZeroUsize| {
*count = NonZeroUsize::new(
count
.get()
.checked_add(SOURCE_COUNT_INCREMENT)
.expect("source count must not overflow usize"),
)
.expect("incremented source count must remain non-zero");
})
.or_insert(NonZeroUsize::MIN);
}
counts
}
fn language_entries(
config: &Config,
selection: LanguageSelection,
detected_counts: BTreeMap<LanguageId, NonZeroUsize>,
) -> Vec<LanguageEntry> {
let detected_languages = detected_counts.keys().copied().collect::<BTreeSet<_>>();
config
.languages()
.iter()
.filter_map(|(id, config)| {
if selection.includes(*id, &detected_languages) {
Some(LanguageEntry::new(
*id,
config,
detected_counts.get(id).copied(),
))
} else {
None
}
})
.collect()
}
pub(super) fn human_report(report: &LanguagesReport) -> String {
let mut output = format!("{} languages:\n", report.mode.heading());
for language in &report.languages {
let count = language
.source_files
.map(|count| format!(" source files: {count}\n"))
.unwrap_or_default();
output.push_str(&formatdoc! {"
{key}
canonical: {canonical}
accepted aliases: {aliases}
enabled: {enabled}
{count} include: {include}
exclude: {exclude}
test globs: {test_globs}{rust}
",
key = language.config_key,
canonical = language.id,
aliases = language.human_aliases(),
enabled = language.enabled,
include = display_list(&language.include),
exclude = display_list(&language.exclude),
test_globs = display_list(&language.test_globs),
rust = language
.rust
.as_ref()
.map(RustLanguageEntry::human_suffix)
.unwrap_or_default(),
});
}
output
}
pub(super) fn json_report(report: &LanguagesReport) -> Result<String> {
serde_json::to_string_pretty(&json_report_value(report))
.map_err(|source| CliError::RenderJson { source })
}
pub(super) fn json_report_value(report: &LanguagesReport) -> Value {
let languages = report
.languages
.iter()
.map(LanguageEntry::json)
.collect::<Vec<_>>();
json!({
"mode": report.mode.as_json_str(),
"languages": languages,
})
}
fn display_list(values: &[String]) -> String {
if values.is_empty() {
return "(none)".to_owned();
}
values.join(", ")
}
fn config_key(language: LanguageId) -> String {
language.to_string().to_ascii_lowercase()
}
fn glob_strings(globs: &[GlobPattern]) -> Vec<String> {
globs.iter().map(ToString::to_string).collect()
}
pub(super) struct LanguagesReport {
mode: LanguageReportMode,
languages: Vec<LanguageEntry>,
}
enum LanguageSelection {
All,
DetectedOnly,
}
impl LanguageSelection {
fn includes(&self, id: LanguageId, detected_languages: &BTreeSet<LanguageId>) -> bool {
match self {
Self::All => true,
Self::DetectedOnly => detected_languages.contains(&id),
}
}
}
enum LanguageReportMode {
Supported,
Detected,
}
impl LanguageReportMode {
fn heading(&self) -> &'static str {
match self {
Self::Supported => "Supported",
Self::Detected => "Detected",
}
}
fn as_json_str(&self) -> &'static str {
match self {
Self::Supported => "supported",
Self::Detected => "detected",
}
}
}
struct LanguageEntry {
id: LanguageId,
config_key: String,
accepted_aliases: Vec<String>,
case_insensitive: bool,
enabled: bool,
source_files: Option<NonZeroUsize>,
include: Vec<String>,
exclude: Vec<String>,
test_globs: Vec<String>,
rust: Option<RustLanguageEntry>,
}
impl LanguageEntry {
fn new(id: LanguageId, config: &LanguageConfig, source_files: Option<NonZeroUsize>) -> Self {
let common = config.common();
let config_key = config_key(id);
Self {
id,
config_key: config_key.clone(),
accepted_aliases: vec![config_key],
case_insensitive: true,
enabled: config.is_enabled(),
source_files,
include: glob_strings(&common.include),
exclude: glob_strings(&common.exclude),
test_globs: glob_strings(&common.test_globs),
rust: config.rust().map(|rust| RustLanguageEntry {
exclude_cfg_test: rust.cfg_test_exclusion,
}),
}
}
fn json(&self) -> Value {
let mut value = json!({
"id": self.id,
"config_key": &self.config_key,
"accepted_aliases": &self.accepted_aliases,
"case_insensitive": self.case_insensitive,
"enabled": self.enabled,
"include": &self.include,
"exclude": &self.exclude,
"test_globs": &self.test_globs,
});
if let Some(source_files) = self.source_files {
value["source_files"] = json!(source_files.get());
}
if let Some(rust) = &self.rust {
value["rust"] = rust.json();
}
value
}
fn human_aliases(&self) -> String {
let aliases = display_list(&self.accepted_aliases);
if self.case_insensitive {
format!("{aliases} (case-insensitive)")
} else {
aliases
}
}
}
struct RustLanguageEntry {
exclude_cfg_test: RustCfgTestExclusion,
}
impl RustLanguageEntry {
fn human_suffix(&self) -> String {
format!(
"\n rust: exclude_cfg_test = {}",
self.exclude_cfg_test.is_enabled()
)
}
fn json(&self) -> Value {
json!({
"exclude_cfg_test": self.exclude_cfg_test.is_enabled(),
})
}
}