mod answers;
mod front_matter;
mod scan;
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::fmt;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use schemars::JsonSchema;
use serde::Serialize;
use serde_json::Value;
use sha2::{Digest as _, Sha256};
pub use answers::Answers;
use answers::Given;
pub use front_matter::{DECLARATION_KEYS, FRONT_MATTER_KEYS, is_variable_name};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum VariableType {
String,
Text,
Integer,
Boolean,
List,
Object,
}
impl VariableType {
pub const ALL: [Self; 6] = [
Self::String,
Self::Text,
Self::Integer,
Self::Boolean,
Self::List,
Self::Object,
];
fn parse(text: &str) -> Option<Self> {
Self::ALL.into_iter().find(|kind| kind.as_str() == text)
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::String => "string",
Self::Text => "text",
Self::Integer => "integer",
Self::Boolean => "boolean",
Self::List => "list",
Self::Object => "object",
}
}
}
impl fmt::Display for VariableType {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ItemType {
String,
Object,
}
impl ItemType {
pub const ALL: [Self; 2] = [Self::String, Self::Object];
fn parse(text: &str) -> Option<Self> {
Self::ALL.into_iter().find(|items| items.as_str() == text)
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::String => "string",
Self::Object => "object",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
pub struct TemplateVariable {
name: String,
description: String,
#[serde(rename = "type")]
kind: VariableType,
#[serde(skip_serializing_if = "Option::is_none")]
items: Option<ItemType>,
required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
default: Option<Value>,
declared_in: String,
}
impl TemplateVariable {
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn description(&self) -> &str {
&self.description
}
#[must_use]
pub fn kind(&self) -> VariableType {
self.kind
}
#[must_use]
pub fn items(&self) -> Option<ItemType> {
self.items
}
#[must_use]
pub fn required(&self) -> bool {
self.required
}
#[must_use]
pub fn default(&self) -> Option<&Value> {
self.default.as_ref()
}
#[must_use]
pub fn declared_in(&self) -> &str {
&self.declared_in
}
#[must_use]
pub fn multi_line(&self) -> bool {
matches!(
self.kind,
VariableType::Text | VariableType::List | VariableType::Object
)
}
pub fn parse(&self, text: &str) -> Result<Value, String> {
let value = match self.kind {
VariableType::String | VariableType::Text => Value::String(text.to_owned()),
_ => {
serde_norway::from_str(text).map_err(|error| format!("it is not YAML: {error}"))?
}
};
self.check(&value)?;
Ok(value)
}
pub fn check(&self, value: &Value) -> Result<(), String> {
check_value(self.kind, self.items, value)
}
}
fn check_value(kind: VariableType, items: Option<ItemType>, value: &Value) -> Result<(), String> {
let fits = match kind {
VariableType::String => {
if let Value::String(text) = value
&& text.contains(['\n', '\r'])
{
return Err("a `string` is one line; declare the variable `text` for more".into());
}
value.is_string()
}
VariableType::Text => value.is_string(),
VariableType::Integer => value.is_i64() || value.is_u64(),
VariableType::Boolean => value.is_boolean(),
VariableType::Object => value.is_object(),
VariableType::List => {
let Value::Array(entries) = value else {
return Err(format!("expected a list, got {}", describe(value)));
};
let items = items.unwrap_or(ItemType::String);
if let Some((index, entry)) =
entries.iter().enumerate().find(|(_, entry)| match items {
ItemType::String => !entry.is_string(),
ItemType::Object => !entry.is_object(),
})
{
return Err(format!(
"entry {index} of the list is {}, and its items are {}s",
describe(entry),
items.as_str()
));
}
true
}
};
if fits {
Ok(())
} else {
Err(format!(
"expected {}, got {}",
article(kind),
describe(value)
))
}
}
fn article(kind: VariableType) -> String {
match kind {
VariableType::Integer | VariableType::Object => format!("an {kind}"),
_ => format!("a {kind}"),
}
}
fn describe(value: &Value) -> String {
match value {
Value::Null => "null".to_owned(),
Value::Bool(value) => format!("the boolean {value}"),
Value::Number(number) => format!("the number {number}"),
Value::String(text) => format!("the string {text:?}"),
Value::Array(_) => "a list".to_owned(),
Value::Object(_) => "a mapping".to_owned(),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChainField {
Type,
Items,
}
impl fmt::Display for ChainField {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Type => "type",
Self::Items => "items",
})
}
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum TemplateError {
#[error(
"template {name:?} was not found{}\nnext: {}",
referenced_from.as_ref().map(|from| format!(" (named by {from})")).unwrap_or_default(),
if searched.is_empty() {
"give the directory holding it as a search path, or register it by name.".to_owned()
} else {
format!("it was looked for in {}; add the directory holding it as a search path.", searched.join(", "))
}
)]
NotFound {
name: String,
referenced_from: Option<String>,
searched: Vec<String>,
},
#[error(
"template {name} could not be read: {message}\n\
next: name a readable file, or give it the permissions this process reads with."
)]
Unreadable {
name: String,
message: String,
},
#[error(
"search path {directory} cannot be searched: {message}\n\
next: give a directory that exists, or leave it out of the search path."
)]
SearchPath {
directory: String,
message: String,
},
#[error(
"template {file}: {}{message}\nnext: {}",
key.as_ref().map(|key| format!("front matter key `{key}` ")).unwrap_or_default(),
"correct that file; a template's front matter is described in the README under \"Task templates\"."
)]
Malformed {
file: String,
key: Option<String>,
message: String,
},
#[error(
"template variable {variable:?} is declared with {field} {nearer_value} in {nearer} and \
with {field} {farther_value} in {farther}; a redeclaration may change `description`, \
`default` and `required`, never `type` or `items`\n\
next: declare it with the same {field} in both files, or rename one of them."
)]
ChainConflict {
variable: String,
field: ChainField,
nearer: String,
nearer_value: &'static str,
farther: String,
farther_value: &'static str,
},
#[error(
"the answers document is refused: {message}\n\
next: write the answers as a YAML mapping from variable name to value."
)]
MalformedAnswers {
message: String,
},
#[error(
"{} no variable the template declares: {}\n\
next: remove {}, or declare {} in the template's front matter — `onetaskgraph template \
variables` lists what it declares.",
if names.len() == 1 { "an answer names" } else { "answers name" },
names.join(", "),
if names.len() == 1 { "that answer" } else { "those answers" },
if names.len() == 1 { "it" } else { "them" }
)]
UnknownAnswer {
names: Vec<String>,
},
#[error(
"the answer to {name:?} is not {expected}: {problem}\n\
next: give {name} a value of type {kind}."
)]
MistypedAnswer {
name: String,
kind: VariableType,
expected: String,
problem: String,
},
#[error(
"{} unanswered: {}\n\
next: answer {} with --var NAME=VALUE or in an answers file (--answers FILE), or run \
interactively to be asked.",
if names.len() == 1 { "a required variable is" } else { "required variables are" },
names.join(", "),
if names.len() == 1 { "it" } else { "each" }
)]
MissingRequired {
names: Vec<String>,
},
#[error(
"template {}{}: {message}\n\
next: {}",
file.as_deref().unwrap_or("?"),
line.map(|line| format!(" line {line}")).unwrap_or_default(),
match name {
Some(name) => format!(
"declare {name} in the front matter of a file in the chain, or set it in the \
template before it is used."
),
None => "correct the template at that line.".to_owned(),
}
)]
Render {
file: Option<String>,
line: Option<usize>,
name: Option<String>,
message: String,
},
}
impl TemplateError {
#[must_use]
pub fn kind(&self) -> &'static str {
match self {
Self::NotFound { .. } => "template-not-found",
Self::Unreadable { .. } => "template-unreadable",
Self::SearchPath { .. } => "template-search-path",
Self::Malformed { .. } => "template-malformed",
Self::ChainConflict { .. } => "template-chain-conflict",
Self::MalformedAnswers { .. } => "template-answers-malformed",
Self::UnknownAnswer { .. } => "template-unknown-answer",
Self::MistypedAnswer { .. } => "template-mistyped-answer",
Self::MissingRequired { .. } => "template-missing-required",
Self::Render { .. } => "template-render",
}
}
#[must_use]
pub fn refuses_answers(&self) -> bool {
matches!(
self,
Self::MalformedAnswers { .. }
| Self::UnknownAnswer { .. }
| Self::MistypedAnswer { .. }
| Self::MissingRequired { .. }
)
}
fn malformed(file: &str, key: Option<&str>, message: impl Into<String>) -> Self {
Self::Malformed {
file: file.to_owned(),
key: key.map(str::to_owned),
message: message.into(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct TemplateLoader {
directories: Vec<PathBuf>,
registered: Vec<(String, String)>,
}
impl TemplateLoader {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_directory(mut self, directory: impl Into<PathBuf>) -> Self {
self.directories.push(directory.into());
self
}
#[must_use]
pub fn with_template(mut self, name: impl Into<String>, source: impl Into<String>) -> Self {
self.registered.push((name.into(), source.into()));
self
}
pub fn load_path(&self, path: &Path) -> Result<Template, TemplateError> {
self.searchable()?;
let shown = path.display().to_string();
let bytes = std::fs::read(path).map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
TemplateError::NotFound {
name: shown.clone(),
referenced_from: None,
searched: Vec::new(),
}
} else {
TemplateError::Unreadable {
name: shown.clone(),
message: error.to_string(),
}
}
})?;
let source = String::from_utf8(bytes)
.map_err(|_| TemplateError::malformed(&shown, None, "it is not UTF-8 text"))?;
let name = path
.file_name()
.map_or_else(|| shown.clone(), |name| name.to_string_lossy().into_owned());
self.load_chain(name, source)
}
pub fn load_name(&self, name: &str) -> Result<Template, TemplateError> {
self.searchable()?;
let source = self.find(name)?.ok_or_else(|| self.not_found(name, None))?;
self.load_chain(name.to_owned(), source)
}
fn find(&self, name: &str) -> Result<Option<String>, TemplateError> {
let path = Path::new(name);
let spelled_within = !name.is_empty()
&& path
.components()
.all(|component| matches!(component, Component::Normal(_)));
if spelled_within {
for directory in &self.directories {
let candidate = directory.join(path);
if candidate.is_file() {
let bytes =
std::fs::read(&candidate).map_err(|error| TemplateError::Unreadable {
name: candidate.display().to_string(),
message: error.to_string(),
})?;
return String::from_utf8(bytes)
.map(Some)
.map_err(|_| TemplateError::malformed(name, None, "it is not UTF-8 text"));
}
}
}
Ok(self
.registered
.iter()
.find(|(registered, _)| registered == name)
.map(|(_, source)| source.clone()))
}
fn not_found(&self, name: &str, referenced_from: Option<&str>) -> TemplateError {
TemplateError::NotFound {
name: name.to_owned(),
referenced_from: referenced_from.map(str::to_owned),
searched: self
.directories
.iter()
.map(|directory| directory.display().to_string())
.collect(),
}
}
fn load_chain(&self, root: String, source: String) -> Result<Template, TemplateError> {
let resolutions = Resolutions::new();
let files = self.build(root.clone(), source, &resolutions)?;
let variables = merge(&files)?;
let Discovered { read, .. } = discover(&root, &files, &variables, self, &Answers::new());
let files = in_read_order(files, &read);
let variables = merge(&files)?;
Ok(Template::assemble(
root,
files,
variables,
resolutions,
self.clone(),
))
}
fn searchable(&self) -> Result<(), TemplateError> {
for directory in &self.directories {
let refused = |message: String| TemplateError::SearchPath {
directory: directory.display().to_string(),
message,
};
match std::fs::metadata(directory) {
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => return Err(refused("it is not a directory".to_owned())),
Err(error) => return Err(refused(error.to_string())),
}
}
Ok(())
}
fn build(
&self,
name: String,
source: String,
resolutions: &Resolutions,
) -> Result<Vec<ChainFile>, TemplateError> {
let mut files = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let mut pending = vec![(name, source)];
while let Some((name, source)) = pending.pop() {
if !seen.insert(name.clone()) {
continue;
}
let split = front_matter::split(&name, &source)?;
compile_check(&name, &split.body, split.offset_lines)?;
let mut names = Vec::new();
let mut children = Vec::new();
for named in scan::named(&split.body) {
let references = match named {
scan::Named::Literal(reference) => vec![reference],
scan::Named::Expression { ordinal, .. } => resolutions
.get(&NamingTag {
file: name.clone(),
ordinal,
})
.into_iter()
.flatten()
.map(|candidates| scan::Reference {
candidates: candidates.names().to_vec(),
optional: true,
})
.collect(),
};
for reference in references {
let mut resolved = false;
for candidate in &reference.candidates {
if seen.contains(candidate) || *candidate == name {
names.push(candidate.clone());
resolved = true;
break;
}
if let Some(source) = self.find(candidate)? {
names.push(candidate.clone());
children.push((candidate.clone(), source));
resolved = true;
break;
}
}
if !resolved && !reference.optional {
return Err(self.not_found(&reference.candidates.join(" or "), Some(&name)));
}
}
}
pending.extend(children.into_iter().rev());
files.push(ChainFile {
name,
source,
body: split.body,
offset_lines: split.offset_lines,
declarations: split.declarations,
names,
});
}
Ok(files)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct NamingTag {
file: String,
ordinal: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Candidates(Vec<String>);
impl Candidates {
fn of(value: &minijinja::Value) -> Option<Self> {
let names: Vec<String> = match value.as_str() {
Some(name) => vec![name.to_owned()],
None if value.kind() == minijinja::value::ValueKind::Seq => value
.try_iter()
.into_iter()
.flatten()
.filter_map(|item| item.as_str().map(str::to_owned))
.collect(),
None => Vec::new(),
};
(!names.is_empty()).then_some(Self(names))
}
fn names(&self) -> &[String] {
&self.0
}
}
type Resolutions = BTreeMap<NamingTag, Vec<Candidates>>;
fn hooked_bodies(files: &[ChainFile]) -> HashMap<String, String> {
files
.iter()
.enumerate()
.map(|(index, file)| (file.name.clone(), scan::hooked(&file.body, index)))
.collect()
}
fn record_names(
environment: &mut minijinja::Environment<'static>,
files: &[ChainFile],
) -> Arc<Mutex<Resolutions>> {
let names: Vec<String> = files.iter().map(|file| file.name.clone()).collect();
let recorded: Arc<Mutex<Resolutions>> = Arc::default();
let sink = Arc::clone(&recorded);
environment.add_function(
scan::HOOK,
move |file: usize, ordinal: usize, value: minijinja::Value| {
if let Some(name) = names.get(file)
&& let Some(candidates) = Candidates::of(&value)
{
let mut recorded = sink.lock().unwrap_or_else(PoisonError::into_inner);
let found = recorded
.entry(NamingTag {
file: name.clone(),
ordinal,
})
.or_default();
if !found.contains(&candidates) {
found.push(candidates);
}
}
value
},
);
recorded
}
fn compile_check(name: &str, body: &str, offset_lines: usize) -> Result<(), TemplateError> {
let environment = environment();
environment
.template_from_named_str(name, body)
.map(|_| ())
.map_err(|error| TemplateError::Malformed {
file: name.to_owned(),
key: None,
message: format!(
"its body does not parse{}: {}",
error
.line()
.map(|line| format!(" at line {}", line + offset_lines))
.unwrap_or_default(),
error.detail().unwrap_or("a syntax error")
),
})
}
#[derive(Debug, Clone)]
struct ChainFile {
name: String,
source: String,
body: String,
offset_lines: usize,
declarations: Vec<TemplateVariable>,
names: Vec<String>,
}
fn merge(files: &[ChainFile]) -> Result<Vec<TemplateVariable>, TemplateError> {
let index_of: HashMap<&str, usize> = files
.iter()
.enumerate()
.map(|(index, file)| (file.name.as_str(), index))
.collect();
let mut depth = vec![usize::MAX; files.len()];
if !files.is_empty() {
depth[0] = 0;
}
let mut queue = VecDeque::from([0usize]);
while let Some(at) = queue.pop_front() {
for child in &files[at].names {
if let Some(&child) = index_of.get(child.as_str())
&& depth[child] == usize::MAX
{
depth[child] = depth[at] + 1;
queue.push_back(child);
}
}
}
let mut nearness: Vec<usize> = (0..files.len()).collect();
nearness.sort_by_key(|&index| (depth[index], index));
let mut merged: BTreeMap<&str, TemplateVariable> = BTreeMap::new();
for &index in &nearness {
let file = &files[index];
for declaration in &file.declarations {
match merged.get(declaration.name.as_str()) {
None => {
merged.insert(&declaration.name, declaration.clone());
}
Some(nearer) => {
if nearer.kind != declaration.kind {
return Err(conflict(
nearer,
ChainField::Type,
nearer.kind.as_str(),
&file.name,
declaration.kind.as_str(),
&declaration.name,
));
}
if nearer.items != declaration.items {
let spell =
|items: Option<ItemType>| items.map_or("none", ItemType::as_str);
return Err(conflict(
nearer,
ChainField::Items,
spell(nearer.items),
&file.name,
spell(declaration.items),
&declaration.name,
));
}
}
}
}
}
let mut ordered = Vec::with_capacity(merged.len());
for file in files {
for declaration in &file.declarations {
if let Some(variable) = merged.remove(declaration.name.as_str()) {
ordered.push(variable);
}
}
}
Ok(ordered)
}
fn conflict(
nearer: &TemplateVariable,
field: ChainField,
nearer_value: &'static str,
farther: &str,
farther_value: &'static str,
variable: &str,
) -> TemplateError {
TemplateError::ChainConflict {
variable: variable.to_owned(),
field,
nearer: nearer.declared_in.clone(),
nearer_value,
farther: farther.to_owned(),
farther_value,
}
}
fn digest<'a>(files: impl IntoIterator<Item = (&'a str, &'a str)>) -> String {
let mut hasher = Sha256::new();
for (name, source) in files {
hasher.update(name.as_bytes());
hasher.update([0]);
hasher.update(source.as_bytes());
hasher.update([0]);
}
let hash = hasher.finalize();
let mut rendered = String::with_capacity(7 + hash.len() * 2);
rendered.push_str("sha256:");
for byte in hash {
rendered.push_str(&format!("{byte:02x}"));
}
rendered
}
fn environment() -> minijinja::Environment<'static> {
let mut environment = minijinja::Environment::new();
environment.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
environment.set_auto_escape_callback(|_| minijinja::AutoEscape::None);
environment.set_keep_trailing_newline(true);
environment.set_trim_blocks(true);
environment.set_lstrip_blocks(true);
environment
}
#[derive(Debug, Clone)]
pub struct Template {
name: String,
files: Vec<ChainFile>,
variables: Vec<TemplateVariable>,
resolutions: Resolutions,
digest: String,
loader: TemplateLoader,
}
#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
pub struct TemplateVariables {
pub template: String,
pub digest: String,
pub variables: Vec<TemplateVariable>,
}
#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
pub struct RenderedTemplate {
pub body: String,
pub digest: String,
pub answers: BTreeMap<String, Value>,
}
impl Template {
fn assemble(
name: String,
files: Vec<ChainFile>,
variables: Vec<TemplateVariable>,
resolutions: Resolutions,
loader: TemplateLoader,
) -> Self {
let digest = digest(
files
.iter()
.map(|file| (file.name.as_str(), file.source.as_str())),
);
Self {
name,
files,
variables,
resolutions,
digest,
loader,
}
}
pub fn expand(&self, answers: &Answers) -> Result<Self, TemplateError> {
let mut expanded = self.clone();
let mut named = vec![expanded.resolutions.clone()];
loop {
let Discovered {
resolutions,
read,
failure,
} = discover(
&expanded.name,
&expanded.files,
&expanded.variables,
&expanded.loader,
answers,
);
if let Some(error) = failure {
return Err(error);
}
if resolutions == expanded.resolutions {
let files = in_read_order(expanded.files, &read);
let variables = merge(&files)?;
return Ok(Self::assemble(
self.name.clone(),
files,
variables,
resolutions,
self.loader.clone(),
));
}
if named.contains(&resolutions) {
return Err(TemplateError::malformed(
&self.name,
None,
"what its expressions name never settles: each template they name gives a \
default naming another, round a cycle; answer the variable that decides it",
));
}
named.push(resolutions.clone());
let files = expanded.rebuild(&resolutions)?;
let variables = merge(&files)?;
expanded = Self::assemble(
self.name.clone(),
files,
variables,
resolutions,
self.loader.clone(),
);
}
}
fn rebuild(&self, resolutions: &Resolutions) -> Result<Vec<ChainFile>, TemplateError> {
let root = self
.files
.first()
.map_or_else(String::new, |file| file.source.clone());
self.loader.build(self.name.clone(), root, resolutions)
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn digest(&self) -> &str {
&self.digest
}
#[must_use]
pub fn variables(&self) -> &[TemplateVariable] {
&self.variables
}
pub fn chain(&self) -> impl Iterator<Item = &str> {
self.files.iter().map(|file| file.name.as_str())
}
#[must_use]
pub fn describe(&self) -> TemplateVariables {
TemplateVariables {
template: self.name.clone(),
digest: self.digest.clone(),
variables: self.variables.clone(),
}
}
pub fn unanswered(&self, answers: &Answers) -> Result<Vec<TemplateVariable>, TemplateError> {
let expanded = self.expand(answers)?;
let typed = expanded.typed(answers)?;
Ok(expanded
.variables
.into_iter()
.filter(|variable| !typed.contains_key(&variable.name))
.collect())
}
pub fn resolve(&self, answers: &Answers) -> Result<BTreeMap<String, Value>, TemplateError> {
self.expand(answers)?.resolve_here(answers)
}
fn resolve_here(&self, answers: &Answers) -> Result<BTreeMap<String, Value>, TemplateError> {
let mut typed = self.typed(answers)?;
let missing: Vec<String> = self
.variables
.iter()
.filter(|variable| {
variable.required
&& variable.default.is_none()
&& !typed.contains_key(&variable.name)
})
.map(|variable| variable.name.clone())
.collect();
if !missing.is_empty() {
return Err(TemplateError::MissingRequired { names: missing });
}
for variable in &self.variables {
typed
.entry(variable.name.clone())
.or_insert_with(|| variable.default.clone().unwrap_or(Value::Null));
}
Ok(typed)
}
pub fn render(&self, answers: &Answers) -> Result<RenderedTemplate, TemplateError> {
self.expand(answers)?.render_here(answers)
}
fn render_here(&self, answers: &Answers) -> Result<RenderedTemplate, TemplateError> {
let resolved = self.resolve_here(answers)?;
let offsets: HashMap<String, usize> = self
.files
.iter()
.map(|file| (file.name.clone(), file.offset_lines))
.collect();
let bodies: HashMap<String, String> = self
.files
.iter()
.map(|file| (file.name.clone(), file.body.clone()))
.collect();
let mut environment = environment();
environment.set_loader(move |name| Ok(bodies.get(name).cloned()));
let rendered = environment
.get_template(&self.name)
.and_then(|template| template.render(&resolved))
.map_err(|error| render_error(&error, &offsets))?;
Ok(RenderedTemplate {
body: rendered,
digest: self.digest.clone(),
answers: resolved,
})
}
fn typed(&self, answers: &Answers) -> Result<BTreeMap<String, Value>, TemplateError> {
let declared: HashMap<&str, &TemplateVariable> = self
.variables
.iter()
.map(|variable| (variable.name.as_str(), variable))
.collect();
let unknown: Vec<String> = answers
.names()
.filter(|name| !declared.contains_key(name))
.map(str::to_owned)
.collect();
if !unknown.is_empty() {
return Err(TemplateError::UnknownAnswer { names: unknown });
}
let mut typed = BTreeMap::new();
for variable in &self.variables {
let value = match answers.given(&variable.name) {
None => continue,
Some(Given::Value(value)) => variable.check(value).map(|()| value.clone()),
Some(Given::Text(text)) => variable.parse(text),
};
let value = value.map_err(|problem| TemplateError::MistypedAnswer {
name: variable.name.clone(),
kind: variable.kind,
expected: article(variable.kind),
problem,
})?;
typed.insert(variable.name.clone(), value);
}
Ok(typed)
}
}
fn discover(
root: &str,
files: &[ChainFile],
variables: &[TemplateVariable],
loader: &TemplateLoader,
answers: &Answers,
) -> Discovered {
let mut context = BTreeMap::new();
for variable in variables {
let answered = match answers.given(&variable.name) {
Some(Given::Value(value)) => variable.check(value).ok().map(|()| value.clone()),
Some(Given::Text(text)) => variable.parse(text).ok(),
None => None,
};
if let Some(value) = answered.or_else(|| variable.default.clone()) {
context.insert(variable.name.clone(), value);
} else if !variable.required {
context.insert(variable.name.clone(), Value::Null);
}
}
let bodies = hooked_bodies(files);
let loader = loader.clone();
let failed: Arc<Mutex<Option<TemplateError>>> = Arc::default();
let failure = Arc::clone(&failed);
let read: Arc<Mutex<Vec<String>>> = Arc::default();
let reading = Arc::clone(&read);
let mut environment = environment();
environment.set_undefined_behavior(minijinja::UndefinedBehavior::Lenient);
let recorded = record_names(&mut environment, files);
environment.set_loader(move |name| {
let mut reading = reading.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(body) = bodies.get(name) {
if !reading.iter().any(|read| read == name) {
reading.push(name.to_owned());
}
return Ok(Some(body.clone()));
}
let source = match loader.find(name) {
Ok(Some(source)) => source,
Ok(None) => return Ok(None),
Err(error) => {
failure
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get_or_insert(error);
return Ok(None);
}
};
Ok(Some(
front_matter::split(name, &source)
.map(|split| split.body)
.unwrap_or_default(),
))
});
let _ = environment
.get_template(root)
.and_then(|template| template.render(&context));
let failure = failed.lock().unwrap_or_else(PoisonError::into_inner).take();
let resolutions = recorded
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone();
let read = read.lock().unwrap_or_else(PoisonError::into_inner).clone();
Discovered {
resolutions,
read,
failure,
}
}
struct Discovered {
resolutions: Resolutions,
read: Vec<String>,
failure: Option<TemplateError>,
}
fn in_read_order(files: Vec<ChainFile>, read: &[String]) -> Vec<ChainFile> {
let mut files: Vec<(usize, ChainFile)> = files
.into_iter()
.enumerate()
.map(|(index, file)| {
let at = if index == 0 {
0
} else {
read.iter()
.position(|name| *name == file.name)
.map_or(read.len() + index, |at| at + 1)
};
(at, file)
})
.collect();
files.sort_by_key(|(at, _)| *at);
files.into_iter().map(|(_, file)| file).collect()
}
fn render_error(error: &minijinja::Error, offsets: &HashMap<String, usize>) -> TemplateError {
let mut error = error;
while let Some(inner) = std::error::Error::source(error)
.and_then(|source| source.downcast_ref::<minijinja::Error>())
{
error = inner;
}
let file = error.name().map(str::to_owned);
let offset = file
.as_deref()
.and_then(|file| offsets.get(file))
.copied()
.unwrap_or(0);
let line = error.line().map(|line| line + offset);
if error.kind() == minijinja::ErrorKind::UndefinedError {
let name = error
.template_source()
.zip(error.range())
.and_then(|(source, range)| source.get(range))
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_owned);
return TemplateError::Render {
message: match &name {
Some(name) => format!(
"{name} is undefined: it is neither a declared variable nor set by the template"
),
None => "a value there is undefined".to_owned(),
},
file,
line,
name,
};
}
TemplateError::Render {
file,
line,
name: None,
message: error
.detail()
.map_or_else(|| error.kind().to_string(), str::to_owned),
}
}