use anyhow::{Result, bail};
pub const LANGUAGES: &[(&str, &[&str])] = &[
("c", &["c", "h"]),
("cpp", &["cc", "cpp", "cxx", "hh", "hpp", "hxx"]),
("csharp", &["cs"]),
("css", &["css", "sass", "scss"]),
("go", &["go"]),
("haskell", &["hs"]),
("html", &["htm", "html"]),
("java", &["java"]),
("javascript", &["cjs", "js", "jsx", "mjs"]),
("json", &["json"]),
("kotlin", &["kt", "kts"]),
("lua", &["lua"]),
("markdown", &["markdown", "md"]),
("ocaml", &["ml", "mli"]),
("php", &["php"]),
("python", &["py", "pyi"]),
("ruby", &["rb"]),
("rust", &["rs"]),
("scala", &["sc", "scala"]),
("shell", &["bash", "sh", "zsh"]),
("sql", &["sql"]),
("swift", &["swift"]),
("toml", &["toml"]),
("typescript", &["cts", "mts", "ts", "tsx"]),
("yaml", &["yaml", "yml"]),
];
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Filter {
groups: Vec<Vec<String>>,
}
impl Filter {
pub fn new(paths: &[String], exts: &[String], langs: &[String]) -> Result<Self> {
let mut groups = Vec::new();
if !paths.is_empty() {
groups.push(paths.iter().map(|p| anchor(p)).collect());
}
let mut extensions: Vec<String> = exts
.iter()
.map(|e| e.trim_start_matches('.').to_ascii_lowercase())
.collect();
for lang in langs {
let want = lang.to_ascii_lowercase();
let Some((_, exts)) = LANGUAGES.iter().find(|(name, _)| *name == want) else {
bail!("unknown language {lang:?}; run `semlith languages` for the list");
};
extensions.extend(exts.iter().map(|e| e.to_string()));
}
if !extensions.is_empty() {
groups.push(
extensions
.iter()
.map(|e| anchor(&format!("*.{e}")))
.collect(),
);
}
Ok(Self { groups })
}
pub fn is_empty(&self) -> bool {
self.groups.is_empty()
}
pub fn groups(&self) -> &[Vec<String>] {
&self.groups
}
}
fn anchor(pattern: &str) -> String {
let pattern = pattern.to_lowercase();
#[cfg(windows)]
let pattern = pattern.replace('/', "\\");
if is_absolute(&pattern) {
pattern
} else {
format!("{}{pattern}", separator_prefix())
}
}
fn is_absolute(pattern: &str) -> bool {
if pattern.starts_with(std::path::MAIN_SEPARATOR) {
return true;
}
cfg!(windows) && pattern.as_bytes().get(1) == Some(&b':')
}
fn separator_prefix() -> String {
format!("*{}", std::path::MAIN_SEPARATOR)
}
#[cfg(test)]
mod tests {
use super::*;
fn s(items: &[&str]) -> Vec<String> {
items.iter().map(|i| i.to_string()).collect()
}
#[test]
fn a_relative_pattern_is_anchored_and_an_absolute_one_is_not() {
let sep = std::path::MAIN_SEPARATOR;
let f = Filter::new(&s(&["src/**"]), &[], &[]).unwrap();
assert_eq!(f.groups(), [[format!("*{sep}src{sep}**")]]);
let absolute = format!("{sep}home{sep}me{sep}proj{sep}src{sep}*");
let f = Filter::new(&s(&[&absolute]), &[], &[]).unwrap();
assert_eq!(f.groups(), [[absolute]]);
}
#[test]
fn extensions_and_languages_share_one_group_and_union() {
let sep = std::path::MAIN_SEPARATOR;
let f = Filter::new(&[], &s(&["toml"]), &s(&["rust"])).unwrap();
assert_eq!(f.groups().len(), 1, "one group means they union");
assert_eq!(
f.groups()[0],
[format!("*{sep}*.toml"), format!("*{sep}*.rs")]
);
}
#[test]
fn paths_and_extensions_are_separate_groups_and_intersect() {
let f = Filter::new(&s(&["src/**"]), &s(&["md"]), &[]).unwrap();
assert_eq!(f.groups().len(), 2);
}
#[test]
fn a_leading_dot_on_an_extension_is_accepted() {
let plain = Filter::new(&[], &s(&["rs"]), &[]).unwrap();
let dotted = Filter::new(&[], &s(&[".rs"]), &[]).unwrap();
assert_eq!(plain, dotted);
}
#[test]
fn an_unknown_language_names_the_command_that_lists_them() {
let err = Filter::new(&[], &[], &s(&["klingon"]))
.unwrap_err()
.to_string();
assert!(err.contains("semlith languages"), "unhelpful error: {err}");
}
#[test]
fn no_flags_is_no_filter() {
assert!(Filter::new(&[], &[], &[]).unwrap().is_empty());
}
#[test]
fn the_language_table_is_normalised_and_sorted() {
let mut previous = "";
for (name, exts) in LANGUAGES {
assert!(name > &previous, "language table is out of order at {name}");
previous = name;
assert_eq!(*name, name.to_ascii_lowercase());
let mut sorted = exts.to_vec();
sorted.sort_unstable();
assert_eq!(&sorted, exts, "extensions for {name} are out of order");
}
}
}