1use std::collections::{BTreeMap, HashMap};
17use std::path::{Path, PathBuf};
18use std::sync::{Arc, OnceLock};
19
20use minijinja::value::{Enumerator, Object, ObjectRepr, Value};
21use minijinja::{Environment, UndefinedBehavior};
22
23use crate::fs::Fs;
24use crate::paths::Pather;
25use crate::preprocessing::{ExpandedFile, Preprocessor, TransformType};
26use crate::{DodotError, Result};
27
28const RESERVED_VARS: &[&str] = &["dodot", "env"];
30
31#[derive(Debug)]
37struct EnvLookup;
38
39impl Object for EnvLookup {
40 fn repr(self: &Arc<Self>) -> ObjectRepr {
41 ObjectRepr::Map
42 }
43
44 fn get_value(self: &Arc<Self>, key: &Value) -> Option<Value> {
45 let name = key.as_str()?;
46 std::env::var(name).ok().map(Value::from)
47 }
48
49 fn enumerate(self: &Arc<Self>) -> Enumerator {
50 Enumerator::NonEnumerable
53 }
54}
55
56pub struct TemplatePreprocessor {
58 extensions: Vec<String>,
59 env: Environment<'static>,
60}
61
62impl std::fmt::Debug for TemplatePreprocessor {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.debug_struct("TemplatePreprocessor")
65 .field("extensions", &self.extensions)
66 .finish_non_exhaustive()
67 }
68}
69
70impl TemplatePreprocessor {
71 pub fn new(
81 extensions: Vec<String>,
82 user_vars: HashMap<String, String>,
83 pather: &dyn Pather,
84 ) -> Result<Self> {
85 for name in user_vars.keys() {
86 if RESERVED_VARS.contains(&name.as_str()) {
87 return Err(DodotError::TemplateReservedVar { name: name.clone() });
88 }
89 }
90
91 let extensions: Vec<String> = extensions
92 .into_iter()
93 .map(|e| e.trim_start_matches('.').to_string())
94 .collect();
95
96 let mut env = Environment::new();
97 env.set_undefined_behavior(UndefinedBehavior::Strict);
98
99 env.add_global("dodot", Value::from(build_dodot_context(pather)));
100 env.add_global("env", Value::from_object(EnvLookup));
101
102 for (name, val) in user_vars {
103 env.add_global(name, Value::from(val));
104 }
105
106 Ok(Self { extensions, env })
107 }
108}
109
110impl Preprocessor for TemplatePreprocessor {
111 fn name(&self) -> &str {
112 "template"
113 }
114
115 fn transform_type(&self) -> TransformType {
116 TransformType::Generative
117 }
118
119 fn matches_extension(&self, filename: &str) -> bool {
120 self.extensions.iter().any(|ext| {
124 filename
125 .strip_suffix(ext.as_str())
126 .is_some_and(|prefix| prefix.ends_with('.'))
127 })
128 }
129
130 fn stripped_name(&self, filename: &str) -> String {
131 self.extensions
136 .iter()
137 .filter_map(|ext| {
138 filename
139 .strip_suffix(ext.as_str())
140 .and_then(|prefix| prefix.strip_suffix('.'))
141 .map(|stripped| (ext.len(), stripped))
142 })
143 .max_by_key(|(len, _)| *len)
144 .map(|(_, stripped)| stripped.to_string())
145 .unwrap_or_else(|| filename.to_string())
146 }
147
148 fn expand(&self, source: &Path, fs: &dyn Fs) -> Result<Vec<ExpandedFile>> {
149 let template_str = fs.read_to_string(source)?;
150
151 let rendered =
152 self.env
153 .render_str(&template_str, ())
154 .map_err(|e| DodotError::TemplateRender {
155 source_file: source.to_path_buf(),
156 message: format_minijinja_error(&e),
157 })?;
158
159 let filename = source
160 .file_name()
161 .unwrap_or_default()
162 .to_string_lossy()
163 .into_owned();
164 let stripped = self.stripped_name(&filename);
165
166 Ok(vec![ExpandedFile {
167 relative_path: PathBuf::from(stripped),
168 content: rendered.into_bytes(),
169 is_dir: false,
170 }])
171 }
172}
173
174fn build_dodot_context(pather: &dyn Pather) -> BTreeMap<String, String> {
188 let mut ctx = BTreeMap::new();
189 ctx.insert("os".into(), std::env::consts::OS.into());
190 ctx.insert("arch".into(), std::env::consts::ARCH.into());
191 if let Some(h) = cached_hostname() {
192 ctx.insert("hostname".into(), h.clone());
193 }
194 if let Some(u) = cached_username() {
195 ctx.insert("username".into(), u.clone());
196 }
197 ctx.insert("home".into(), pather.home_dir().display().to_string());
198 ctx.insert(
199 "dotfiles_root".into(),
200 pather.dotfiles_root().display().to_string(),
201 );
202 ctx
203}
204
205fn cached_hostname() -> Option<&'static String> {
208 static CACHE: OnceLock<Option<String>> = OnceLock::new();
209 CACHE.get_or_init(detect_hostname).as_ref()
210}
211
212fn cached_username() -> Option<&'static String> {
215 static CACHE: OnceLock<Option<String>> = OnceLock::new();
216 CACHE.get_or_init(detect_username).as_ref()
217}
218
219fn detect_hostname() -> Option<String> {
220 if let Ok(h) = std::env::var("HOSTNAME") {
221 if !h.is_empty() {
222 return Some(h);
223 }
224 }
225 let output = std::process::Command::new("hostname").output().ok()?;
227 if !output.status.success() {
228 return None;
229 }
230 let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
231 if name.is_empty() {
232 None
233 } else {
234 Some(name)
235 }
236}
237
238fn detect_username() -> Option<String> {
239 for var in ["USER", "USERNAME", "LOGNAME"] {
240 if let Ok(v) = std::env::var(var) {
241 if !v.is_empty() {
242 return Some(v);
243 }
244 }
245 }
246 None
247}
248
249fn format_minijinja_error(err: &minijinja::Error) -> String {
252 use minijinja::ErrorKind;
253
254 let base = match err.kind() {
255 ErrorKind::UndefinedError => {
256 let mut msg = err.to_string();
260 msg.push_str(
261 "\n hint: define the variable in [preprocessor.template.vars] in .dodot.toml,\n or reference an environment variable with {{ env.NAME }} (with a default filter if optional)",
262 );
263 msg
264 }
265 ErrorKind::SyntaxError => err.to_string(),
266 _ => err.to_string(),
267 };
268
269 base.lines().take(10).collect::<Vec<_>>().join("\n ")
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::paths::XdgPather;
278
279 fn make_pather() -> XdgPather {
280 XdgPather::builder()
281 .home("/home/alice")
282 .dotfiles_root("/home/alice/dotfiles")
283 .xdg_config_home("/home/alice/.config")
284 .data_dir("/home/alice/.local/share/dodot")
285 .build()
286 .unwrap()
287 }
288
289 fn new_pp(vars: HashMap<String, String>) -> TemplatePreprocessor {
290 TemplatePreprocessor::new(vec!["tmpl".into(), "template".into()], vars, &make_pather())
291 .unwrap()
292 }
293
294 #[test]
297 fn trait_properties() {
298 let pp = new_pp(HashMap::new());
299 assert_eq!(pp.name(), "template");
300 assert_eq!(pp.transform_type(), TransformType::Generative);
301 }
302
303 #[test]
304 fn matches_default_extensions() {
305 let pp = new_pp(HashMap::new());
306 assert!(pp.matches_extension("config.toml.tmpl"));
307 assert!(pp.matches_extension("config.toml.template"));
308 assert!(!pp.matches_extension("config.toml"));
309 assert!(!pp.matches_extension("config.tmpl.bak"));
310 }
311
312 #[test]
313 fn matches_custom_extension() {
314 let pp =
315 TemplatePreprocessor::new(vec!["j2".into()], HashMap::new(), &make_pather()).unwrap();
316 assert!(pp.matches_extension("nginx.conf.j2"));
317 assert!(!pp.matches_extension("nginx.conf.tmpl"));
318 }
319
320 #[test]
321 fn stripped_name_removes_either_extension() {
322 let pp = new_pp(HashMap::new());
323 assert_eq!(pp.stripped_name("config.toml.tmpl"), "config.toml");
324 assert_eq!(pp.stripped_name("config.toml.template"), "config.toml");
325 assert_eq!(pp.stripped_name("already-stripped"), "already-stripped");
326 }
327
328 #[test]
331 fn reserved_dodot_var_rejected() {
332 let mut vars = HashMap::new();
333 vars.insert("dodot".into(), "x".into());
334 let err = TemplatePreprocessor::new(vec!["tmpl".into()], vars, &make_pather()).unwrap_err();
335 assert!(
336 matches!(err, DodotError::TemplateReservedVar { ref name } if name == "dodot"),
337 "got: {err}"
338 );
339 }
340
341 #[test]
342 fn reserved_env_var_rejected() {
343 let mut vars = HashMap::new();
344 vars.insert("env".into(), "x".into());
345 let err = TemplatePreprocessor::new(vec!["tmpl".into()], vars, &make_pather()).unwrap_err();
346 assert!(matches!(err, DodotError::TemplateReservedVar { .. }));
347 }
348
349 #[test]
352 fn renders_user_var() {
353 let env = crate::testing::TempEnvironment::builder()
354 .pack("app")
355 .file("greeting.tmpl", "hello {{ name }}")
356 .done()
357 .build();
358
359 let mut vars = HashMap::new();
360 vars.insert("name".into(), "Alice".into());
361 let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
362
363 let source = env.dotfiles_root.join("app/greeting.tmpl");
364 let result = pp.expand(&source, env.fs.as_ref()).unwrap();
365
366 assert_eq!(result.len(), 1);
367 assert_eq!(result[0].relative_path, PathBuf::from("greeting"));
368 assert_eq!(String::from_utf8_lossy(&result[0].content), "hello Alice");
369 }
370
371 #[test]
372 fn renders_dodot_builtins() {
373 let env = crate::testing::TempEnvironment::builder()
374 .pack("app")
375 .file(
376 "info.tmpl",
377 "home={{ dodot.home }} root={{ dodot.dotfiles_root }} os={{ dodot.os }}",
378 )
379 .done()
380 .build();
381
382 let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
383 .unwrap();
384
385 let source = env.dotfiles_root.join("app/info.tmpl");
386 let result = pp.expand(&source, env.fs.as_ref()).unwrap();
387
388 let rendered = String::from_utf8_lossy(&result[0].content);
389 let home = env.paths.home_dir().display().to_string();
390 let root = env.paths.dotfiles_root().display().to_string();
391 assert!(
392 rendered.contains(&format!("home={home}")),
393 "rendered: {rendered}"
394 );
395 assert!(
396 rendered.contains(&format!("root={root}")),
397 "rendered: {rendered}"
398 );
399 assert!(rendered.contains(&format!("os={}", std::env::consts::OS)));
400 }
401
402 #[test]
403 fn renders_env_var() {
404 let env = crate::testing::TempEnvironment::builder()
407 .pack("app")
408 .file("has_path.tmpl", "path={{ env.PATH }}")
409 .done()
410 .build();
411
412 let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
413 .unwrap();
414
415 let source = env.dotfiles_root.join("app/has_path.tmpl");
416 let result = pp.expand(&source, env.fs.as_ref()).unwrap();
417 let rendered = String::from_utf8_lossy(&result[0].content).into_owned();
418
419 assert!(rendered.starts_with("path="));
420 assert!(
421 rendered.len() > "path=".len(),
422 "env.PATH should have some value"
423 );
424 }
425
426 #[test]
427 fn missing_env_var_errors() {
428 let env = crate::testing::TempEnvironment::builder()
429 .pack("app")
430 .file("bad.tmpl", "value={{ env.DEFINITELY_UNSET_VAR_ZZZ_12345 }}")
431 .done()
432 .build();
433
434 let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
435 .unwrap();
436
437 let source = env.dotfiles_root.join("app/bad.tmpl");
438 std::env::remove_var("DEFINITELY_UNSET_VAR_ZZZ_12345");
440 let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
441 assert!(
442 matches!(err, DodotError::TemplateRender { ref source_file, .. } if source_file == &source),
443 "got: {err}"
444 );
445 }
446
447 #[test]
448 fn undefined_user_var_errors() {
449 let env = crate::testing::TempEnvironment::builder()
450 .pack("app")
451 .file("bad.tmpl", "value={{ not_defined }}")
452 .done()
453 .build();
454
455 let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
456 .unwrap();
457
458 let source = env.dotfiles_root.join("app/bad.tmpl");
459 let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
460 assert!(
461 matches!(err, DodotError::TemplateRender { ref message, .. } if message.contains("not_defined") || message.contains("undefined")),
462 "got: {err}"
463 );
464 }
465
466 #[test]
467 fn syntax_error_reports_source_file() {
468 let env = crate::testing::TempEnvironment::builder()
469 .pack("app")
470 .file("broken.tmpl", "{% if %}unterminated")
471 .done()
472 .build();
473
474 let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
475 .unwrap();
476
477 let source = env.dotfiles_root.join("app/broken.tmpl");
478 let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
479 assert!(
480 matches!(err, DodotError::TemplateRender { ref source_file, .. } if source_file == &source),
481 "got: {err}"
482 );
483 }
484
485 #[test]
486 fn renders_filters_and_conditionals() {
487 let env = crate::testing::TempEnvironment::builder()
488 .pack("app")
489 .file(
490 "multi.tmpl",
491 "NAME={{ name | upper }}\n{% if show %}shown{% else %}hidden{% endif %}",
492 )
493 .done()
494 .build();
495
496 let mut vars = HashMap::new();
497 vars.insert("name".into(), "alice".into());
498 vars.insert("show".into(), "true".into());
499 let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
500
501 let source = env.dotfiles_root.join("app/multi.tmpl");
502 let result = pp.expand(&source, env.fs.as_ref()).unwrap();
503 let rendered = String::from_utf8_lossy(&result[0].content);
504 assert!(rendered.contains("NAME=ALICE"), "rendered: {rendered}");
505 assert!(rendered.contains("shown"), "rendered: {rendered}");
506 }
507
508 #[test]
509 fn renders_empty_template() {
510 let env = crate::testing::TempEnvironment::builder()
511 .pack("app")
512 .file("empty.tmpl", "")
513 .done()
514 .build();
515
516 let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
517 .unwrap();
518
519 let source = env.dotfiles_root.join("app/empty.tmpl");
520 let result = pp.expand(&source, env.fs.as_ref()).unwrap();
521 assert_eq!(result.len(), 1);
522 assert!(result[0].content.is_empty());
523 }
524
525 #[test]
526 fn renders_template_without_substitutions() {
527 let env = crate::testing::TempEnvironment::builder()
528 .pack("app")
529 .file("plain.tmpl", "just plain text\nno vars here")
530 .done()
531 .build();
532
533 let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
534 .unwrap();
535
536 let source = env.dotfiles_root.join("app/plain.tmpl");
537 let result = pp.expand(&source, env.fs.as_ref()).unwrap();
538 assert_eq!(
539 String::from_utf8_lossy(&result[0].content),
540 "just plain text\nno vars here"
541 );
542 }
543
544 #[test]
545 fn extension_with_leading_dot_still_matches() {
546 let pp = TemplatePreprocessor::new(
550 vec![".tmpl".into(), ".template".into()],
551 HashMap::new(),
552 &make_pather(),
553 )
554 .unwrap();
555 assert!(pp.matches_extension("config.toml.tmpl"));
556 assert!(pp.matches_extension("app.template"));
557 assert_eq!(pp.stripped_name("config.toml.tmpl"), "config.toml");
558 }
559
560 #[test]
561 fn overlapping_suffix_does_not_false_match() {
562 let pp =
568 TemplatePreprocessor::new(vec!["mpl".into()], HashMap::new(), &make_pather()).unwrap();
569 assert!(!pp.matches_extension("foo.tmpl"));
570 assert_eq!(pp.stripped_name("foo.tmpl"), "foo.tmpl");
571
572 assert!(pp.matches_extension("song.mpl"));
574 assert_eq!(pp.stripped_name("song.mpl"), "song");
575 }
576
577 #[test]
578 fn overlapping_extensions_prefer_longest_match() {
579 let pp = TemplatePreprocessor::new(
584 vec!["tmpl".into(), "j2.tmpl".into()],
585 HashMap::new(),
586 &make_pather(),
587 )
588 .unwrap();
589 assert_eq!(pp.stripped_name("config.j2.tmpl"), "config");
590
591 let pp_reversed = TemplatePreprocessor::new(
593 vec!["j2.tmpl".into(), "tmpl".into()],
594 HashMap::new(),
595 &make_pather(),
596 )
597 .unwrap();
598 assert_eq!(pp_reversed.stripped_name("config.j2.tmpl"), "config");
599 }
600
601 #[test]
602 fn missing_dodot_key_raises_strict_error() {
603 let env = crate::testing::TempEnvironment::builder()
613 .pack("app")
614 .file("uses_missing.tmpl", "value={{ dodot.nonexistent_key_zzz }}")
615 .done()
616 .build();
617
618 let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
619 .unwrap();
620
621 let source = env.dotfiles_root.join("app/uses_missing.tmpl");
622 let err = pp.expand(&source, env.fs.as_ref()).unwrap_err();
623 assert!(
624 matches!(err, DodotError::TemplateRender { .. }),
625 "accessing a missing dodot.* key must error, got: {err}"
626 );
627 }
628
629 #[test]
630 fn missing_dodot_key_can_be_defaulted() {
631 let env = crate::testing::TempEnvironment::builder()
634 .pack("app")
635 .file(
636 "defaulted.tmpl",
637 "value={{ dodot.nonexistent_key_zzz | default(\"unknown\") }}",
638 )
639 .done()
640 .build();
641
642 let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
643 .unwrap();
644
645 let source = env.dotfiles_root.join("app/defaulted.tmpl");
646 let result = pp.expand(&source, env.fs.as_ref()).unwrap();
647 assert_eq!(String::from_utf8_lossy(&result[0].content), "value=unknown");
648 }
649
650 #[test]
651 fn env_var_default_filter_bridges_missing_vars() {
652 let env = crate::testing::TempEnvironment::builder()
657 .pack("app")
658 .file(
659 "cfg.tmpl",
660 "editor={{ env.DODOT_MISSING_VAR_ZZZ | default(\"vim\") }}",
661 )
662 .done()
663 .build();
664
665 let pp = TemplatePreprocessor::new(vec!["tmpl".into()], HashMap::new(), env.paths.as_ref())
666 .unwrap();
667
668 let source = env.dotfiles_root.join("app/cfg.tmpl");
669 let result = pp.expand(&source, env.fs.as_ref()).unwrap();
670 assert_eq!(String::from_utf8_lossy(&result[0].content), "editor=vim");
671 }
672
673 #[test]
674 fn renders_for_loop_over_user_var() {
675 let env = crate::testing::TempEnvironment::builder()
681 .pack("app")
682 .file(
683 "loop.tmpl",
684 "{% for c in word %}{{ c | upper }}{% endfor %}",
685 )
686 .done()
687 .build();
688
689 let mut vars = HashMap::new();
690 vars.insert("word".into(), "hi".into());
691 let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
692
693 let source = env.dotfiles_root.join("app/loop.tmpl");
694 let result = pp.expand(&source, env.fs.as_ref()).unwrap();
695 assert_eq!(String::from_utf8_lossy(&result[0].content), "HI");
696 }
697
698 #[test]
699 fn renders_unicode_content_and_vars() {
700 let env = crate::testing::TempEnvironment::builder()
703 .pack("app")
704 .file("greet.tmpl", "こんにちは {{ name }}! 🎉")
705 .done()
706 .build();
707
708 let mut vars = HashMap::new();
709 vars.insert("name".into(), "世界".into());
710 let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
711
712 let source = env.dotfiles_root.join("app/greet.tmpl");
713 let result = pp.expand(&source, env.fs.as_ref()).unwrap();
714 assert_eq!(
715 String::from_utf8_lossy(&result[0].content),
716 "こんにちは 世界! 🎉"
717 );
718 }
719
720 #[test]
721 fn rendering_is_deterministic_across_calls() {
722 let env = crate::testing::TempEnvironment::builder()
728 .pack("app")
729 .file(
730 "cfg.tmpl",
731 "name={{ name }} os={{ dodot.os }} home={{ dodot.home }}",
732 )
733 .done()
734 .build();
735
736 let mut vars = HashMap::new();
737 vars.insert("name".into(), "Alice".into());
738 let pp = TemplatePreprocessor::new(vec!["tmpl".into()], vars, env.paths.as_ref()).unwrap();
739
740 let source = env.dotfiles_root.join("app/cfg.tmpl");
741 let first = pp.expand(&source, env.fs.as_ref()).unwrap();
742 let second = pp.expand(&source, env.fs.as_ref()).unwrap();
743 let third = pp.expand(&source, env.fs.as_ref()).unwrap();
744
745 assert_eq!(first[0].content, second[0].content);
746 assert_eq!(second[0].content, third[0].content);
747 }
748
749 #[test]
750 fn stripped_name_of_literal_extension_returns_empty() {
751 let pp = new_pp(HashMap::new());
761 assert_eq!(pp.stripped_name(".tmpl"), "");
762 assert!(pp.matches_extension(".tmpl"));
763 }
764
765 #[test]
766 fn build_dodot_context_omits_undetected_optional_keys() {
767 let ctx = build_dodot_context(&make_pather());
775
776 assert!(ctx.contains_key("os"));
778 assert!(ctx.contains_key("arch"));
779 assert!(ctx.contains_key("home"));
780 assert!(ctx.contains_key("dotfiles_root"));
781
782 assert_eq!(ctx.contains_key("username"), detect_username().is_some());
784 assert_eq!(ctx.contains_key("hostname"), detect_hostname().is_some());
785 }
786}