Skip to main content

cargo_dist/backend/
templates.rs

1//! Logic for resolving/rendering templates
2
3use camino::{Utf8Path, Utf8PathBuf};
4use include_dir::{include_dir, Dir};
5use minijinja::Environment;
6use newline_converter::dos2unix;
7use serde::Serialize;
8
9use crate::{errors::DistResult, SortedMap};
10
11const TEMPLATE_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/templates");
12/// Key used for looking up templates (relative path from the templates dir)
13pub type TemplateId = &'static str;
14/// Template key for installer.ps1
15pub const TEMPLATE_INSTALLER_PS1: TemplateId = "installer/installer.ps1";
16/// Template key for installer.sh
17pub const TEMPLATE_INSTALLER_SH: TemplateId = "installer/installer.sh";
18/// Template key for Homebrew formula
19pub const TEMPLATE_INSTALLER_RB: TemplateId = "installer/homebrew.rb";
20/// Template key for the npm installer dir
21pub const TEMPLATE_INSTALLER_NPM: TemplateId = "installer/npm";
22/// Template key for the npm installer dir
23pub const TEMPLATE_INSTALLER_NPM_RUN_JS: TemplateId = "installer/npm/run.js";
24/// Template key for the npm package.json
25pub const TEMPLATE_INSTALLER_NPM_PACKAGE_JSON: TemplateId = "installer/package.json";
26/// Template key for the npm-shrinkwrap.json
27pub const TEMPLATE_INSTALLER_NPM_SHRINKWRAP: TemplateId = "installer/npm-shrinkwrap.json";
28/// Template key for the github ci.yml
29pub const TEMPLATE_CI_GITHUB: TemplateId = "ci/github/release.yml";
30
31/// ID used to look up an environment in [`Templates::envs`][]
32type EnvId = &'static str;
33/// Vanilla environment for most things
34const ENV_MISC: &str = "*";
35/// Environment with tweaked syntax to deal with {{ blah }} showing up in templated yml files
36const ENV_YAML: &str = "yml";
37/// Is not a jinja template
38const ENV_NONE: &str = "none";
39
40/// Main templates struct that gets passed around in the application.
41#[derive(Debug)]
42pub struct Templates {
43    /// Minijinja environments that contains all loaded templates
44    ///
45    /// Keys are ENV_MISC, ENV_YML
46    envs: SortedMap<EnvId, Environment<'static>>,
47    /// non-templated files that should be returned verbatim
48    raw_files: SortedMap<String, String>,
49    /// Traversable/searchable structure of the templates dir
50    entries: TemplateDir,
51}
52
53/// An entry in the template dir
54#[derive(Debug)]
55pub enum TemplateEntry {
56    /// A directory
57    Dir(TemplateDir),
58    /// A file
59    File(TemplateFile),
60}
61
62/// A directory in the template dir
63#[derive(Debug)]
64pub struct TemplateDir {
65    /// name of the dir
66    _name: String,
67    /// relative path of the dir from `TEMPLATE_DIR`
68    ///
69    /// (This is also the [`TemplateId`][] for this dir)
70    pub path: Utf8PathBuf,
71    /// children
72    pub entries: SortedMap<String, TemplateEntry>,
73}
74
75/// A file in the template dir
76#[derive(Debug)]
77pub struct TemplateFile {
78    /// name of the file
79    pub name: String,
80    /// relative path of the file from `TEMPLATE_DIR`
81    ///
82    /// (This is also the [`TemplateId`][] for this file)
83    pub path: Utf8PathBuf,
84    /// which Environment will render this
85    env: EnvId,
86}
87
88impl TemplateFile {
89    /// Gets the relative path to this file from the ancestor directory
90    pub fn path_from_ancestor(&self, ancestor: &TemplateDir) -> &Utf8Path {
91        self.path
92            .strip_prefix(&ancestor.path)
93            .expect("jinja2 template path wasn't properly nested under parent")
94    }
95}
96
97impl Templates {
98    /// Load + Parse templates from the binary
99    pub fn new() -> DistResult<Self> {
100        // Initialize the envs
101        let mut envs = SortedMap::new();
102        let mut raw_files = SortedMap::new();
103        {
104            let misc_env = Environment::new();
105            envs.insert(ENV_MISC, misc_env);
106        }
107        {
108            // Github CI ymls already use {{ }} as delimiters so add an extra layer
109            // of braces to disambiguate without needing tons of escaping
110            let mut yaml_env = Environment::new();
111            yaml_env.set_syntax(
112                minijinja::syntax::SyntaxConfig::builder()
113                    .block_delimiters("{{%", "%}}")
114                    .variable_delimiters("{{{", "}}}")
115                    .comment_delimiters("{{#", "#}}")
116                    .build()
117                    .expect("failed to change jinja2 syntax for yaml files"),
118            );
119            yaml_env.set_formatter(|o, s, v| {
120                let Some(value) = v.as_str() else {
121                    return minijinja::escape_formatter(o, s, v);
122                };
123                // preserve existing behavior for single line strings not sure why but
124                // an empty string is being formatted when importing the homebrew publish
125                // job so also preserve this behavior for whitespace only strings
126                if !value.trim().contains('\n') {
127                    return minijinja::escape_formatter(o, s, v);
128                };
129                // This will avoid quote/escapes
130                o.write_str(value)?;
131                Ok(())
132            });
133
134            envs.insert(ENV_YAML, yaml_env);
135        }
136        for env in envs.values_mut() {
137            env.set_debug(true);
138            env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
139
140            fn jinja_error(details: String) -> std::result::Result<String, minijinja::Error> {
141                Err(minijinja::Error::new(
142                    minijinja::ErrorKind::EvalBlock,
143                    details,
144                ))
145            }
146
147            env.add_function("error", jinja_error);
148
149            fn is_empty(value: &minijinja::Value) -> bool {
150                let Some(len) = value.len() else {
151                    return false;
152                };
153                len == 0
154            }
155            env.add_test("empty", is_empty);
156
157            fn is_multiline(value: &minijinja::Value) -> bool {
158                let Some(s) = value.as_str() else {
159                    return false;
160                };
161                s.contains('\n')
162            }
163            env.add_test("multiline", is_multiline);
164        }
165
166        let mut entries = TemplateDir {
167            _name: String::new(),
168            path: Utf8PathBuf::new(),
169            entries: SortedMap::new(),
170        };
171        // These two `expects` should never happen in production, because all of these things are
172        // are baked into the binary. If this fails at all it should presumably *always* fail, and
173        // so these unwraps will only show up when someone's messing with the templates locally
174        // during development and presumably wrote some malformed jinja2 markup.
175        Self::load_files(&mut envs, &mut raw_files, &TEMPLATE_DIR, &mut entries)
176            .expect("failed to load jinja2 templates from binary");
177
178        let templates = Self {
179            envs,
180            raw_files,
181            entries,
182        };
183
184        Ok(templates)
185    }
186
187    /// Get the entry for a template by key (the TEMPLATE_* consts)
188    fn get_template_entry(&self, key: TemplateId) -> DistResult<&TemplateEntry> {
189        let mut parent = &self.entries;
190        let mut result: Option<&TemplateEntry> = None;
191        for part in key.split('/') {
192            result = parent.entries.get(part);
193            if let Some(entry) = result {
194                if let TemplateEntry::Dir(dir) = entry {
195                    parent = dir;
196                }
197            } else {
198                panic!("invalid jinja2 template key: {key}")
199            }
200        }
201
202        if let Some(entry) = result {
203            Ok(entry)
204        } else {
205            panic!("invalid jinja2 template key: {key}");
206        }
207    }
208
209    /// Get the entry for a template by key (the TEMPLATE_* consts), and require it to be a file
210    pub fn get_template_file(&self, key: TemplateId) -> DistResult<&TemplateFile> {
211        if let TemplateEntry::File(file) = self.get_template_entry(key)? {
212            Ok(file)
213        } else {
214            panic!("jinja2 template key was not a file: {key}");
215        }
216    }
217
218    /// Get the entry for a template by key (the TEMPLATE_* consts), and require it to be a dir
219    pub fn get_template_dir(&self, key: TemplateId) -> DistResult<&TemplateDir> {
220        if let TemplateEntry::Dir(dir) = self.get_template_entry(key)? {
221            Ok(dir)
222        } else {
223            panic!("jinja2 template key was not a dir: {key}");
224        }
225    }
226
227    /// Render a template file to a string, cleaning all newlines to be unix-y
228    pub fn render_file_to_clean_string(
229        &self,
230        key: TemplateId,
231        val: &impl Serialize,
232    ) -> DistResult<String> {
233        let file = self.get_template_file(key)?;
234        self.render_file_to_clean_string_inner(file, val)
235    }
236
237    /// Render a maybe-templated file to a string, cleaning all newlines to be unix-y
238    fn render_file_to_clean_string_inner(
239        &self,
240        file: &TemplateFile,
241        val: &impl Serialize,
242    ) -> DistResult<String> {
243        if file.env == ENV_NONE {
244            self.render_raw_file_to_clean_string(file)
245        } else {
246            self.render_templated_file_to_clean_string(file, val)
247        }
248    }
249
250    /// ""Render"" a non-jinja template file to a string, cleaning all newlines to be unix-y
251    /// (it just returns the file verbatime with newlines fixed).
252    fn render_raw_file_to_clean_string(&self, file: &TemplateFile) -> DistResult<String> {
253        let rendered = &self.raw_files[file.path.as_str()];
254        let cleaned = dos2unix(rendered).into_owned();
255        Ok(cleaned)
256    }
257
258    /// Render a jinja template file to a string, cleaning all newlines to be unix-y
259    fn render_templated_file_to_clean_string(
260        &self,
261        file: &TemplateFile,
262        val: &impl Serialize,
263    ) -> DistResult<String> {
264        let template = self.envs[file.env].get_template(file.path.as_str())?;
265        let mut rendered = template.render(val)?;
266        // minijinja strips trailing newlines from templates
267        if !rendered.ends_with('\n') {
268            rendered.push('\n');
269        }
270        let cleaned = dos2unix(&rendered).into_owned();
271        Ok(cleaned)
272    }
273
274    /// Render all the templates under a directory to a string, cleaning all newlines to be unix-y
275    ///
276    /// The output is a map from relpath => rendered_text, where relpath is the path of the file relative
277    /// to the starting directory. So if you render "installer", you'll get back "npm/package.json" => "...".
278    /// This allows us to store directory structures in the templates dir and forward them verbatim
279    /// when writing them to disk.
280    pub fn render_dir_to_clean_strings(
281        &self,
282        key: TemplateId,
283        val: &impl Serialize,
284    ) -> DistResult<SortedMap<Utf8PathBuf, String>> {
285        let root_dir = self.get_template_dir(key)?;
286        let mut output = SortedMap::new();
287        self.render_dir_to_clean_strings_inner(&mut output, root_dir, root_dir, val)?;
288        Ok(output)
289    }
290
291    fn render_dir_to_clean_strings_inner(
292        &self,
293        output: &mut SortedMap<Utf8PathBuf, String>,
294        root_dir: &TemplateDir,
295        dir: &TemplateDir,
296        val: &impl Serialize,
297    ) -> DistResult<()> {
298        for entry in dir.entries.values() {
299            match entry {
300                TemplateEntry::Dir(subdir) => {
301                    self.render_dir_to_clean_strings_inner(output, root_dir, subdir, val)?
302                }
303                TemplateEntry::File(file) => {
304                    let rendered = self.render_file_to_clean_string_inner(file, val)?;
305                    let relpath = file.path_from_ancestor(root_dir);
306                    output.insert(relpath.to_owned(), rendered);
307                }
308            }
309        }
310        Ok(())
311    }
312
313    /// load + parse templates from the binary (recursive)
314    fn load_files(
315        envs: &mut SortedMap<EnvId, Environment<'static>>,
316        raw_files: &mut SortedMap<String, String>,
317        dir: &'static Dir,
318        parent: &mut TemplateDir,
319    ) -> DistResult<()> {
320        for entry in dir.entries() {
321            let path = Utf8Path::from_path(entry.path()).expect("non-utf8 jinja2 template path");
322            if let Some(file) = entry.as_file() {
323                let is_jinja = path.extension().unwrap_or_default() == "j2";
324                // Remove the .j2 extension
325                let path = if is_jinja {
326                    path.with_extension("")
327                } else {
328                    path.to_owned()
329                };
330
331                let name = path
332                    .file_name()
333                    .expect("template didn't have a name!?")
334                    .to_owned();
335                let contents = file.contents_utf8().expect("non-utf8 template").to_string();
336                let env = if !is_jinja {
337                    ENV_NONE
338                } else if path.extension().unwrap_or_default() == "yml" {
339                    ENV_YAML
340                } else {
341                    ENV_MISC
342                };
343
344                if is_jinja {
345                    envs.get_mut(env)
346                        .expect("invalid template env key")
347                        .add_template_owned(path.to_string(), contents)
348                        .expect("failed to add template");
349                } else {
350                    raw_files.insert(path.to_string(), contents);
351                }
352                parent.entries.insert(
353                    name.clone(),
354                    TemplateEntry::File(TemplateFile { name, path, env }),
355                );
356            }
357            if let Some(dir) = entry.as_dir() {
358                let name = path
359                    .file_name()
360                    .expect("jinja2 template didn't have a name!?")
361                    .to_owned();
362                let mut new_dir = TemplateDir {
363                    _name: name.clone(),
364                    path: path.to_owned(),
365                    entries: SortedMap::new(),
366                };
367                Self::load_files(envs, raw_files, dir, &mut new_dir)
368                    .expect("failed to load jinja2 templates from binary");
369                parent.entries.insert(name, TemplateEntry::Dir(new_dir));
370            }
371        }
372
373        Ok(())
374    }
375}
376
377#[cfg(test)]
378mod test {
379    use super::*;
380
381    #[test]
382    fn ensure_known_templates() {
383        let templates = Templates::new().unwrap();
384
385        templates.get_template_file(TEMPLATE_INSTALLER_SH).unwrap();
386        templates.get_template_file(TEMPLATE_INSTALLER_RB).unwrap();
387        templates.get_template_file(TEMPLATE_INSTALLER_PS1).unwrap();
388        templates.get_template_dir(TEMPLATE_INSTALLER_NPM).unwrap();
389        templates
390            .get_template_file(TEMPLATE_INSTALLER_NPM_RUN_JS)
391            .unwrap();
392
393        templates.get_template_file(TEMPLATE_CI_GITHUB).unwrap();
394    }
395}