use std::path::Path;
use anyhow::Result;
use regex::Regex;
use super::scan::{LineIndex, Walker};
use super::{Found, alternation, compile};
use crate::config::Config;
use crate::model::{KeyUsage, Location, UsageType};
pub fn keys(config: &Config, walker: &Walker) -> Result<Found> {
let Some(pattern) = pattern(&config.filters)? else {
return Ok(Found::default());
};
let mut found = Found::default();
for dir in &config.templates {
for path in walker.files(dir, |p| is_template(p, config))? {
let content = super::scan::read(&path)?;
let relative = config.relative(&path);
found.usages.extend(keys_in(&pattern, &content, &relative));
found.files += 1;
}
}
found.usages.sort();
Ok(found)
}
fn is_template(path: &Path, config: &Config) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| config.is_template_ext(e))
}
fn pattern(filters: &[String]) -> Result<Option<Regex>> {
let Some(alts) = alternation(filters) else {
return Ok(None);
};
let regex = compile(
&format!(r#""([^"]+)"\s*\|\s*(?:{alts})\s*\("#),
"template filter",
)?;
Ok(Some(regex))
}
fn keys_in(pattern: &Regex, content: &str, path: &Path) -> Vec<KeyUsage> {
let index = LineIndex::new(content);
let mut usages = Vec::new();
for captures in pattern.captures_iter(content) {
let Some(key) = captures.get(1) else { continue };
let (line, column) = index.locate(key.start());
usages.push(KeyUsage {
key: key.as_str().to_owned(),
at: Location {
file: path.to_path_buf(),
line,
column,
},
kind: UsageType::Template,
});
}
usages
}
#[cfg(test)]
mod tests {
use super::*;
fn names(values: &[&str]) -> Vec<String> {
values.iter().map(|v| (*v).to_owned()).collect()
}
fn extract(content: &str, filters: &[&str]) -> Vec<KeyUsage> {
let pattern = pattern(&names(filters))
.expect("the pattern compiles")
.expect("there is at least one name");
keys_in(&pattern, content, Path::new("templates/home.html"))
}
fn keys_of(content: &str) -> Vec<String> {
extract(content, &["t", "tn"])
.into_iter()
.map(|u| u.key)
.collect()
}
#[test]
fn both_default_filters_are_found() {
let content = r#"
<h1>{{ "tpl-title" | t(&lang) }}</h1>
<p>{{ "mail-count" | tn(&lang, "count", count) }}</p>
"#;
assert_eq!(keys_of(content), vec!["tpl-title", "mail-count"]);
}
#[test]
fn the_longer_filter_name_is_preferred() {
let usages = extract(r#"{{ "k" | tn(&lang, "n", v) }}"#, &["t", "tn"]);
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].key, "k");
}
#[test]
fn a_filter_split_across_lines_is_found() {
let content = "{{ \"wrapped\"\n\t| t(&lang) }}";
let usages = extract(content, &["t"]);
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].key, "wrapped");
assert_eq!(usages[0].at.line, 1);
}
#[test]
fn a_key_inside_a_block_tag_is_found() {
let content = r#"{% block title %}{{ "tpl-title" | t(&lang) }}{% endblock %}"#;
assert_eq!(keys_of(content), vec!["tpl-title"]);
}
#[test]
fn a_chained_filter_still_yields_the_key() {
assert_eq!(keys_of(r#"{{ "k" | t(&lang) | upper }}"#), vec!["k"]);
}
#[test]
fn a_plain_string_without_the_filter_is_not_a_key() {
let content = r#"<a href="/home" class="button">{{ title }}</a>"#;
assert!(keys_of(content).is_empty());
}
#[test]
fn custom_filter_names_replace_the_defaults() {
let content = r#"{{ "mine" | x(&lang) }} {{ "theirs" | t(&lang) }}"#;
let keys: Vec<String> = extract(content, &["x"])
.into_iter()
.map(|u| u.key)
.collect();
assert_eq!(keys, vec!["mine"]);
}
#[test]
fn the_location_points_at_the_key_itself() {
let content = "<html>\n<h1>{{ \"the-key\" | t(&lang) }}</h1>\n";
let usages = extract(content, &["t"]);
assert_eq!(usages[0].at.line, 2);
assert_eq!(usages[0].at.column, 9);
assert_eq!(usages[0].kind, UsageType::Template);
}
#[test]
fn whitespace_around_the_pipe_is_tolerated() {
assert_eq!(keys_of(r#"{{"k"|t(&lang)}}"#), vec!["k"]);
assert_eq!(keys_of("{{ \"k\" | t (&lang) }}"), vec!["k"]);
}
#[test]
fn no_configured_filters_means_no_pattern() {
assert!(
pattern(&[])
.expect("an empty list is not an error")
.is_none()
);
}
}