use crate::{
component_file::ComponentFile, component_not_found, transformers::Transformers, Context, Result,
};
use std::{
collections::HashMap,
ffi::OsStr,
fs,
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
use walkdir::{DirEntry, WalkDir};
fn path_to_template_name(path: &Path) -> String {
path.file_stem()
.expect("path must be a file")
.to_os_string()
.into_string()
.expect("invalid unicode data")
}
fn is_hidden(entry: &DirEntry) -> bool {
entry
.file_name()
.to_str()
.is_some_and(|s| s.starts_with('.'))
}
fn is_template(entry: &DirEntry, extensions: &[String]) -> bool {
entry.file_type().is_file()
&& extensions.contains(
&entry
.path()
.extension()
.unwrap_or(OsStr::new(""))
.to_str()
.expect("invalid unicode data")
.to_string(),
)
}
fn base_walk_dir(path: &Path) -> impl Iterator<Item = DirEntry> {
WalkDir::new(path)
.follow_links(true)
.into_iter()
.filter_entry(|e| !is_hidden(e))
.filter_map(std::result::Result::ok)
}
fn find_all_templates(path: &Path, extensions: &[String]) -> Vec<String> {
base_walk_dir(path)
.filter_map(|e| {
if is_template(&e, extensions) {
Some(path_to_template_name(e.path()))
} else {
None
}
})
.collect()
}
fn search_template(name: &str, path: &Path, extensions: &[String]) -> Option<PathBuf> {
for e in base_walk_dir(path) {
if is_template(&e, extensions) && path_to_template_name(e.path()) == name {
return Some(e.path().into());
}
}
None
}
#[derive(Debug, Clone)]
pub struct FilesystemProvider {
paths: Vec<PathBuf>,
extensions: Vec<String>,
cache: Arc<RwLock<HashMap<String, ComponentFile<'static, 'static>>>>,
cache_enabled: Option<bool>,
}
impl FilesystemProvider {
pub fn new() -> Self {
Self {
paths: vec![],
extensions: vec!["html".to_string()],
cache: Arc::new(RwLock::new(HashMap::new())),
cache_enabled: None,
}
}
fn is_cache_enabled(&self) -> bool {
#[cfg(debug_assertions)]
let is_release = false;
#[cfg(not(debug_assertions))]
let is_release = true;
self.cache_enabled.unwrap_or(is_release)
}
#[must_use]
pub fn enable_cache(mut self) -> Self {
self.cache_enabled = Some(true);
self
}
#[must_use]
pub fn disable_cache(mut self) -> Self {
self.cache_enabled = Some(false);
self
}
#[must_use]
pub fn with_path<P: AsRef<Path>>(mut self, path: P) -> Self {
self.paths.push(path.as_ref().into());
self
}
pub fn insert_path<P: AsRef<Path>>(&mut self, path: P) {
self.paths.push(path.as_ref().into());
}
pub fn remove_path<P: AsRef<Path>>(&mut self, path: P) {
if let Some(i) = self.paths.iter().enumerate().find(|p| p.1 == path.as_ref()) {
self.paths.remove(i.0);
}
}
#[must_use]
pub fn with_extension<T: Into<String>>(mut self, ext: T) -> Self {
self.extensions.push(ext.into());
self
}
pub fn insert_extension<T: Into<String>>(&mut self, ext: T) {
self.extensions.push(ext.into());
}
pub fn remove_extension<T: Into<String>>(&mut self, ext: T) {
let ext = ext.into();
if let Some(i) = self.extensions.iter().enumerate().find(|e| e.1 == &ext) {
self.extensions.remove(i.0);
}
}
pub fn get(&self, name: &str) -> Result<ComponentFile<'_, '_>> {
if self.is_cache_enabled() {
if let Some(cache_entry) = self
.cache
.read()
.expect("cache lock should not be poisoned")
.get(name)
{
return Ok(cache_entry.clone());
}
}
let Some(template_path) = self
.paths
.iter()
.find_map(|p| search_template(name, p, &self.extensions))
else {
return Err(component_not_found(name));
};
let Ok(template_contents) = fs::read_to_string(&template_path) else {
return Err(component_not_found(name));
};
if self.is_cache_enabled() {
self.cache
.write()
.expect("cache lock should not be poisoned")
.insert(
name.to_string(),
ComponentFile::new(template_path.clone(), template_contents.clone()),
);
}
Ok(ComponentFile::new(template_path, template_contents))
}
pub fn template_names(&self) -> impl Iterator<Item = String> + '_ {
self.paths.iter().flat_map(|p| {
if p.is_file() {
vec![path_to_template_name(p)]
} else {
find_all_templates(p, &self.extensions)
}
})
}
pub fn to_resolver_fn<'a, 'b: 'a>(
&'b self,
) -> impl FnMut(&str) -> Result<ComponentFile<'a, 'b>> + 'b {
move |name| self.get(name)
}
pub fn compile<'a: 'b, 'b>(
&self,
default_component_name: &str,
default_context: &mut Context,
) -> Result<String> {
crate::compile(
default_component_name,
default_context,
self.to_resolver_fn(),
)
}
pub fn transform_and_compile<'a: 'b, 'b>(
&self,
default_component_name: &str,
default_context: &mut Context,
transformers: &mut Transformers,
) -> Result<String> {
crate::transform_and_compile(
default_component_name,
default_context,
self.to_resolver_fn(),
transformers,
)
}
}
impl Default for FilesystemProvider {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use pochoir_lang::IntoValue;
use super::*;
#[allow(dead_code)]
trait AssertSendSync: Send + Sync {}
impl AssertSendSync for FilesystemProvider {}
const FILES: &[(&str, &str)] = &[
(
"templates/index.html",
r#"<h1>Index</h1><main><my-button color="blue" /></main>"#,
),
(
"templates/about.html",
"<h1>About</h1><main><my-button /><manually-added /></main>",
),
(
"templates/components/my-button.html",
r#"<button style="color: {{ color == null ? 'green' : color }};">My button</button>"#,
),
(
"templates/components/other-ext.choir",
r#"<h1>Component with another {{ ext + "ension" }}</h1>"#,
),
(
"templates/.components/should-be-ignored.html",
"Nothing to see here",
),
(
"templates/.components/manually-added.html",
"Manually added",
),
];
fn helper() -> tempfile::TempDir {
let dir = tempfile::Builder::new()
.prefix("pochoir-test")
.tempdir()
.unwrap();
for file in FILES {
if let Some(parent) = Path::new(file.0).parent() {
fs::create_dir_all(dir.path().join(parent)).unwrap();
}
fs::write(dir.path().join(file.0), file.1).unwrap();
}
dir
}
#[test]
fn compile_basic() {
let dir = helper();
let provider = FilesystemProvider::new()
.with_path(dir.path().join("templates"))
.with_path(dir.path().join("templates/.components/manually-added.html"));
let mut template_names = provider.template_names().collect::<Vec<String>>();
template_names.sort();
assert_eq!(
template_names,
vec!["about", "index", "manually-added", "my-button"]
);
assert_eq!(
provider.compile("index", &mut Context::new()),
Ok(
r#"<h1>Index</h1><main><button style="color: blue;">My button</button></main>"#
.to_string()
)
);
assert_eq!(
provider.compile("about", &mut Context::new()),
Ok(
r#"<h1>About</h1><main><button style="color: green;">My button</button>Manually added</main>"#
.to_string()
)
);
}
#[test]
fn compile_with_custom_extension() {
let dir = helper();
let mut provider = FilesystemProvider::new().with_path(dir.path().join("templates"));
provider.remove_extension("html");
provider.insert_extension("choir");
assert_eq!(
provider.template_names().collect::<Vec<String>>(),
vec!["other-ext"],
);
assert_eq!(
provider.compile(
"other-ext",
&mut Context::from_iter([("ext".to_string(), "ext".into_value())])
),
Ok("<h1>Component with another extension</h1>".to_string())
);
}
#[test]
fn compile_with_cache() {
let dir = helper();
let provider = FilesystemProvider::new()
.enable_cache()
.with_path(dir.path().join("templates"));
assert_eq!(
provider.compile("index", &mut Context::new()),
Ok(
r#"<h1>Index</h1><main><button style="color: blue;">My button</button></main>"#
.to_string()
)
);
drop(dir);
assert_eq!(
provider.compile("index", &mut Context::new()),
Ok(
r#"<h1>Index</h1><main><button style="color: blue;">My button</button></main>"#
.to_string()
)
);
}
}