use codespan_reporting::diagnostic::{Diagnostic, Label};
use globetrotter_model::{
diagnostics::{DiagnosticExt, FileId, Span},
lint::{AllowEntry, LintCode, is_allowed},
};
use std::collections::{BTreeSet, HashSet};
use std::path::{Path, PathBuf};
const SKIP_DIRS: &[&str] = &[
"node_modules",
".git",
"dist",
"build",
"out",
"target",
".next",
".turbo",
".svelte-kit",
".cache",
"coverage",
"vendor",
];
const SOURCE_EXTENSIONS: &[&str] = &[
"ts", "tsx", "js", "jsx", "mjs", "cjs", "vue", "svelte", "astro", "html", "htm", "rs", "go",
"py", "rb", "php", "java", "kt", "swift", "dart", "ex", "exs", "lua", "zig", "cs",
];
const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
pub struct DefinedKey {
pub key: String,
pub forms: Vec<String>,
pub file_id: FileId,
pub span: Span,
pub allow: BTreeSet<AllowEntry>,
}
fn is_key_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '.' | '_' | '-')
}
fn is_whole_token(content: &str, start: usize, end: usize) -> bool {
let before_ok = content
.get(..start)
.and_then(|prefix| prefix.chars().next_back())
.is_none_or(|c| !is_key_char(c));
let after_ok = content
.get(end..)
.and_then(|suffix| suffix.chars().next())
.is_none_or(|c| !is_key_char(c));
before_ok && after_ok
}
fn collect_dynamic_prefixes(content: &str, prefixes: &mut HashSet<String>) {
for (pos, _) in content.match_indices("${") {
let Some(before) = content.get(..pos) else {
continue;
};
let run: String = before
.chars()
.rev()
.take_while(|character| is_key_char(*character))
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
if run.contains('.') {
prefixes.insert(run);
}
}
}
fn is_source_file(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| SOURCE_EXTENSIONS.contains(&extension.to_lowercase().as_str()))
}
fn is_pruned_dir(
path: &Path,
name: &str,
scan_roots: &BTreeSet<PathBuf>,
excluded: &BTreeSet<PathBuf>,
) -> bool {
if SKIP_DIRS.contains(&name) {
return true;
}
let canonical = path.canonicalize().ok();
if canonical
.as_ref()
.is_some_and(|canonical| excluded.contains(canonical))
{
return true;
}
if canonical
.as_ref()
.is_some_and(|canonical| scan_roots.contains(canonical))
{
return false;
}
crate::config::config_file_names().any(|config| path.join(config).exists())
}
pub fn find_unused_keys(
keys: &[DefinedKey],
usage_dirs: &[PathBuf],
excluded: &BTreeSet<PathBuf>,
strict: bool,
) -> std::io::Result<Vec<Diagnostic<FileId>>> {
if keys.is_empty() {
return Ok(Vec::new());
}
let patterns: Vec<&str> = keys
.iter()
.flat_map(|key| key.forms.iter().map(String::as_str))
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let searcher = aho_corasick::AhoCorasick::new(&patterns).map_err(std::io::Error::other)?;
let Some((first, rest)) = usage_dirs.split_first() else {
return Ok(Vec::new());
};
let mut builder = ignore::WalkBuilder::new(first);
for dir in rest {
builder.add(dir);
}
builder.require_git(false);
let scan_roots: BTreeSet<PathBuf> = usage_dirs
.iter()
.filter_map(|path| path.canonicalize().ok())
.collect();
let excluded = excluded.clone();
builder.filter_entry(move |entry| {
if !entry
.file_type()
.is_some_and(|file_type| file_type.is_dir())
{
return true;
}
!is_pruned_dir(
entry.path(),
entry.file_name().to_string_lossy().as_ref(),
&scan_roots,
&excluded,
)
});
let mut used: HashSet<usize> = HashSet::new();
let mut dynamic_prefixes: HashSet<String> = HashSet::new();
for entry in builder.build().flatten() {
if !entry
.file_type()
.is_some_and(|file_type| file_type.is_file())
{
continue;
}
let path = entry.path();
if !is_source_file(path) {
continue;
}
if entry
.metadata()
.is_ok_and(|metadata| metadata.len() > MAX_FILE_BYTES)
{
continue;
}
let Ok(content) = std::fs::read_to_string(path) else {
continue;
};
for found in searcher.find_overlapping_iter(&content) {
if is_whole_token(&content, found.start(), found.end()) {
used.insert(found.pattern().as_usize());
}
}
collect_dynamic_prefixes(&content, &mut dynamic_prefixes);
}
let matched_forms: HashSet<&str> = used
.iter()
.filter_map(|&index| patterns.get(index).copied())
.collect();
let dynamic: Vec<&str> = dynamic_prefixes.iter().map(String::as_str).collect();
let mut diagnostics = Vec::new();
for key in keys {
let referenced = key
.forms
.iter()
.any(|form| matched_forms.contains(form.as_str()))
|| dynamic.iter().any(|prefix| key.key.starts_with(prefix));
if referenced || is_allowed(&key.allow, LintCode::UnusedKey) {
continue;
}
diagnostics.push(
Diagnostic::warning_or_error(strict)
.with_code(LintCode::UnusedKey)
.with_message(format!("translation key `{}` is never used", key.key))
.with_labels(vec![
Label::primary(key.file_id, key.span.clone())
.with_message("defined here but not referenced in the scanned source"),
]),
);
}
Ok(diagnostics)
}
#[cfg(test)]
mod tests {
use super::{DefinedKey, find_unused_keys};
use color_eyre::eyre;
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
fn temp_dir(prefix: &str) -> eyre::Result<PathBuf> {
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
let unique = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let path = std::env::temp_dir().join(format!(
"globetrotter-dead-keys-{prefix}-{}-{nanos}-{unique}",
std::process::id()
));
std::fs::create_dir_all(&path)?;
Ok(path)
}
#[test_util::test]
fn empty_key_set_returns_no_diagnostics() -> eyre::Result<()> {
let dir = temp_dir("empty")?;
let diagnostics =
find_unused_keys(&[], std::slice::from_ref(&dir), &BTreeSet::new(), false)?;
assert!(diagnostics.is_empty(), "{diagnostics:?}");
std::fs::remove_dir_all(dir)?;
Ok(())
}
#[test_util::test]
fn explicit_root_with_config_file_is_still_scanned() -> eyre::Result<()> {
let dir = temp_dir("root-config")?;
std::fs::write(
dir.join(".globetrotter.yaml"),
indoc::indoc! {"
version: 1
configs: []
"},
)?;
std::fs::write(
dir.join("app.ts"),
indoc::indoc! {"
export const title = t('upload.title');
"},
)?;
let key = DefinedKey {
key: "upload.title".to_string(),
forms: vec!["upload.title".to_string()],
file_id: 0,
span: 0..0,
allow: BTreeSet::new(),
};
let diagnostics =
find_unused_keys(&[key], std::slice::from_ref(&dir), &BTreeSet::new(), false)?;
assert!(diagnostics.is_empty(), "{diagnostics:?}");
std::fs::remove_dir_all(dir)?;
Ok(())
}
}