Skip to content

Commit

Permalink
feat(eval): add Evaluate::evaluate_in_place (#292)
Browse files Browse the repository at this point in the history
Closes #184.

Key difference to the existing `Evaluate::evaluate` method:

- It mutably borrows the value and tries to evaluate all nested expressions in-place.
- It returns all errors that it encounters, not just the first one.

This allows for partial expression evaluation, e.g. to support use cases where a HCL document contains variable references to other parts within the same document that themselves contain expressions that are not evaluated yet.

In this case one would:

1. Partially evaluate the document via `evaluate_in_place`.
2. Update the `Context` with newly discovered variables.
3. Repeat 1. and 2. until there are no more errors left.
  • Loading branch information
martinohmann committed Sep 17, 2023
1 parent f2fa40a commit ccc000f
Show file tree
Hide file tree
Showing 6 changed files with 508 additions and 6 deletions.
4 changes: 4 additions & 0 deletions crates/hcl-rs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,7 @@ vecmap-rs = { version = "0.1.11", features = ["serde"] }
indoc = "2.0"
pretty_assertions = "1.4.0"
serde_json = { version = "1.0.105", features = ["preserve_order"] }

[[example]]
name = "in-place-expr-evaluation"
test = true
134 changes: 134 additions & 0 deletions crates/hcl-rs/examples/in-place-expr-evaluation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
use hcl::eval::{Context, Evaluate};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let Some(filename) = std::env::args().into_iter().skip(1).next() else {
eprintln!("filename argument required");
std::process::exit(1);
};

let input = std::fs::read_to_string(filename)?;
let mut body = hcl::parse(&input)?;
let ctx = Context::new();

// This will try to evaluate all expressions in `body` and updates it in-place, returning all
// errors that occurred along the way.
if let Err(errors) = body.evaluate_in_place(&ctx) {
eprintln!("{errors}");
}

hcl::to_writer(std::io::stdout(), &body)?;
Ok(())
}

#[cfg(test)]
mod test {
use super::*;
use hcl::eval::{FuncDef, ParamType};
use hcl::value::Value;
use hcl::Map;
use pretty_assertions::assert_eq;

#[test]
fn exprs_are_evaluated_in_place() {
let input = indoc::indoc! {r#"
resource "aws_eks_cluster" "this" {
count = var.create_eks ? 1 : 0
name = var.cluster_name
role_arn = local.cluster_iam_role_arn
version = var.cluster_version
vpc_config {
security_group_ids = compact([local.cluster_security_group_id])
subnet_ids = var.subnets
}
kubernetes_network_config {
service_ipv4_cidr = var.cluster_service_ipv4_cidr
}
tags = merge(
var.tags,
var.cluster_tags,
)
}
"#};

let mut body = hcl::parse(&input).unwrap();
let mut ctx = Context::new();
ctx.declare_var(
"var",
hcl::value!({
"create_eks" = true
"cluster_name" = "mycluster"
"cluster_tags" = {
"team" = "ops"
}
"cluster_version" = "1.27.0"
"tags" = {
"environment" = "dev"
}
}),
);

ctx.declare_func(
"merge",
FuncDef::builder()
.variadic_param(ParamType::Any)
.build(|args| {
let mut map = Map::<String, Value>::new();
for arg in args.variadic_args() {
if let Some(object) = arg.as_object() {
map.extend(object.clone());
} else {
return Err(format!("Argument {:?} is not an object", arg));
}
}

Ok(Value::Object(map))
}),
);

let res = body.evaluate_in_place(&ctx);
assert!(res.is_err());

let errors = res.unwrap_err();

assert_eq!(errors.len(), 4);
assert_eq!(
errors.to_string(),
indoc::indoc! {r#"
4 errors occurred:
- undefined variable `local` in expression `local.cluster_iam_role_arn`
- undefined variable `local` in expression `local.cluster_security_group_id`
- no such key: `subnets` in expression `var.subnets`
- no such key: `cluster_service_ipv4_cidr` in expression `var.cluster_service_ipv4_cidr`
"#}
);

let expected = indoc::indoc! {r#"
resource "aws_eks_cluster" "this" {
count = 1
name = "mycluster"
role_arn = local.cluster_iam_role_arn
version = "1.27.0"
vpc_config {
security_group_ids = compact([local.cluster_security_group_id])
subnet_ids = var.subnets
}
kubernetes_network_config {
service_ipv4_cidr = var.cluster_service_ipv4_cidr
}
tags = {
"environment" = "dev"
"team" = "ops"
}
}
"#};

assert_eq!(hcl::to_string(&body).unwrap(), expected);
}
}
93 changes: 91 additions & 2 deletions crates/hcl-rs/src/eval/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,97 @@ use std::fmt;
/// The result type used by this module.
pub type EvalResult<T, E = Error> = std::result::Result<T, E>;

pub(super) trait EvalResultExt {
fn add_errors(self, rhs: Self) -> Self;
}

impl EvalResultExt for EvalResult<(), Errors> {
fn add_errors(self, rhs: Self) -> Self {
match self {
Err(mut lhs) => {
lhs.extend_from_result(rhs);
Err(lhs)
}
_ => rhs,
}
}
}

/// A type holding multiple errors that occurred during in-place expression evaluation via
/// [`Evaluate::evaluate_in_place`].
///
/// It is guaranteed that `Errors` instances hold at least one error.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Errors {
inner: Vec<Error>,
}

impl Errors {
fn extend_from_result(&mut self, res: EvalResult<(), Errors>) {
if let Err(errors) = res {
self.inner.extend(errors);
}
}

/// Returns the number of errors.
#[inline]
#[allow(clippy::len_without_is_empty)]
pub fn len(&self) -> usize {
self.inner.len()
}

/// Returns an iterator over all errors.
#[inline]
pub fn iter(&self) -> std::slice::Iter<Error> {
self.inner.iter()
}
}

impl fmt::Display for Errors {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.len() == 1 {
self.inner[0].fmt(f)
} else {
writeln!(f, "{} errors occurred:", self.len())?;

for error in self {
writeln!(f, "- {error}")?;
}

Ok(())
}
}
}

impl From<Error> for Errors {
#[inline]
fn from(error: Error) -> Self {
Errors { inner: vec![error] }
}
}

impl std::error::Error for Errors {}

impl IntoIterator for Errors {
type Item = Error;
type IntoIter = std::vec::IntoIter<Error>;

fn into_iter(self) -> Self::IntoIter {
self.inner.into_iter()
}
}

impl<'a> IntoIterator for &'a Errors {
type Item = &'a Error;
type IntoIter = std::slice::Iter<'a, Error>;

fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}

/// The error type returned by all fallible operations within this module.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Error {
inner: Box<ErrorInner>,
}
Expand Down Expand Up @@ -73,7 +162,7 @@ impl std::error::Error for Error {}
// The inner type that holds the actual error data.
//
// This is a separate type because it gets boxed to keep the size of the `Error` struct small.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
struct ErrorInner {
kind: ErrorKind,
expr: Option<Expression>,
Expand Down
Loading

0 comments on commit ccc000f

Please sign in to comment.