use anyhow::{Result, bail};
use std::path::{Path, PathBuf};
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"
);
}
}
}
}
pub const DENIED_DIRS: &[&str] = &[
".ssh",
".aws",
".gnupg",
".kube",
".config/gcloud",
".azure",
".docker",
"Library/Keychains",
".password-store",
".local/share/keyrings",
];
pub const DENIED_NAMES: &[&str] = &[
".env",
".env.*",
"*.pem",
"*.key",
"*.p12",
"*.pfx",
"*.jks",
"id_rsa*",
"id_ed25519*",
"*credentials*",
"*secret*",
"*.tfstate",
"*.kdbx",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Denied {
Directory(&'static str),
Name(&'static str),
Hidden,
}
impl Denied {
pub fn reason(&self) -> String {
match self {
Denied::Directory(dir) => {
format!("under ~/{dir}, which holds credentials — semlith does not index it")
}
Denied::Name(pattern) => {
format!("matches {pattern}, which names a credential — semlith does not index it")
}
Denied::Hidden => "is a hidden file, which semlith skips when walking and does not \
index when named"
.to_string(),
}
}
}
pub fn denied(path: &Path) -> Option<Denied> {
let home = std::env::var_os("HOME").map(PathBuf::from);
if let Some(home) = home {
let home = crate::canonical(&home);
let real = crate::canonical(path);
for dir in DENIED_DIRS {
if real.starts_with(home.join(dir)) {
return Some(Denied::Directory(dir));
}
}
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
for pattern in DENIED_NAMES {
if glob_match(pattern, &name) {
return Some(Denied::Name(pattern));
}
}
if name.starts_with('.') && name.len() > 1 {
return Some(Denied::Hidden);
}
None
}
fn glob_match(pattern: &str, name: &str) -> bool {
let pattern = pattern.to_ascii_lowercase();
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == name;
}
let mut rest = name;
if let Some(first) = parts.first()
&& !first.is_empty()
{
match rest.strip_prefix(first) {
Some(tail) => rest = tail,
None => return false,
}
}
if let Some(last) = parts.last()
&& !last.is_empty()
{
match rest.strip_suffix(last) {
Some(head) => rest = head,
None => return false,
}
}
for middle in &parts[1..parts.len().saturating_sub(1)] {
if middle.is_empty() {
continue;
}
match rest.find(middle) {
Some(at) => rest = &rest[at + middle.len()..],
None => return false,
}
}
true
}
pub fn within_boundary(path: &Path, roots: &[PathBuf]) -> bool {
let real = crate::canonical(path);
if roots
.iter()
.any(|root| real.starts_with(crate::canonical(root)))
{
return true;
}
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|home| real.starts_with(crate::canonical(&home)))
.unwrap_or(false)
}
#[cfg(test)]
mod deny_tests {
use super::*;
#[test]
fn a_star_matches_a_run_of_anything() {
assert!(glob_match(".env", ".env"));
assert!(glob_match(".env.*", ".env.production"));
assert!(!glob_match(".env.*", ".environment"));
assert!(glob_match("*.pem", "server.pem"));
assert!(!glob_match("*.pem", "pem"));
assert!(glob_match("id_rsa*", "id_rsa"));
assert!(glob_match("id_rsa*", "id_rsa.pub"));
assert!(glob_match(
"*credentials*",
"service-account-credentials.json"
));
assert!(glob_match("*secret*", "my-secrets.yaml"));
assert!(!glob_match("*secret*", "secrat"));
assert!(glob_match("*.tfstate", "terraform.tfstate"));
}
#[test]
fn a_credential_is_denied_wherever_it_is_named() {
assert!(matches!(
denied(Path::new("/work/api/.env")),
Some(Denied::Name(".env"))
));
assert!(matches!(
denied(Path::new("/work/api/service-account-credentials.json")),
Some(Denied::Name("*credentials*"))
));
assert!(matches!(
denied(Path::new("/work/api/tls/server.pem")),
Some(Denied::Name("*.pem"))
));
assert!(matches!(
denied(Path::new("/work/api/.hidden-notes")),
Some(Denied::Hidden)
));
assert_eq!(denied(Path::new("/work/api/src/lib.rs")), None);
assert_eq!(denied(Path::new("/work/api/README.md")), None);
assert_eq!(denied(Path::new("/work/api/.")), None);
}
#[test]
fn a_refusal_names_the_rule_rather_than_the_path() {
assert!(Denied::Name("*.pem").reason().contains("*.pem"));
assert!(Denied::Directory(".ssh").reason().contains(".ssh"));
assert!(Denied::Hidden.reason().contains("hidden"));
}
#[test]
fn the_boundary_is_the_roots_and_the_home() {
let roots = vec![PathBuf::from("/work/api")];
assert!(within_boundary(Path::new("/work/api/src/lib.rs"), &roots));
assert!(within_boundary(Path::new("/work/api"), &roots));
assert!(!within_boundary(Path::new("/etc/hosts"), &roots));
assert!(!within_boundary(Path::new("/work/other"), &roots));
}
}