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,
}
}
const PROSE: &[&str] = &["css", "html", "json", "markdown", "toml", "yaml"];
pub fn language_of_path(path: &str) -> Option<&'static Language> {
let lower = path.to_ascii_lowercase();
let name = lower.rsplit(['/', '\\']).next().unwrap_or(&lower);
let ext = name.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
LANGUAGES.iter().find(|entry| {
entry.extensions.contains(&ext)
|| entry.filenames.iter().any(|f| match f.strip_suffix(".*") {
Some(stem) => name.starts_with(stem),
None => *f == name,
})
})
}
pub fn is_code(path: &str) -> bool {
language_of_path(path).is_some_and(|entry| !PROSE.contains(&entry.name))
}
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 and_language(&self, language: &str) -> Result<Self> {
let added = Self::new(&[], &[], std::slice::from_ref(&language.to_string()))?;
let mut groups = self.groups.clone();
groups.extend(added.groups);
Ok(Self { groups })
}
pub fn groups(&self) -> &[Vec<String>] {
&self.groups
}
}
fn anchor(pattern: &str) -> String {
let pattern = pattern.to_lowercase().replace('\\', "/");
if is_absolute(&pattern) {
pattern
} else {
format!("*/{pattern}")
}
}
fn is_absolute(pattern: &str) -> bool {
if pattern.starts_with('/') {
return true;
}
cfg!(windows) && pattern.as_bytes().get(1) == Some(&b':')
}
#[cfg(test)]
mod tests {
#[test]
fn code_is_told_from_prose_by_the_language_table() {
for path in [
"src/lib.rs",
"a/b.py",
"Makefile",
"deploy/Dockerfile",
"x.ts",
] {
assert!(super::is_code(path), "{path} is code");
}
for path in [
"README.md",
"docs/architecture.md",
"Cargo.toml",
"page.html",
"data.json",
"notes.txt",
"book.epub",
] {
assert!(!super::is_code(path), "{path} is not code");
}
}
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 f = Filter::new(&s(&["src/**"]), &[], &[]).unwrap();
assert_eq!(f.groups(), [["*/src/**"]]);
let f = Filter::new(&s(&["/home/me/proj/src/*"]), &[], &[]).unwrap();
assert_eq!(f.groups(), [["/home/me/proj/src/*"]]);
let f = Filter::new(&s(&[r"src\**"]), &[], &[]).unwrap();
assert_eq!(f.groups(), [["*/src/**"]]);
}
#[test]
fn extensions_and_languages_share_one_group_and_union() {
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], ["*/*.toml", "*/*.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 f = Filter::new(&[], &[], &s(&["dockerfile"])).unwrap();
assert_eq!(
f.groups()[0],
["*/*.dockerfile", "*/dockerfile", "*/dockerfile.*"],
"a bare Dockerfile and a Dockerfile.prod are both the language"
);
let f = Filter::new(&[], &[], &s(&["makefile"])).unwrap();
assert_eq!(f.groups()[0], ["*/*.mk", "*/gnumakefile", "*/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,
NoHome,
}
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(),
Denied::NoHome => "cannot be checked against the credential directories, because \
semlith cannot tell where your home directory is — set SEMLITH_HOME, or HOME"
.to_string(),
}
}
}
pub fn denied(path: &Path) -> Option<Denied> {
denied_against(path, crate::home::user_home().ok().as_deref())
}
pub(crate) fn denied_against(path: &Path, home: Option<&Path>) -> Option<Denied> {
let Some(home) = home else {
return Some(Denied::NoHome);
};
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;
}
crate::home::user_home().is_ok_and(|home| real.starts_with(crate::canonical(&home)))
}
#[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_path_is_refused_when_the_home_directory_is_unknown() {
let home = Path::new("/home/someone");
assert_eq!(
denied_against(&home.join(".kube").join("config"), Some(home)),
Some(Denied::Directory(".kube")),
"the rule does not hold with a home"
);
assert_eq!(
denied_against(Path::new("/srv/corpus/notes.md"), None),
Some(Denied::NoHome),
"an unknown home let an ordinary path through, so it let ~/.kube through too"
);
assert_eq!(
denied_against(&home.join(".kube").join("config"), None),
Some(Denied::NoHome),
"the credential itself must not slip past either"
);
let refusal = Denied::NoHome.reason();
assert!(
refusal.contains("home directory"),
"the reason should say what semlith could not determine: {refusal}"
);
}
#[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));
}
}