#[cfg(feature = "cli")]
pub mod service;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[cfg_attr(feature = "config", derive(schemars::JsonSchema))]
#[serde(default)]
pub struct AliasConfig {
#[serde(flatten)]
pub entries: HashMap<String, Vec<String>>,
}
impl AliasConfig {
pub fn builtin_names() -> &'static [&'static str] {
&["tests", "config", "build", "docs", "generated"]
}
pub fn get(&self, name: &str) -> Option<Vec<String>> {
self.get_with_languages(name, &[])
}
pub fn get_with_languages(&self, name: &str, languages: &[&str]) -> Option<Vec<String>> {
if let Some(values) = self.entries.get(name) {
if values.is_empty() {
return None; }
return Some(values.clone());
}
Self::builtin(name, languages)
}
fn builtin(name: &str, languages: &[&str]) -> Option<Vec<String>> {
let patterns: Vec<&str> = match name {
"tests" => {
let mut p: Vec<String> = vec![];
for lang in languages {
p.extend(normalize_language_meta::test_file_globs_for_language(lang));
}
p.sort_unstable();
p.dedup();
return Some(p);
}
"config" => vec![
"*.toml",
"*.yaml",
"*.yml",
"*.json",
"*.ini",
"*.cfg",
".env",
".env.*",
"*.config.js",
"*.config.ts",
],
"build" => vec![
"target/**",
"dist/**",
"build/**",
"out/**",
"node_modules/**",
".next/**",
".nuxt/**",
"__pycache__/**",
"*.pyc",
],
"docs" => vec![
"*.md",
"*.rst",
"*.txt",
"docs/**",
"doc/**",
"README*",
"CHANGELOG*",
"LICENSE*",
],
"generated" => vec![
"*.gen.*",
"*.generated.*",
"*.pb.go",
"*.pb.rs",
"*_generated.go",
"*_generated.rs",
"generated/**",
],
_ => return None,
};
Some(patterns.into_iter().map(String::from).collect())
}
}
#[derive(Debug, thiserror::Error)]
pub enum FilterError {
#[error("invalid filter pattern '{pattern}': {reason}")]
InvalidPattern { pattern: String, reason: String },
#[error("{0}")]
InvalidPatternHint(String),
#[error("unknown alias @{0}")]
UnknownAlias(String),
}
impl From<FilterError> for String {
fn from(e: FilterError) -> String {
e.to_string()
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[cfg_attr(feature = "config", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum AliasStatus {
Builtin,
Custom,
Disabled,
Overridden,
}
impl std::fmt::Display for AliasStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AliasStatus::Builtin => write!(f, "builtin"),
AliasStatus::Custom => write!(f, "custom"),
AliasStatus::Disabled => write!(f, "disabled"),
AliasStatus::Overridden => write!(f, "overridden"),
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedAlias {
pub name: String,
pub patterns: Vec<String>,
pub status: AliasStatus,
}
#[derive(Debug)]
pub enum AliasResolution {
Patterns(Vec<String>),
UnknownAlias(String),
DisabledAlias(String),
}
#[derive(Debug)]
pub struct Filter {
exclude_matcher: Option<Gitignore>,
only_matcher: Option<Gitignore>,
warnings: Vec<String>,
}
impl Filter {
pub fn new(
exclude: &[String],
only: &[String],
config: &AliasConfig,
languages: &[&str],
) -> Result<Self, FilterError> {
let mut warnings = Vec::new();
let exclude_matcher = if exclude.is_empty() {
None
} else {
let patterns = resolve_patterns(exclude, config, languages, &mut warnings)?;
if patterns.is_empty() {
None
} else {
Some(build_matcher(&patterns)?)
}
};
let only_matcher = if only.is_empty() {
None
} else {
let patterns = resolve_patterns(only, config, languages, &mut warnings)?;
if patterns.is_empty() {
None
} else {
Some(build_matcher(&patterns)?)
}
};
Ok(Self {
exclude_matcher,
only_matcher,
warnings,
})
}
pub fn warnings(&self) -> &[String] {
&self.warnings
}
pub fn matches(&self, path: &Path) -> bool {
if let Some(ref only) = self.only_matcher
&& !only.matched(path, false).is_ignore()
{
return false;
}
if let Some(ref exclude) = self.exclude_matcher
&& exclude.matched(path, false).is_ignore()
{
return false;
}
true
}
#[allow(dead_code)]
pub fn is_active(&self) -> bool {
self.exclude_matcher.is_some() || self.only_matcher.is_some()
}
}
fn resolve_patterns(
patterns: &[String],
config: &AliasConfig,
languages: &[&str],
warnings: &mut Vec<String>,
) -> Result<Vec<String>, FilterError> {
let mut result = Vec::new();
for pattern in patterns {
if let Some(alias_name) = pattern.strip_prefix('@') {
match resolve_alias(alias_name, config, languages) {
AliasResolution::Patterns(ps) => {
result.extend(ps);
}
AliasResolution::UnknownAlias(name) => {
return Err(FilterError::UnknownAlias(name));
}
AliasResolution::DisabledAlias(name) => {
warnings.push(format!("@{} is disabled (matches nothing)", name));
}
}
} else if looks_like_language_name(pattern) {
let matched_lang = languages
.iter()
.find(|l| l.eq_ignore_ascii_case(pattern))
.copied();
if let Some(lang) = matched_lang {
return Err(FilterError::InvalidPatternHint(format!(
"'{pattern}' is not a valid pattern — use a glob like '*.ext' or an alias like '@tests' (run 'normalize aliases' to list available aliases; detected language: {lang})"
)));
} else {
return Err(FilterError::InvalidPatternHint(format!(
"'{pattern}' is not a valid pattern — use a glob like '*.rs' or an alias like '@tests' (run 'normalize aliases' to list available aliases)"
)));
}
} else {
result.push(pattern.clone());
}
}
Ok(result)
}
fn looks_like_language_name(pattern: &str) -> bool {
!pattern.is_empty()
&& !pattern.contains(['*', '?', '{', '[', '/', '.'])
&& pattern
.chars()
.all(|c| c.is_alphabetic() || c == '-' || c == '_')
}
fn resolve_alias(name: &str, config: &AliasConfig, languages: &[&str]) -> AliasResolution {
if let Some(patterns) = config.entries.get(name)
&& patterns.is_empty()
{
return AliasResolution::DisabledAlias(name.to_string());
}
match config.get_with_languages(name, languages) {
Some(patterns) => AliasResolution::Patterns(patterns),
None => AliasResolution::UnknownAlias(name.to_string()),
}
}
fn build_matcher(patterns: &[String]) -> Result<Gitignore, FilterError> {
let mut builder = GitignoreBuilder::new("");
for pattern in patterns {
builder
.add_line(None, pattern)
.map_err(|e| FilterError::InvalidPattern {
pattern: pattern.clone(),
reason: e.to_string(),
})?;
}
builder.build().map_err(|e| FilterError::InvalidPattern {
pattern: String::new(),
reason: e.to_string(),
})
}
pub fn list_aliases(config: &AliasConfig, languages: &[&str]) -> Vec<ResolvedAlias> {
let mut aliases = Vec::new();
let builtin_names = AliasConfig::builtin_names();
for &name in builtin_names {
if let Some(user_patterns) = config.entries.get(name) {
if user_patterns.is_empty() {
aliases.push(ResolvedAlias {
name: name.to_string(),
patterns: vec![],
status: AliasStatus::Disabled,
});
} else {
aliases.push(ResolvedAlias {
name: name.to_string(),
patterns: user_patterns.clone(),
status: AliasStatus::Overridden,
});
}
} else if let Some(patterns) = config.get_with_languages(name, languages) {
aliases.push(ResolvedAlias {
name: name.to_string(),
patterns,
status: AliasStatus::Builtin,
});
}
}
let builtin_set: std::collections::HashSet<&str> = builtin_names.iter().copied().collect();
for (name, patterns) in &config.entries {
if !builtin_set.contains(name.as_str()) {
aliases.push(ResolvedAlias {
name: name.clone(),
patterns: patterns.clone(),
status: AliasStatus::Custom,
});
}
}
aliases.sort_by(|a, b| {
let a_builtin = matches!(
a.status,
AliasStatus::Builtin | AliasStatus::Disabled | AliasStatus::Overridden
);
let b_builtin = matches!(
b.status,
AliasStatus::Builtin | AliasStatus::Disabled | AliasStatus::Overridden
);
match (a_builtin, b_builtin) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
}
});
aliases
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_glob_pattern() {
let config = AliasConfig::default();
let filter =
Filter::new(&["*.test.js".to_string()], &[], &config, &["javascript"]).unwrap();
assert!(filter.is_active());
assert!(!filter.matches(Path::new("foo.test.js")));
assert!(filter.matches(Path::new("foo.js")));
}
#[test]
fn test_resolve_alias() {
let config = AliasConfig::default();
let filter = Filter::new(&["@tests".to_string()], &[], &config, &["go"]).unwrap();
assert!(filter.is_active());
assert!(!filter.matches(Path::new("foo_test.go")));
assert!(filter.matches(Path::new("foo.go")));
}
#[test]
fn test_unknown_alias_error() {
let config = AliasConfig::default();
let result = Filter::new(&["@unknown".to_string()], &[], &config, &[]);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("unknown alias @unknown")
);
}
#[test]
fn test_disabled_alias_warning() {
let mut config = AliasConfig::default();
config.entries.insert("tests".to_string(), vec![]);
let filter = Filter::new(&["@tests".to_string()], &[], &config, &["Go"]).unwrap();
assert!(!filter.is_active()); assert_eq!(filter.warnings().len(), 1);
assert!(filter.warnings()[0].contains("disabled"));
}
#[test]
fn test_config_override() {
let mut config = AliasConfig::default();
config
.entries
.insert("tests".to_string(), vec!["my_tests/**".to_string()]);
let filter = Filter::new(&["@tests".to_string()], &[], &config, &["Go"]).unwrap();
assert!(filter.is_active());
assert!(!filter.matches(Path::new("my_tests/foo.go")));
assert!(filter.matches(Path::new("foo_test.go"))); }
#[test]
fn test_only_mode() {
let config = AliasConfig::default();
let filter = Filter::new(&[], &["*.rs".to_string()], &config, &[]).unwrap();
assert!(filter.is_active());
assert!(filter.matches(Path::new("foo.rs")));
assert!(!filter.matches(Path::new("foo.go")));
}
#[test]
fn test_bare_language_name_error() {
let config = AliasConfig::default();
let result = Filter::new(&[], &["rust".to_string()], &config, &["Rust"]);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("'rust' is not a valid pattern"), "got: {err}");
assert!(
err.contains("Rust"),
"should mention detected language, got: {err}"
);
}
#[test]
fn test_bare_language_name_no_detected_language() {
let config = AliasConfig::default();
let result = Filter::new(&[], &["python".to_string()], &config, &["Rust"]);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("'python' is not a valid pattern"),
"got: {err}"
);
}
#[test]
fn test_list_aliases() {
let mut config = AliasConfig::default();
config.entries.insert("tests".to_string(), vec![]); config
.entries
.insert("vendor".to_string(), vec!["vendor/**".to_string()]);
let aliases = list_aliases(&config, &["rust"]);
let tests = aliases.iter().find(|a| a.name == "tests").unwrap();
assert_eq!(tests.status, AliasStatus::Disabled);
let vendor = aliases.iter().find(|a| a.name == "vendor").unwrap();
assert_eq!(vendor.status, AliasStatus::Custom);
let docs = aliases.iter().find(|a| a.name == "docs").unwrap();
assert_eq!(docs.status, AliasStatus::Builtin);
}
}