use anyhow::{Result, bail};
pub struct Language {
pub name: &'static str,
pub extensions: &'static [&'static str],
pub filenames: &'static [&'static str],
}
pub const LANGUAGES: &[Language] = &[
lang("c", &["c", "h"], &[]),
lang("clojure", &["clj", "cljc", "cljs", "edn"], &[]),
lang("cpp", &["cc", "cpp", "cxx", "hh", "hpp", "hxx"], &[]),
lang("csharp", &["cs"], &[]),
lang("css", &["css", "sass", "scss"], &[]),
lang("dart", &["dart"], &[]),
lang(
"dockerfile",
&["dockerfile"],
&["dockerfile", "dockerfile.*"],
),
lang("elixir", &["ex", "exs"], &[]),
lang("elm", &["elm"], &[]),
lang("erlang", &["erl", "hrl"], &[]),
lang("fortran", &["f", "f03", "f90", "f95"], &[]),
lang("go", &["go"], &[]),
lang("graphql", &["gql", "graphql"], &[]),
lang("groovy", &["gradle", "groovy"], &[]),
lang("haskell", &["hs"], &[]),
lang("html", &["htm", "html"], &[]),
lang("java", &["java"], &[]),
lang("javascript", &["cjs", "js", "jsx", "mjs"], &[]),
lang("json", &["json"], &[]),
lang("julia", &["jl"], &[]),
lang("kotlin", &["kt", "kts"], &[]),
lang("lua", &["lua"], &[]),
lang("makefile", &["mk"], &["gnumakefile", "makefile"]),
lang("markdown", &["markdown", "md"], &[]),
lang("nix", &["nix"], &[]),
lang("objective-c", &["m", "mm"], &[]),
lang("ocaml", &["ml", "mli"], &[]),
lang("perl", &["pl", "pm", "t"], &[]),
lang("php", &["php"], &[]),
lang("powershell", &["ps1", "psd1", "psm1"], &[]),
lang("proto", &["proto"], &[]),
lang("python", &["py", "pyi"], &[]),
lang("r", &["r"], &[]),
lang("ruby", &["rb"], &[]),
lang("rust", &["rs"], &[]),
lang("scala", &["sc", "scala"], &[]),
lang("shell", &["bash", "sh", "zsh"], &[]),
lang("sql", &["sql"], &[]),
lang("svelte", &["svelte"], &[]),
lang("swift", &["swift"], &[]),
lang("terraform", &["tf", "tfvars"], &[]),
lang("toml", &["toml"], &[]),
lang("typescript", &["cts", "mts", "ts", "tsx"], &[]),
lang("vue", &["vue"], &[]),
lang("yaml", &["yaml", "yml"], &[]),
lang("zig", &["zig"], &[]),
];
const fn lang(
name: &'static str,
extensions: &'static [&'static str],
filenames: &'static [&'static str],
) -> Language {
Language {
name,
extensions,
filenames,
}
}
pub fn language(name: &str) -> Option<&'static Language> {
let wanted = name.to_ascii_lowercase();
LANGUAGES.iter().find(|entry| entry.name == wanted)
}
#[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 patterns: Vec<String> = exts
.iter()
.map(|e| format!("*.{}", e.trim_start_matches('.').to_ascii_lowercase()))
.collect();
for name in langs {
let Some(entry) = language(name) else {
bail!("unknown language {name:?}; run `semlith languages` for the list");
};
patterns.extend(entry.extensions.iter().map(|e| format!("*.{e}")));
patterns.extend(entry.filenames.iter().map(|f| f.to_string()));
}
if !patterns.is_empty() {
groups.push(patterns.iter().map(|p| anchor(p)).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 a_language_with_no_extension_matches_by_filename() {
let sep = std::path::MAIN_SEPARATOR;
let f = Filter::new(&[], &[], &s(&["dockerfile"])).unwrap();
assert_eq!(
f.groups()[0],
[
format!("*{sep}*.dockerfile"),
format!("*{sep}dockerfile"),
format!("*{sep}dockerfile.*"),
],
"a bare Dockerfile and a Dockerfile.prod are both the language"
);
let f = Filter::new(&[], &[], &s(&["makefile"])).unwrap();
assert_eq!(
f.groups()[0],
[
format!("*{sep}*.mk"),
format!("*{sep}gnumakefile"),
format!("*{sep}makefile"),
]
);
let f = Filter::new(&[], &s(&["md"]), &s(&["dockerfile"])).unwrap();
assert_eq!(f.groups().len(), 1);
assert_eq!(f.groups()[0].len(), 4);
}
#[test]
fn a_language_name_is_matched_however_it_is_typed() {
let lower = Filter::new(&[], &[], &s(&["dockerfile"])).unwrap();
let shouted = Filter::new(&[], &[], &s(&["DockerFile"])).unwrap();
assert_eq!(lower, shouted);
}
#[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 entry in LANGUAGES {
let name = entry.name;
assert!(name > previous, "language table is out of order at {name}");
previous = name;
assert_eq!(name, name.to_ascii_lowercase());
for set in [entry.extensions, entry.filenames] {
let mut sorted = set.to_vec();
sorted.sort_unstable();
assert_eq!(&sorted, &set, "{name}'s entries are out of order");
assert!(
set.iter().all(|e| *e == e.to_ascii_lowercase()),
"{name} has an uppercase entry"
);
}
}
}
}