1mod answers;
57mod front_matter;
58mod input;
59mod provenance;
60mod scan;
61
62use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
63use std::fmt;
64use std::path::{Component, Path, PathBuf};
65use std::sync::{Arc, Mutex, PoisonError};
66
67use schemars::JsonSchema;
68use serde::Serialize;
69use serde_json::Value;
70use sha2::{Digest as _, Sha256};
71
72pub use answers::Answers;
73use answers::Given;
74pub use front_matter::{DECLARATION_KEYS, FRONT_MATTER_KEYS, is_variable_name};
75pub use input::{LoaderDocument, TemplateInput};
76pub use provenance::{Sha256Digest, TemplateProvenance, answers_digest, body_digest};
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
80#[serde(rename_all = "kebab-case")]
81pub enum VariableType {
82 String,
84 Text,
86 Integer,
88 Boolean,
90 List,
92 Object,
94}
95
96impl VariableType {
97 pub const ALL: [Self; 6] = [
99 Self::String,
100 Self::Text,
101 Self::Integer,
102 Self::Boolean,
103 Self::List,
104 Self::Object,
105 ];
106
107 fn parse(text: &str) -> Option<Self> {
109 Self::ALL.into_iter().find(|kind| kind.as_str() == text)
110 }
111
112 #[must_use]
114 pub fn as_str(self) -> &'static str {
115 match self {
116 Self::String => "string",
117 Self::Text => "text",
118 Self::Integer => "integer",
119 Self::Boolean => "boolean",
120 Self::List => "list",
121 Self::Object => "object",
122 }
123 }
124}
125
126impl fmt::Display for VariableType {
127 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
128 formatter.write_str(self.as_str())
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
134#[serde(rename_all = "kebab-case")]
135pub enum ItemType {
136 String,
138 Object,
140}
141
142impl ItemType {
143 pub const ALL: [Self; 2] = [Self::String, Self::Object];
145
146 fn parse(text: &str) -> Option<Self> {
148 Self::ALL.into_iter().find(|items| items.as_str() == text)
149 }
150
151 #[must_use]
153 pub fn as_str(self) -> &'static str {
154 match self {
155 Self::String => "string",
156 Self::Object => "object",
157 }
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
167pub struct TemplateVariable {
168 name: String,
170 description: String,
172 #[serde(rename = "type")]
174 kind: VariableType,
175 #[serde(skip_serializing_if = "Option::is_none")]
177 items: Option<ItemType>,
178 required: bool,
181 #[serde(skip_serializing_if = "Option::is_none")]
183 default: Option<Value>,
184 declared_in: String,
186}
187impl TemplateVariable {
190 #[must_use]
192 pub fn name(&self) -> &str {
193 &self.name
194 }
195
196 #[must_use]
198 pub fn description(&self) -> &str {
199 &self.description
200 }
201
202 #[must_use]
204 pub fn kind(&self) -> VariableType {
205 self.kind
206 }
207
208 #[must_use]
210 pub fn items(&self) -> Option<ItemType> {
211 self.items
212 }
213
214 #[must_use]
216 pub fn required(&self) -> bool {
217 self.required
218 }
219
220 #[must_use]
222 pub fn default(&self) -> Option<&Value> {
223 self.default.as_ref()
224 }
225
226 #[must_use]
228 pub fn declared_in(&self) -> &str {
229 &self.declared_in
230 }
231
232 #[must_use]
235 pub fn multi_line(&self) -> bool {
236 matches!(
237 self.kind,
238 VariableType::Text | VariableType::List | VariableType::Object
239 )
240 }
241
242 pub fn parse(&self, text: &str) -> Result<Value, String> {
249 let value = match self.kind {
250 VariableType::String | VariableType::Text => Value::String(text.to_owned()),
251 _ => {
252 serde_norway::from_str(text).map_err(|error| format!("it is not YAML: {error}"))?
253 }
254 };
255 self.check(&value)?;
256 Ok(value)
257 }
258
259 pub fn check(&self, value: &Value) -> Result<(), String> {
265 check_value(self.kind, self.items, value)
266 }
267}
268
269fn check_value(kind: VariableType, items: Option<ItemType>, value: &Value) -> Result<(), String> {
271 let fits = match kind {
272 VariableType::String => {
273 if let Value::String(text) = value
274 && text.contains(['\n', '\r'])
275 {
276 return Err("a `string` is one line; declare the variable `text` for more".into());
277 }
278 value.is_string()
279 }
280 VariableType::Text => value.is_string(),
281 VariableType::Integer => value.is_i64() || value.is_u64(),
282 VariableType::Boolean => value.is_boolean(),
283 VariableType::Object => value.is_object(),
284 VariableType::List => {
285 let Value::Array(entries) = value else {
286 return Err(format!("expected a list, got {}", describe(value)));
287 };
288 let items = items.unwrap_or(ItemType::String);
289 if let Some((index, entry)) =
290 entries.iter().enumerate().find(|(_, entry)| match items {
291 ItemType::String => !entry.is_string(),
292 ItemType::Object => !entry.is_object(),
293 })
294 {
295 return Err(format!(
296 "entry {index} of the list is {}, and its items are {}s",
297 describe(entry),
298 items.as_str()
299 ));
300 }
301 true
302 }
303 };
304 if fits {
305 Ok(())
306 } else {
307 Err(format!(
308 "expected {}, got {}",
309 article(kind),
310 describe(value)
311 ))
312 }
313}
314
315fn article(kind: VariableType) -> String {
317 match kind {
318 VariableType::Integer | VariableType::Object => format!("an {kind}"),
319 _ => format!("a {kind}"),
320 }
321}
322
323fn describe(value: &Value) -> String {
325 match value {
326 Value::Null => "null".to_owned(),
327 Value::Bool(value) => format!("the boolean {value}"),
328 Value::Number(number) => format!("the number {number}"),
329 Value::String(text) => format!("the string {text:?}"),
330 Value::Array(_) => "a list".to_owned(),
331 Value::Object(_) => "a mapping".to_owned(),
332 }
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
337pub enum ChainField {
338 Type,
340 Items,
342}
343
344impl fmt::Display for ChainField {
345 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
346 formatter.write_str(match self {
347 Self::Type => "type",
348 Self::Items => "items",
349 })
350 }
351}
352
353#[derive(Debug, Clone, PartialEq, thiserror::Error)]
355#[non_exhaustive]
356pub enum TemplateError {
357 #[error(
359 "template {name:?} was not found{}\nnext: {}",
360 referenced_from.as_ref().map(|from| format!(" (named by {from})")).unwrap_or_default(),
361 if searched.is_empty() {
362 "give the directory holding it as a search path, or register it by name.".to_owned()
363 } else {
364 format!("it was looked for in {}; add the directory holding it as a search path.", searched.join(", "))
365 }
366 )]
367 NotFound {
368 name: String,
370 referenced_from: Option<String>,
372 searched: Vec<String>,
374 },
375 #[error(
378 "template {name} could not be read: {message}\n\
379 next: name a readable file, or give it the permissions this process reads with."
380 )]
381 Unreadable {
382 name: String,
384 message: String,
386 },
387 #[error(
390 "search path {directory} cannot be searched: {message}\n\
391 next: give a directory that exists, or leave it out of the search path."
392 )]
393 SearchPath {
394 directory: String,
396 message: String,
398 },
399 #[error(
401 "template {file}: {}{message}\nnext: {}",
402 key.as_ref().map(|key| format!("front matter key `{key}` ")).unwrap_or_default(),
403 "correct that file; a template's front matter is described in the README under \"Task templates\"."
404 )]
405 Malformed {
406 file: String,
408 key: Option<String>,
410 message: String,
412 },
413 #[error(
415 "template variable {variable:?} is declared with {field} {nearer_value} in {nearer} and \
416 with {field} {farther_value} in {farther}; a redeclaration may change `description`, \
417 `default` and `required`, never `type` or `items`\n\
418 next: declare it with the same {field} in both files, or rename one of them."
419 )]
420 ChainConflict {
421 variable: String,
423 field: ChainField,
425 nearer: String,
427 nearer_value: &'static str,
429 farther: String,
431 farther_value: &'static str,
433 },
434 #[error(
436 "the answers document is refused: {message}\n\
437 next: write the answers as a YAML mapping from variable name to value."
438 )]
439 MalformedAnswers {
440 message: String,
442 },
443 #[error(
445 "{} no variable the template declares: {}\n\
446 next: remove {}, or declare {} in the template's front matter — `onetaskgraph template \
447 variables` lists what it declares.",
448 if names.len() == 1 { "an answer names" } else { "answers name" },
449 names.join(", "),
450 if names.len() == 1 { "that answer" } else { "those answers" },
451 if names.len() == 1 { "it" } else { "them" }
452 )]
453 UnknownAnswer {
454 names: Vec<String>,
456 },
457 #[error(
459 "the answer to {name:?} is not {expected}: {problem}\n\
460 next: give {name} a value of type {kind}."
461 )]
462 MistypedAnswer {
463 name: String,
465 kind: VariableType,
467 expected: String,
469 problem: String,
471 },
472 #[error(
474 "{} unanswered: {}\n\
475 next: answer {} with --var NAME=VALUE or in an answers file (--answers FILE), or run \
476 interactively to be asked.",
477 if names.len() == 1 { "a required variable is" } else { "required variables are" },
478 names.join(", "),
479 if names.len() == 1 { "it" } else { "each" }
480 )]
481 MissingRequired {
482 names: Vec<String>,
484 },
485 #[error(
487 "template {}{}: {message}\n\
488 next: {}",
489 file.as_deref().unwrap_or("?"),
490 line.map(|line| format!(" line {line}")).unwrap_or_default(),
491 match name {
492 Some(name) => format!(
493 "declare {name} in the front matter of a file in the chain, or set it in the \
494 template before it is used."
495 ),
496 None => "correct the template at that line.".to_owned(),
497 }
498 )]
499 Render {
500 file: Option<String>,
502 line: Option<usize>,
504 name: Option<String>,
506 message: String,
508 },
509 #[error(
511 "the template loader document is refused: {message}\n\
512 next: supply a JSON object with a non-empty string `reference`, a non-empty string \
513 `entry`, an optional `search_path` of absolute directories, optional `templates` of \
514 {{\"name\", \"source\"}} string pairs and an optional string `digest`."
515 )]
516 MalformedLoader {
517 message: String,
519 },
520 #[error(
522 "the template loader document states the digest {stated}, and the chain it names \
523 computes {computed}\n\
524 next: state the digest of the chain the document names — `onetaskgraph template \
525 variables --template-loader` reports it — or leave `digest` out."
526 )]
527 LoaderDigest {
528 stated: String,
530 computed: String,
532 },
533}
534
535impl TemplateError {
536 #[must_use]
538 pub fn kind(&self) -> &'static str {
539 match self {
540 Self::NotFound { .. } => "template-not-found",
541 Self::Unreadable { .. } => "template-unreadable",
542 Self::SearchPath { .. } => "template-search-path",
543 Self::Malformed { .. } => "template-malformed",
544 Self::ChainConflict { .. } => "template-chain-conflict",
545 Self::MalformedAnswers { .. } => "template-answers-malformed",
546 Self::UnknownAnswer { .. } => "template-unknown-answer",
547 Self::MistypedAnswer { .. } => "template-mistyped-answer",
548 Self::MissingRequired { .. } => "template-missing-required",
549 Self::Render { .. } => "template-render",
550 Self::MalformedLoader { .. } => "template-loader-malformed",
551 Self::LoaderDigest { .. } => "template-loader-digest",
552 }
553 }
554
555 #[must_use]
559 pub fn refuses_answers(&self) -> bool {
560 matches!(
561 self,
562 Self::MalformedAnswers { .. }
563 | Self::UnknownAnswer { .. }
564 | Self::MistypedAnswer { .. }
565 | Self::MissingRequired { .. }
566 )
567 }
568
569 fn malformed(file: &str, key: Option<&str>, message: impl Into<String>) -> Self {
570 Self::Malformed {
571 file: file.to_owned(),
572 key: key.map(str::to_owned),
573 message: message.into(),
574 }
575 }
576}
577
578#[derive(Debug, Clone, Default)]
584pub struct TemplateLoader {
585 directories: Vec<PathBuf>,
586 registered: Vec<(String, String)>,
587}
588
589impl TemplateLoader {
590 #[must_use]
592 pub fn new() -> Self {
593 Self::default()
594 }
595
596 #[must_use]
598 pub fn with_directory(mut self, directory: impl Into<PathBuf>) -> Self {
599 self.directories.push(directory.into());
600 self
601 }
602
603 #[must_use]
605 pub fn with_template(mut self, name: impl Into<String>, source: impl Into<String>) -> Self {
606 self.registered.push((name.into(), source.into()));
607 self
608 }
609
610 pub fn load_path(&self, path: &Path) -> Result<Template, TemplateError> {
622 self.searchable()?;
623 let shown = path.display().to_string();
624 let bytes = std::fs::read(path).map_err(|error| {
625 if error.kind() == std::io::ErrorKind::NotFound {
626 TemplateError::NotFound {
627 name: shown.clone(),
628 referenced_from: None,
629 searched: Vec::new(),
630 }
631 } else {
632 TemplateError::Unreadable {
633 name: shown.clone(),
634 message: error.to_string(),
635 }
636 }
637 })?;
638 let source = String::from_utf8(bytes)
639 .map_err(|_| TemplateError::malformed(&shown, None, "it is not UTF-8 text"))?;
640 let name = path
641 .file_name()
642 .map_or_else(|| shown.clone(), |name| name.to_string_lossy().into_owned());
643 self.load_chain(name, source)
644 }
645
646 pub fn load_name(&self, name: &str) -> Result<Template, TemplateError> {
654 self.searchable()?;
655 let source = self.find(name)?.ok_or_else(|| self.not_found(name, None))?;
656 self.load_chain(name.to_owned(), source)
657 }
658
659 fn find(&self, name: &str) -> Result<Option<String>, TemplateError> {
667 let path = Path::new(name);
668 let spelled_within = !name.is_empty()
669 && path
670 .components()
671 .all(|component| matches!(component, Component::Normal(_)));
672 if spelled_within {
673 for directory in &self.directories {
674 let candidate = directory.join(path);
675 if candidate.is_file() {
676 let bytes =
677 std::fs::read(&candidate).map_err(|error| TemplateError::Unreadable {
678 name: candidate.display().to_string(),
679 message: error.to_string(),
680 })?;
681 return String::from_utf8(bytes)
682 .map(Some)
683 .map_err(|_| TemplateError::malformed(name, None, "it is not UTF-8 text"));
684 }
685 }
686 }
687 Ok(self
688 .registered
689 .iter()
690 .find(|(registered, _)| registered == name)
691 .map(|(_, source)| source.clone()))
692 }
693
694 fn not_found(&self, name: &str, referenced_from: Option<&str>) -> TemplateError {
695 TemplateError::NotFound {
696 name: name.to_owned(),
697 referenced_from: referenced_from.map(str::to_owned),
698 searched: self
699 .directories
700 .iter()
701 .map(|directory| directory.display().to_string())
702 .collect(),
703 }
704 }
705
706 fn load_chain(&self, root: String, source: String) -> Result<Template, TemplateError> {
713 let resolutions = Resolutions::new();
714 let files = self.build(root.clone(), source, &resolutions)?;
715 let variables = merge(&files)?;
716 let Discovered { read, .. } = discover(&root, &files, &variables, self, &Answers::new());
717 let files = in_read_order(files, &read);
718 let variables = merge(&files)?;
719 Ok(Template::assemble(
720 root,
721 files,
722 variables,
723 resolutions,
724 self.clone(),
725 ))
726 }
727
728 fn searchable(&self) -> Result<(), TemplateError> {
730 for directory in &self.directories {
731 let refused = |message: String| TemplateError::SearchPath {
732 directory: directory.display().to_string(),
733 message,
734 };
735 match std::fs::metadata(directory) {
736 Ok(metadata) if metadata.is_dir() => {}
737 Ok(_) => return Err(refused("it is not a directory".to_owned())),
738 Err(error) => return Err(refused(error.to_string())),
739 }
740 }
741 Ok(())
742 }
743
744 fn build(
751 &self,
752 name: String,
753 source: String,
754 resolutions: &Resolutions,
755 ) -> Result<Vec<ChainFile>, TemplateError> {
756 let mut files = Vec::new();
757 let mut seen: HashSet<String> = HashSet::new();
758 let mut pending = vec![(name, source)];
759 while let Some((name, source)) = pending.pop() {
761 if !seen.insert(name.clone()) {
762 continue;
763 }
764 let split = front_matter::split(&name, &source)?;
765 compile_check(&name, &split.body, split.offset_lines)?;
766 let mut names = Vec::new();
767 let mut children = Vec::new();
768 for named in scan::named(&split.body) {
769 let references = match named {
770 scan::Named::Literal(reference) => vec![reference],
771 scan::Named::Expression { ordinal, .. } => resolutions
773 .get(&NamingTag {
774 file: name.clone(),
775 ordinal,
776 })
777 .into_iter()
778 .flatten()
779 .map(|candidates| scan::Reference {
780 candidates: candidates.names().to_vec(),
781 optional: true,
782 })
783 .collect(),
784 };
785 for reference in references {
786 let mut resolved = false;
789 for candidate in &reference.candidates {
790 if seen.contains(candidate) || *candidate == name {
791 names.push(candidate.clone());
792 resolved = true;
793 break;
794 }
795 if let Some(source) = self.find(candidate)? {
796 names.push(candidate.clone());
797 children.push((candidate.clone(), source));
798 resolved = true;
799 break;
800 }
801 }
802 if !resolved && !reference.optional {
803 return Err(self.not_found(&reference.candidates.join(" or "), Some(&name)));
804 }
805 }
806 }
807 pending.extend(children.into_iter().rev());
808 files.push(ChainFile {
809 name,
810 source,
811 body: split.body,
812 offset_lines: split.offset_lines,
813 declarations: split.declarations,
814 names,
815 });
816 }
817 Ok(files)
818 }
819}
820
821#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
824struct NamingTag {
825 file: String,
826 ordinal: usize,
827}
828
829#[derive(Debug, Clone, PartialEq, Eq)]
832struct Candidates(Vec<String>);
833
834impl Candidates {
835 fn of(value: &minijinja::Value) -> Option<Self> {
838 let names: Vec<String> = match value.as_str() {
839 Some(name) => vec![name.to_owned()],
840 None if value.kind() == minijinja::value::ValueKind::Seq => value
841 .try_iter()
842 .into_iter()
843 .flatten()
844 .filter_map(|item| item.as_str().map(str::to_owned))
845 .collect(),
846 None => Vec::new(),
847 };
848 (!names.is_empty()).then_some(Self(names))
849 }
850
851 fn names(&self) -> &[String] {
852 &self.0
853 }
854}
855
856type Resolutions = BTreeMap<NamingTag, Vec<Candidates>>;
859
860fn hooked_bodies(files: &[ChainFile]) -> HashMap<String, String> {
863 files
864 .iter()
865 .enumerate()
866 .map(|(index, file)| (file.name.clone(), scan::hooked(&file.body, index)))
867 .collect()
868}
869
870fn record_names(
873 environment: &mut minijinja::Environment<'static>,
874 files: &[ChainFile],
875) -> Arc<Mutex<Resolutions>> {
876 let names: Vec<String> = files.iter().map(|file| file.name.clone()).collect();
877 let recorded: Arc<Mutex<Resolutions>> = Arc::default();
878 let sink = Arc::clone(&recorded);
879 environment.add_function(
880 scan::HOOK,
881 move |file: usize, ordinal: usize, value: minijinja::Value| {
882 if let Some(name) = names.get(file)
883 && let Some(candidates) = Candidates::of(&value)
884 {
885 let mut recorded = sink.lock().unwrap_or_else(PoisonError::into_inner);
886 let found = recorded
887 .entry(NamingTag {
888 file: name.clone(),
889 ordinal,
890 })
891 .or_default();
892 if !found.contains(&candidates) {
893 found.push(candidates);
894 }
895 }
896 value
897 },
898 );
899 recorded
900}
901
902fn compile_check(name: &str, body: &str, offset_lines: usize) -> Result<(), TemplateError> {
904 let environment = environment();
905 environment
906 .template_from_named_str(name, body)
907 .map(|_| ())
908 .map_err(|error| TemplateError::Malformed {
909 file: name.to_owned(),
910 key: None,
911 message: format!(
912 "its body does not parse{}: {}",
913 error
914 .line()
915 .map(|line| format!(" at line {}", line + offset_lines))
916 .unwrap_or_default(),
917 error.detail().unwrap_or("a syntax error")
918 ),
919 })
920}
921
922#[derive(Debug, Clone)]
924struct ChainFile {
925 name: String,
926 source: String,
927 body: String,
928 offset_lines: usize,
929 declarations: Vec<TemplateVariable>,
930 names: Vec<String>,
933}
934
935fn merge(files: &[ChainFile]) -> Result<Vec<TemplateVariable>, TemplateError> {
940 let index_of: HashMap<&str, usize> = files
941 .iter()
942 .enumerate()
943 .map(|(index, file)| (file.name.as_str(), index))
944 .collect();
945 let mut depth = vec![usize::MAX; files.len()];
946 if !files.is_empty() {
947 depth[0] = 0;
948 }
949 let mut queue = VecDeque::from([0usize]);
950 while let Some(at) = queue.pop_front() {
951 for child in &files[at].names {
952 if let Some(&child) = index_of.get(child.as_str())
953 && depth[child] == usize::MAX
954 {
955 depth[child] = depth[at] + 1;
956 queue.push_back(child);
957 }
958 }
959 }
960 let mut nearness: Vec<usize> = (0..files.len()).collect();
961 nearness.sort_by_key(|&index| (depth[index], index));
962
963 let mut merged: BTreeMap<&str, TemplateVariable> = BTreeMap::new();
964 for &index in &nearness {
965 let file = &files[index];
966 for declaration in &file.declarations {
967 match merged.get(declaration.name.as_str()) {
968 None => {
969 merged.insert(&declaration.name, declaration.clone());
970 }
971 Some(nearer) => {
972 if nearer.kind != declaration.kind {
973 return Err(conflict(
974 nearer,
975 ChainField::Type,
976 nearer.kind.as_str(),
977 &file.name,
978 declaration.kind.as_str(),
979 &declaration.name,
980 ));
981 }
982 if nearer.items != declaration.items {
983 let spell =
984 |items: Option<ItemType>| items.map_or("none", ItemType::as_str);
985 return Err(conflict(
986 nearer,
987 ChainField::Items,
988 spell(nearer.items),
989 &file.name,
990 spell(declaration.items),
991 &declaration.name,
992 ));
993 }
994 }
995 }
996 }
997 }
998
999 let mut ordered = Vec::with_capacity(merged.len());
1000 for file in files {
1001 for declaration in &file.declarations {
1002 if let Some(variable) = merged.remove(declaration.name.as_str()) {
1003 ordered.push(variable);
1004 }
1005 }
1006 }
1007 Ok(ordered)
1008}
1009
1010fn conflict(
1011 nearer: &TemplateVariable,
1012 field: ChainField,
1013 nearer_value: &'static str,
1014 farther: &str,
1015 farther_value: &'static str,
1016 variable: &str,
1017) -> TemplateError {
1018 TemplateError::ChainConflict {
1019 variable: variable.to_owned(),
1020 field,
1021 nearer: nearer.declared_in.clone(),
1022 nearer_value,
1023 farther: farther.to_owned(),
1024 farther_value,
1025 }
1026}
1027
1028fn digest<'a>(files: impl IntoIterator<Item = (&'a str, &'a str)>) -> String {
1030 let mut hasher = Sha256::new();
1031 for (name, source) in files {
1032 hasher.update(name.as_bytes());
1033 hasher.update([0]);
1034 hasher.update(source.as_bytes());
1035 hasher.update([0]);
1036 }
1037 let hash = hasher.finalize();
1038 let mut rendered = String::with_capacity(7 + hash.len() * 2);
1039 rendered.push_str("sha256:");
1040 for byte in hash {
1041 rendered.push_str(&format!("{byte:02x}"));
1042 }
1043 rendered
1044}
1045
1046fn environment() -> minijinja::Environment<'static> {
1048 let mut environment = minijinja::Environment::new();
1049 environment.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
1050 environment.set_auto_escape_callback(|_| minijinja::AutoEscape::None);
1051 environment.set_keep_trailing_newline(true);
1052 environment.set_trim_blocks(true);
1053 environment.set_lstrip_blocks(true);
1054 environment
1055}
1056
1057#[derive(Debug, Clone)]
1059pub struct Template {
1060 name: String,
1061 files: Vec<ChainFile>,
1062 variables: Vec<TemplateVariable>,
1063 resolutions: Resolutions,
1065 digest: String,
1066 loader: TemplateLoader,
1067}
1068
1069#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
1071pub struct TemplateVariables {
1072 pub template: String,
1074 pub digest: String,
1078 pub variables: Vec<TemplateVariable>,
1080}
1081
1082#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
1084pub struct RenderedTemplate {
1085 pub body: String,
1087 pub digest: String,
1092 pub answers: BTreeMap<String, Value>,
1095}
1096
1097impl Template {
1098 fn assemble(
1099 name: String,
1100 files: Vec<ChainFile>,
1101 variables: Vec<TemplateVariable>,
1102 resolutions: Resolutions,
1103 loader: TemplateLoader,
1104 ) -> Self {
1105 let digest = digest(
1106 files
1107 .iter()
1108 .map(|file| (file.name.as_str(), file.source.as_str())),
1109 );
1110 Self {
1111 name,
1112 files,
1113 variables,
1114 resolutions,
1115 digest,
1116 loader,
1117 }
1118 }
1119
1120 pub fn expand(&self, answers: &Answers) -> Result<Self, TemplateError> {
1147 let mut expanded = self.clone();
1148 let mut named = vec![expanded.resolutions.clone()];
1149 loop {
1150 let Discovered {
1151 resolutions,
1152 read,
1153 failure,
1154 } = discover(
1155 &expanded.name,
1156 &expanded.files,
1157 &expanded.variables,
1158 &expanded.loader,
1159 answers,
1160 );
1161 if let Some(error) = failure {
1162 return Err(error);
1163 }
1164 if resolutions == expanded.resolutions {
1165 let files = in_read_order(expanded.files, &read);
1166 let variables = merge(&files)?;
1167 return Ok(Self::assemble(
1168 self.name.clone(),
1169 files,
1170 variables,
1171 resolutions,
1172 self.loader.clone(),
1173 ));
1174 }
1175 if named.contains(&resolutions) {
1176 return Err(TemplateError::malformed(
1177 &self.name,
1178 None,
1179 "what its expressions name never settles: each template they name gives a \
1180 default naming another, round a cycle; answer the variable that decides it",
1181 ));
1182 }
1183 named.push(resolutions.clone());
1184 let files = expanded.rebuild(&resolutions)?;
1185 let variables = merge(&files)?;
1186 expanded = Self::assemble(
1187 self.name.clone(),
1188 files,
1189 variables,
1190 resolutions,
1191 self.loader.clone(),
1192 );
1193 }
1194 }
1195
1196 fn rebuild(&self, resolutions: &Resolutions) -> Result<Vec<ChainFile>, TemplateError> {
1197 let root = self
1198 .files
1199 .first()
1200 .map_or_else(String::new, |file| file.source.clone());
1201 self.loader.build(self.name.clone(), root, resolutions)
1202 }
1203
1204 #[must_use]
1206 pub fn name(&self) -> &str {
1207 &self.name
1208 }
1209
1210 #[must_use]
1212 pub fn digest(&self) -> &str {
1213 &self.digest
1214 }
1215
1216 #[must_use]
1218 pub fn variables(&self) -> &[TemplateVariable] {
1219 &self.variables
1220 }
1221
1222 pub fn chain(&self) -> impl Iterator<Item = &str> {
1227 self.files.iter().map(|file| file.name.as_str())
1228 }
1229
1230 #[must_use]
1232 pub fn describe(&self) -> TemplateVariables {
1233 TemplateVariables {
1234 template: self.name.clone(),
1235 digest: self.digest.clone(),
1236 variables: self.variables.clone(),
1237 }
1238 }
1239
1240 pub fn unanswered(&self, answers: &Answers) -> Result<Vec<TemplateVariable>, TemplateError> {
1250 let expanded = self.expand(answers)?;
1251 let typed = expanded.typed(answers)?;
1252 Ok(expanded
1253 .variables
1254 .into_iter()
1255 .filter(|variable| !typed.contains_key(&variable.name))
1256 .collect())
1257 }
1258
1259 pub fn resolve(&self, answers: &Answers) -> Result<BTreeMap<String, Value>, TemplateError> {
1268 self.expand(answers)?.resolve_here(answers)
1269 }
1270
1271 fn resolve_here(&self, answers: &Answers) -> Result<BTreeMap<String, Value>, TemplateError> {
1273 let mut typed = self.typed(answers)?;
1274 let missing: Vec<String> = self
1275 .variables
1276 .iter()
1277 .filter(|variable| {
1278 variable.required
1279 && variable.default.is_none()
1280 && !typed.contains_key(&variable.name)
1281 })
1282 .map(|variable| variable.name.clone())
1283 .collect();
1284 if !missing.is_empty() {
1285 return Err(TemplateError::MissingRequired { names: missing });
1286 }
1287 for variable in &self.variables {
1288 typed
1289 .entry(variable.name.clone())
1290 .or_insert_with(|| variable.default.clone().unwrap_or(Value::Null));
1291 }
1292 Ok(typed)
1293 }
1294
1295 pub fn render(&self, answers: &Answers) -> Result<RenderedTemplate, TemplateError> {
1304 self.expand(answers)?.render_here(answers)
1305 }
1306
1307 fn render_here(&self, answers: &Answers) -> Result<RenderedTemplate, TemplateError> {
1313 let resolved = self.resolve_here(answers)?;
1314
1315 let offsets: HashMap<String, usize> = self
1316 .files
1317 .iter()
1318 .map(|file| (file.name.clone(), file.offset_lines))
1319 .collect();
1320 let bodies: HashMap<String, String> = self
1321 .files
1322 .iter()
1323 .map(|file| (file.name.clone(), file.body.clone()))
1324 .collect();
1325 let mut environment = environment();
1326 environment.set_loader(move |name| Ok(bodies.get(name).cloned()));
1327 let rendered = environment
1328 .get_template(&self.name)
1329 .and_then(|template| template.render(&resolved))
1330 .map_err(|error| render_error(&error, &offsets))?;
1331 Ok(RenderedTemplate {
1332 body: rendered,
1333 digest: self.digest.clone(),
1334 answers: resolved,
1335 })
1336 }
1337
1338 fn typed(&self, answers: &Answers) -> Result<BTreeMap<String, Value>, TemplateError> {
1340 let declared: HashMap<&str, &TemplateVariable> = self
1341 .variables
1342 .iter()
1343 .map(|variable| (variable.name.as_str(), variable))
1344 .collect();
1345 let unknown: Vec<String> = answers
1346 .names()
1347 .filter(|name| !declared.contains_key(name))
1348 .map(str::to_owned)
1349 .collect();
1350 if !unknown.is_empty() {
1351 return Err(TemplateError::UnknownAnswer { names: unknown });
1352 }
1353
1354 let mut typed = BTreeMap::new();
1355 for variable in &self.variables {
1356 let value = match answers.given(&variable.name) {
1357 None => continue,
1358 Some(Given::Value(value)) => variable.check(value).map(|()| value.clone()),
1359 Some(Given::Text(text)) => variable.parse(text),
1360 };
1361 let value = value.map_err(|problem| TemplateError::MistypedAnswer {
1362 name: variable.name.clone(),
1363 kind: variable.kind,
1364 expected: article(variable.kind),
1365 problem,
1366 })?;
1367 typed.insert(variable.name.clone(), value);
1368 }
1369 Ok(typed)
1370 }
1371}
1372
1373fn discover(
1385 root: &str,
1386 files: &[ChainFile],
1387 variables: &[TemplateVariable],
1388 loader: &TemplateLoader,
1389 answers: &Answers,
1390) -> Discovered {
1391 let mut context = BTreeMap::new();
1392 for variable in variables {
1393 let answered = match answers.given(&variable.name) {
1394 Some(Given::Value(value)) => variable.check(value).ok().map(|()| value.clone()),
1395 Some(Given::Text(text)) => variable.parse(text).ok(),
1396 None => None,
1397 };
1398 if let Some(value) = answered.or_else(|| variable.default.clone()) {
1399 context.insert(variable.name.clone(), value);
1400 } else if !variable.required {
1401 context.insert(variable.name.clone(), Value::Null);
1402 }
1403 }
1404
1405 let bodies = hooked_bodies(files);
1406 let loader = loader.clone();
1407 let failed: Arc<Mutex<Option<TemplateError>>> = Arc::default();
1408 let failure = Arc::clone(&failed);
1409 let read: Arc<Mutex<Vec<String>>> = Arc::default();
1410 let reading = Arc::clone(&read);
1411 let mut environment = environment();
1412 environment.set_undefined_behavior(minijinja::UndefinedBehavior::Lenient);
1413 let recorded = record_names(&mut environment, files);
1414 environment.set_loader(move |name| {
1415 let mut reading = reading.lock().unwrap_or_else(PoisonError::into_inner);
1416 if let Some(body) = bodies.get(name) {
1417 if !reading.iter().any(|read| read == name) {
1418 reading.push(name.to_owned());
1419 }
1420 return Ok(Some(body.clone()));
1421 }
1422 let source = match loader.find(name) {
1423 Ok(Some(source)) => source,
1424 Ok(None) => return Ok(None),
1425 Err(error) => {
1426 failure
1427 .lock()
1428 .unwrap_or_else(PoisonError::into_inner)
1429 .get_or_insert(error);
1430 return Ok(None);
1431 }
1432 };
1433 Ok(Some(
1434 front_matter::split(name, &source)
1435 .map(|split| split.body)
1436 .unwrap_or_default(),
1437 ))
1438 });
1439 let _ = environment
1440 .get_template(root)
1441 .and_then(|template| template.render(&context));
1442 let failure = failed.lock().unwrap_or_else(PoisonError::into_inner).take();
1443 let resolutions = recorded
1444 .lock()
1445 .unwrap_or_else(PoisonError::into_inner)
1446 .clone();
1447 let read = read.lock().unwrap_or_else(PoisonError::into_inner).clone();
1448 Discovered {
1449 resolutions,
1450 read,
1451 failure,
1452 }
1453}
1454
1455struct Discovered {
1459 resolutions: Resolutions,
1460 read: Vec<String>,
1461 failure: Option<TemplateError>,
1462}
1463
1464fn in_read_order(files: Vec<ChainFile>, read: &[String]) -> Vec<ChainFile> {
1468 let mut files: Vec<(usize, ChainFile)> = files
1469 .into_iter()
1470 .enumerate()
1471 .map(|(index, file)| {
1472 let at = if index == 0 {
1473 0
1474 } else {
1475 read.iter()
1476 .position(|name| *name == file.name)
1477 .map_or(read.len() + index, |at| at + 1)
1478 };
1479 (at, file)
1480 })
1481 .collect();
1482 files.sort_by_key(|(at, _)| *at);
1483 files.into_iter().map(|(_, file)| file).collect()
1484}
1485
1486fn render_error(error: &minijinja::Error, offsets: &HashMap<String, usize>) -> TemplateError {
1491 let mut error = error;
1492 while let Some(inner) = std::error::Error::source(error)
1493 .and_then(|source| source.downcast_ref::<minijinja::Error>())
1494 {
1495 error = inner;
1496 }
1497 let file = error.name().map(str::to_owned);
1498 let offset = file
1499 .as_deref()
1500 .and_then(|file| offsets.get(file))
1501 .copied()
1502 .unwrap_or(0);
1503 let line = error.line().map(|line| line + offset);
1504 if error.kind() == minijinja::ErrorKind::UndefinedError {
1505 let name = error
1506 .template_source()
1507 .zip(error.range())
1508 .and_then(|(source, range)| source.get(range))
1509 .map(str::trim)
1510 .filter(|name| !name.is_empty())
1511 .map(str::to_owned);
1512 return TemplateError::Render {
1513 message: match &name {
1514 Some(name) => format!(
1515 "{name} is undefined: it is neither a declared variable nor set by the template"
1516 ),
1517 None => "a value there is undefined".to_owned(),
1518 },
1519 file,
1520 line,
1521 name,
1522 };
1523 }
1524 TemplateError::Render {
1525 file,
1526 line,
1527 name: None,
1528 message: error
1529 .detail()
1530 .map_or_else(|| error.kind().to_string(), str::to_owned),
1531 }
1532}