use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use eros::Context;
use serde::Deserialize;
use tera::{Kwargs, State, Tera};
use crate::user_error::user_error;
#[derive(Debug, Clone)]
pub struct Script {
pub interpreter: String,
pub body: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ScriptArgs {
name: String,
#[serde(default)]
template: bool,
}
struct Expansion {
marker: String,
heredoc: String,
}
fn script_tera() -> Tera {
let mut tera = Tera::default();
tera.autoescape_on(Vec::<&str>::new());
tera
}
fn prepare_script(
kwargs: Kwargs,
scripts: &HashMap<String, Script>,
context: &tera::Context,
) -> eros::Result<String> {
let args: ScriptArgs = kwargs.deserialize().map_err(|error| {
user_error("script() requires a string `name` and accepts an optional boolean `template` argument.")
.context(error.to_string())
})?;
let script = scripts.get(&args.name).ok_or_else(|| user_error(format!(
"Script '{}' was not found in this module. Declare a named `bash` or `sh` fenced block.", args.name
)))?;
let mut body = script.body.clone();
if args.template {
body = script_tera()
.render_str(&body, context, false)
.with_user_context(|| {
format!(
"Could not render script '{}'. Check its Tera syntax and module arguments.",
args.name
)
})?;
}
if body.contains('\0') {
return Err(user_error(format!(
"Script '{}' contains a NUL byte.",
args.name
)));
}
let mut index = 0;
let delimiter = loop {
let candidate = format!("YARD_SCRIPT_{index}");
if !body.lines().any(|line| line == candidate) {
break candidate;
}
index += 1;
};
if !body.ends_with('\n') {
body.push('\n');
}
Ok(format!(
"<<'{delimiter}' {}\n{body}{delimiter}",
script.interpreter
))
}
#[derive(Default)]
pub struct OutputDirectives {
values: HashMap<String, String>,
uses_scripts: bool,
}
impl OutputDirectives {
fn extract(&mut self, fragment: &str) -> eros::Result<String> {
let mut header = true;
let mut content = String::new();
for line in fragment.split_inclusive('\n') {
let trimmed = line.trim();
if header {
if let Some(comment) = trimmed.strip_prefix('#') {
if let Some((key, value)) = comment.split_once('=') {
let key = key.trim().to_ascii_lowercase();
if matches!(key.as_str(), "syntax" | "escape" | "check") {
let value = value.trim();
if value.is_empty() {
return Err(user_error(format!(
"Dockerfile directive '# {key}=' has no value."
)));
}
if let Some(previous) = self.values.get(&key)
&& previous != value
{
return Err(user_error(format!(
"Conflicting Dockerfile '{key}' directives: '{previous}' and '{value}'. Use one value per output."
)));
}
self.values.insert(key, value.to_owned());
continue;
}
}
} else if !trimmed.is_empty() {
header = false;
}
}
content.push_str(line);
}
Ok(content)
}
pub fn prepend(mut self, content: String) -> String {
if self.uses_scripts {
self.values
.entry("syntax".to_owned())
.or_insert_with(|| "docker/dockerfile:1".to_owned());
}
let mut header = String::new();
for key in ["syntax", "escape", "check"] {
if let Some(value) = self.values.get(key) {
header.push_str(&format!("# {key}={value}\n"));
}
}
if header.is_empty() {
content
} else {
format!("{header}\n{content}")
}
}
}
pub fn render(
template: &str,
scripts: &HashMap<String, Script>,
context: &tera::Context,
directives: &mut OutputDirectives,
) -> eros::Result<String> {
if template.contains('\0') {
return Err(user_error("A Containerfile template contains a NUL byte."));
}
let mut tera = script_tera();
let scripts = scripts.clone();
let script_context = context.clone();
let expansions = Arc::new(Mutex::new(Vec::<Expansion>::new()));
let script_error = Arc::new(Mutex::new(None));
let captured_expansions = Arc::clone(&expansions);
let captured_error = Arc::clone(&script_error);
tera.register_function(
"script",
move |kwargs: Kwargs, _: &State| -> tera::TeraResult<String> {
match prepare_script(kwargs, &scripts, &script_context) {
Ok(heredoc) => {
let mut expansions = captured_expansions.lock().unwrap();
let marker = format!("\0YARD_SCRIPT_{}\0", expansions.len());
expansions.push(Expansion {
marker: marker.clone(),
heredoc,
});
Ok(marker)
}
Err(error) => {
*captured_error.lock().unwrap() = Some(error);
Err(tera::Error::message("Could not expand script()."))
}
}
},
);
let rendered = tera.render_str(template, context, false);
if let Some(error) = script_error.lock().unwrap().take() {
return Err(error);
}
let rendered = rendered.user_context(
"Could not render a module template. Check its template syntax and supplied arguments.",
)?;
let rendered = directives.extract(&rendered)?;
let expansions = expansions.lock().unwrap();
let mut content = String::new();
for line in rendered.split_inclusive('\n') {
if let Some(expansion) = expansions
.iter()
.find(|expansion| line.contains(&expansion.marker))
{
let (prefix, suffix) = line.split_once(&expansion.marker).unwrap();
let mut words = prefix.split_whitespace();
if !words
.next()
.is_some_and(|word| word.eq_ignore_ascii_case("RUN"))
|| !words.all(|word| word.starts_with("--"))
|| !suffix.trim().is_empty()
{
return Err(user_error(
"Place script() after `RUN` and optional RUN flags, with no text after it.",
));
}
content.push_str(prefix.trim());
content.push(' ');
content.push_str(&expansion.heredoc);
content.push('\n');
directives.uses_scripts = true;
} else {
content.push_str(line);
}
}
if content.contains('\0') {
return Err(user_error(
"A rendered Containerfile contains a NUL byte or an altered script() reference.",
));
}
Ok(content.trim().to_owned())
}