use alloc::collections::BTreeMap;
use core::str;
use std::collections::HashSet;
use derive_more::{AsRef, Display, Into};
use minijinja::{AutoEscape, Environment, UndefinedBehavior, Value};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::errors::{ParseError, ProfileError, Result, VarError};
use crate::gate::Pattern;
use crate::path::RelPath;
use crate::profile::{MANIFEST, PAYLOAD};
use crate::resolve::{Layer, LayerFile};
use crate::target::Target;
use crate::tree::Tree;
pub(crate) const RESERVED: &str = "devset";
#[derive(
Clone,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
AsRef,
Display,
derive_more::Debug,
Into,
Serialize,
Deserialize,
JsonSchema,
)]
#[as_ref(str)]
#[debug("{_0:?}")]
#[into(String)]
#[serde(try_from = "String", into = "String")]
#[schemars(inline)]
pub struct VarName(Box<str>);
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct VarSpec {
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub default: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Question {
pub name: VarName,
pub prompt: String,
pub default: Option<String>,
}
impl VarName {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for VarName {
type Error = VarError;
fn try_from(name: String) -> Result<Self, VarError> {
let mut chars = name.chars();
let valid = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
match (valid, name.as_str()) {
(false, _) => Err(VarError::Name { name }),
(true, RESERVED) => Err(VarError::Reserved),
(true, _) => Ok(Self(name.into_boxed_str())),
}
}
}
#[derive(Default)]
struct Declared<'a> {
prompt: Option<&'a str>,
default: Offer<'a>,
}
#[derive(Clone, Copy, Default, PartialEq, Eq)]
enum Offer<'a> {
#[default]
None,
Agreed(&'a str),
Disputed,
}
pub(crate) fn answers(layers: &[Layer], target: &Target) -> Result<BTreeMap<VarName, String>> {
let mut declared: BTreeMap<&VarName, Declared<'_>> = BTreeMap::new();
for (name, spec) in layers.iter().flat_map(Layer::vars) {
let entry = declared.entry(name).or_default();
entry.prompt = entry.prompt.or(spec.prompt.as_deref());
entry.default = match (entry.default, spec.default.as_deref()) {
(offer, None) => offer,
(Offer::None, Some(default)) => Offer::Agreed(default),
(Offer::Agreed(first), Some(default)) if first == default => Offer::Agreed(first),
(Offer::Agreed(_) | Offer::Disputed, Some(_)) => Offer::Disputed,
};
}
if let Some(name) = target.given().find(|name| !declared.contains_key(name)) {
let declared = declared.keys().map(|&name| name.clone()).collect();
return Err(VarError::Unknown { name: name.clone(), declared }.into());
}
let mut answers = BTreeMap::new();
let mut questions = Vec::new();
for (name, Declared { prompt, default }) in declared {
match target.answers().get(name) {
Some(answer) => {
answers.insert(name.clone(), answer.clone());
},
None => questions.push(Question {
name: name.clone(),
prompt: prompt.map_or_else(|| name.to_string(), str::to_owned),
default: match default {
Offer::Agreed(default) => Some(default.to_owned()),
Offer::None | Offer::Disputed => None,
},
}),
}
}
if questions.is_empty() { Ok(answers) } else { Err(VarError::Unanswered { questions }.into()) }
}
#[derive(Debug, Serialize)]
pub(crate) struct Context {
target: String,
profiles: Vec<String>,
layers: Vec<LayerContext>,
}
#[derive(Debug, Serialize)]
struct LayerContext {
profile: String,
source: String,
features: Vec<String>,
configured: bool,
}
#[derive(Serialize)]
struct Devset<'a> {
target: &'a str,
profiles: &'a [String],
layers: &'a [LayerContext],
features: &'a [String],
}
impl Context {
pub(crate) fn of(target: &Target, layers: &[Layer]) -> Self {
let layers: Vec<LayerContext> = layers
.iter()
.map(|layer| LayerContext {
profile: layer.name().to_string(),
source: layer
.source_name()
.map_or_else(|| layer.source().to_string(), ToString::to_string),
features: layer.features().keys().map(ToString::to_string).collect(),
configured: layer.configured(),
})
.collect();
Self {
target: target.root().file_name().unwrap_or_default().to_owned(),
profiles: layers.iter().map(|layer| layer.profile.clone()).collect(),
layers,
}
}
fn vars(&self, layer: &Layer, answers: &BTreeMap<VarName, String>) -> Value {
let empty = Vec::new();
let features = self
.layers
.iter()
.find(|l| *layer.name() == *l.profile)
.map_or(&empty, |l| &l.features);
let devset = Devset {
target: &self.target,
profiles: &self.profiles,
layers: &self.layers,
features,
};
let mut vars: BTreeMap<&str, Value> = answers
.iter()
.map(|(name, answer)| (name.as_str(), Value::from(answer.as_str())))
.collect();
vars.insert(RESERVED, Value::from_serialize(&devset));
Value::from_serialize(&vars)
}
}
struct Renderer<'a> {
env: Environment<'static>,
vars: Value,
known: HashSet<String>,
declared: &'a BTreeMap<VarName, String>,
}
impl<'a> Renderer<'a> {
fn new(layer: &Layer, answers: &'a BTreeMap<VarName, String>, context: &Context) -> Self {
let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict);
env.set_keep_trailing_newline(true);
env.set_auto_escape_callback(|_| AutoEscape::None);
let mut known: HashSet<String> = env.globals().map(|(name, _)| name.to_owned()).collect();
known.extend(answers.keys().map(ToString::to_string));
known.insert(RESERVED.to_owned());
Self { env, vars: context.vars(layer, answers), known, declared: answers }
}
fn render(&self, name: &RelPath, source: &str, file: &str) -> Result<String> {
let failed = |e: &minijinja::Error| {
let message = e
.detail()
.map_or_else(|| e.kind().to_string(), |detail| format!("{}: {detail}", e.kind()));
ParseError { file: file.to_owned(), text: source.to_owned(), span: e.range(), message }
};
let template =
self.env.template_from_named_str(name.as_str(), source).map_err(|e| failed(&e))?;
let undeclared = template
.undeclared_variables(false)
.into_iter()
.filter(|name| !self.known.contains(name))
.min();
if let Some(undeclared) = undeclared {
let declared = self.declared.keys().cloned().collect();
return Err(
VarError::Undeclared { path: name.clone(), name: undeclared, declared }.into()
);
}
Ok(template.render(&self.vars).map_err(|e| failed(&e))?)
}
fn text(&self, written: &str, file: &str) -> Result<String> {
if !written.contains('{') {
return Ok(written.to_owned());
}
let name = RelPath::new(MANIFEST)?;
self.render(&name, written, file)
}
}
pub(crate) fn render_paths(
layers: &mut [Layer], answers: &BTreeMap<VarName, String>, context: &Context,
) -> Result<()> {
for layer in layers {
let (specs, scaffolds) = layer.take_specs();
let templates = Renderer::new(layer, answers, context);
let file = layer.label(MANIFEST);
let pattern = |written: &str| -> Result<Pattern> {
let rendered = templates.text(written, &file)?;
Pattern::parse(&rendered).map_err(|reason| {
ProfileError::Pattern { profile: layer.name().clone(), pattern: rendered, reason }
.into()
})
};
let mut files: BTreeMap<RelPath, LayerFile> = BTreeMap::new();
for (written, spec) in specs {
let rendered = templates.text(written.as_str(), &file)?;
let path = RelPath::new(&rendered).map_err(|e| ProfileError::Manifest {
file: file.clone(),
message: format!("[files.\"{written}\"] is {rendered:?} once answered, which is not a path in the target: {e}"),
})?;
let exists =
spec.when.exists.iter().map(|each| pattern(each)).collect::<Result<_>>()?;
let entry = LayerFile { written: written.clone(), spec, exists, gated: None };
if let Some(first) = files.insert(path.clone(), entry) {
let (profile, first) = (layer.name().clone(), first.written);
return Err(ProfileError::SamePath { profile, path, first, second: written }.into());
}
}
let scaffolds = scaffolds
.into_iter()
.map(|(name, spec)| {
Ok((name, spec.unless.0.iter().map(|each| pattern(each)).collect::<Result<_>>()?))
})
.collect::<Result<_>>()?;
layer.set_files(files, scaffolds);
}
Ok(())
}
pub(crate) fn render(
layers: &mut [Layer], answers: &BTreeMap<VarName, String>, context: &Context,
) -> Result<()> {
for layer in layers {
let templates = Renderer::new(layer, answers, context);
let (mut payload, mut starters) = (Tree::default(), Tree::default());
let bytes_of = |written: &RelPath, template: bool| -> Result<Vec<u8>> {
let bytes = layer.written().get(written).unwrap_or_default();
if !template {
return Ok(bytes.to_vec());
}
let source = str::from_utf8(bytes).map_err(|e| VarError::Template {
path: written.clone(),
reason: format!("it is not UTF-8: {e}"),
})?;
let file = layer.label(&format!("{PAYLOAD}/{written}"));
Ok(templates.render(written, source, &file)?.into_bytes())
};
for (path, file) in layer.files().iter().filter(|(_, file)| file.gated.is_none()) {
payload.put(path.clone(), &bytes_of(&file.written, file.spec.template)?);
if let Some(starter) = &file.spec.starter {
starters.put(path.clone(), &bytes_of(starter, file.spec.template)?);
}
}
layer.set_payload(payload, starters);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::VarName;
#[test]
fn names_are_identifiers() {
for name in ["author", "_x", "target_cpu2"] {
assert!(VarName::try_from(name.to_owned()).is_ok(), "{name} is valid");
}
for name in ["", "2x", "a-b", "a.b", "é", "devset"] {
assert!(VarName::try_from(name.to_owned()).is_err(), "{name:?} is invalid");
}
}
}