use std::collections::HashMap;
use std::iter::Peekable;
use std::str::Chars;
use indexmap::IndexMap;
use crate::error::{ManifestError, Result};
pub const MAX_INTERPOLATION_DEPTH: usize = 10;
#[derive(Debug, Default, Clone)]
pub struct InterpolationContext {
env: HashMap<String, String>,
resources: HashMap<String, IndexMap<String, String>>,
}
impl InterpolationContext {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn from_env() -> Self {
Self {
env: std::env::vars().collect(),
resources: HashMap::new(),
}
}
#[must_use]
pub fn with_env<I>(mut self, vars: I) -> Self
where
I: IntoIterator<Item = (String, String)>,
{
self.env.extend(vars);
self
}
#[must_use]
pub fn with_resource(
mut self,
name: impl Into<String>,
properties: IndexMap<String, String>,
) -> Self {
self.resources.insert(name.into(), properties);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Reference {
Resource {
name: String,
property: String,
},
Env {
name: String,
default: Option<String>,
},
}
impl Reference {
#[must_use]
pub fn resource_name(self) -> Option<String> {
match self {
Self::Resource { name, .. } => Some(name),
Self::Env { .. } => None,
}
}
}
pub struct Interpolator<'ctx> {
ctx: &'ctx InterpolationContext,
}
impl<'ctx> Interpolator<'ctx> {
#[must_use]
pub fn new(ctx: &'ctx InterpolationContext) -> Self {
Self { ctx }
}
pub fn resolve(&self, input: &str) -> Result<String> {
self.resolve_at(input, 1)
}
fn resolve_at(&self, input: &str, depth: usize) -> Result<String> {
let mut output = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
output.push(c);
continue;
}
if chars.peek() != Some(&'{') {
output.push('$');
continue;
}
chars.next();
if chars.peek() == Some(&'{') {
chars.next();
let body = consume_until_double_close(&mut chars, input)?;
output.push('$');
output.push('{');
output.push_str(&body);
output.push('}');
continue;
}
let body = consume_balanced_body(&mut chars, input, depth)?;
let reference = parse_reference(&body)?;
let resolved = match &reference {
Reference::Env {
name,
default: Some(raw_default),
} => {
if let Some(value) = self.ctx.env.get(name).filter(|v| !v.is_empty()) {
value.clone()
} else {
self.resolve_at(raw_default, depth + 1)?
}
}
_ => self.lookup(&reference)?,
};
output.push_str(&resolved);
}
Ok(output)
}
pub fn scan(&self, input: &str) -> Result<Vec<Reference>> {
let mut refs = Vec::new();
scan_at(input, 1, &mut refs)?;
Ok(refs)
}
fn lookup(&self, reference: &Reference) -> Result<String> {
match reference {
Reference::Resource { name, property } => {
let resource = self
.ctx
.resources
.get(name)
.ok_or_else(|| ManifestError::UnknownResource(name.clone()))?;
let value =
resource
.get(property)
.ok_or_else(|| ManifestError::UnknownProperty {
resource: name.clone(),
property: property.clone(),
kind: "<runtime>",
})?;
Ok(value.clone())
}
Reference::Env { name, default } => {
if let Some(value) = self.ctx.env.get(name).filter(|v| !v.is_empty()) {
Ok(value.clone())
} else if let Some(fallback) = default {
Ok(fallback.clone())
} else {
Err(ManifestError::EnvUnset(name.clone()))
}
}
}
}
}
fn consume_balanced_body(
chars: &mut Peekable<Chars<'_>>,
full: &str,
depth: usize,
) -> Result<String> {
let mut body = String::new();
let mut nesting = 1usize;
while let Some(c) = chars.next() {
if c == '$' && chars.peek() == Some(&'{') {
chars.next();
nesting += 1;
if depth + (nesting - 1) > MAX_INTERPOLATION_DEPTH {
return Err(ManifestError::InterpolationTooDeep {
limit: MAX_INTERPOLATION_DEPTH,
context: full.to_owned(),
});
}
body.push('$');
body.push('{');
} else if c == '}' {
nesting -= 1;
if nesting == 0 {
return Ok(body);
}
body.push('}');
} else {
body.push(c);
}
}
Err(ManifestError::InvalidInterpolation(format!(
"unterminated `${{` in `{full}`"
)))
}
fn consume_until_double_close(chars: &mut Peekable<Chars<'_>>, full: &str) -> Result<String> {
let mut body = String::new();
while let Some(c) = chars.next() {
if c == '}' && chars.peek() == Some(&'}') {
chars.next();
return Ok(body);
}
body.push(c);
}
Err(ManifestError::InvalidInterpolation(format!(
"unterminated `${{{{` in `{full}`"
)))
}
fn scan_at(input: &str, depth: usize, out: &mut Vec<Reference>) -> Result<()> {
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' || chars.peek() != Some(&'{') {
continue;
}
chars.next();
if chars.peek() == Some(&'{') {
chars.next();
consume_until_double_close(&mut chars, input)?;
continue;
}
let body = consume_balanced_body(&mut chars, input, depth)?;
let reference = parse_reference(&body)?;
if let Reference::Env {
default: Some(raw_default),
..
} = &reference
{
scan_at(raw_default, depth + 1, out)?;
}
out.push(reference);
}
Ok(())
}
fn parse_reference(body: &str) -> Result<Reference> {
if let Some(rest) = body.strip_prefix("resources.") {
let (name, property) = rest.split_once('.').ok_or_else(|| {
ManifestError::InvalidInterpolation(format!(
"resource reference missing property in `${{{body}}}`"
))
})?;
if name.is_empty() || property.is_empty() {
return Err(ManifestError::InvalidInterpolation(format!(
"empty resource reference in `${{{body}}}`"
)));
}
if name.contains("${") || property.contains("${") {
return Err(ManifestError::InvalidInterpolation(format!(
"nested interpolation is only allowed in an env default, not in `${{{body}}}`"
)));
}
Ok(Reference::Resource {
name: name.to_owned(),
property: property.to_owned(),
})
} else if let Some(rest) = body.strip_prefix("env.") {
if let Some((name, default)) = rest.split_once(":-") {
if name.contains("${") {
return Err(ManifestError::InvalidInterpolation(format!(
"nested interpolation is only allowed in an env default, not in the variable name of `${{{body}}}`"
)));
}
Ok(Reference::Env {
name: name.to_owned(),
default: Some(default.to_owned()),
})
} else {
if rest.contains("${") {
return Err(ManifestError::InvalidInterpolation(format!(
"nested interpolation is only allowed in an env default, not in `${{{body}}}`"
)));
}
Ok(Reference::Env {
name: rest.to_owned(),
default: None,
})
}
} else {
Err(ManifestError::InvalidInterpolation(format!(
"unknown reference scheme in `${{{body}}}`"
)))
}
}