Skip to main content

tfparser_core/eval/
component.rs

1//! Top-level evaluator.
2//!
3//! [`Evaluator::evaluate`] is the Phase 4 entry point: it threads an
4//! [`EvalContext`] through one [`Component`] (post-projection) and returns
5//! an [`EvaluatedComponent`] with every reachable expression reduced as far
6//! as the evaluator can. Per [13-evaluator.md § 3] the pipeline is:
7//!
8//! 1. Bind variables: defaults eagerly reduced; `repo_vars` overrides.
9//! 2. Solve locals with the worklist algorithm; surface cycles as a diagnostic.
10//! 3. Reduce providers / resources / modules / outputs.
11//!
12//! Failures inside any pass become [`Diagnostic`]s attached to the returned
13//! `EvaluatedComponent.diagnostics`. The evaluator never aborts the whole
14//! component on a single bad expression — best-effort by contract
15//! ([99-key-decisions.md] D4).
16//!
17//! [13-evaluator.md § 3]: ../../../specs/13-evaluator.md
18//! [99-key-decisions.md]: ../../../specs/99-key-decisions.md
19
20use std::sync::Arc;
21
22use crate::{
23    Result,
24    diagnostic::{Diagnostic, Severity},
25    eval::{
26        context::EvalContext,
27        error::EvalError,
28        locals::solve_locals,
29        reduce::{Scope, reduce_expression},
30    },
31    ir::{
32        AttributeMap, Component, Expression, Local, Map, ModuleCall, Output, ProviderBlock,
33        Resource, Variable,
34    },
35};
36
37/// The contract every evaluator implements. Phase 4 ships exactly one
38/// implementation: [`HclEvaluator`].
39pub trait Evaluator: Send + Sync + std::fmt::Debug {
40    /// Reduce `component` against `ctx`, returning the resolved IR plus
41    /// any diagnostics surfaced during evaluation.
42    ///
43    /// # Errors
44    ///
45    /// Only fatal errors (would-leave-the-IR-malformed) bubble out as
46    /// [`crate::Error`]. Recoverable failures (cycle in locals, function
47    /// call errors, sandboxed file rejects) attach to the returned
48    /// `EvaluatedComponent.diagnostics` instead.
49    fn evaluate(&self, component: &Component, ctx: &EvalContext) -> Result<EvaluatedComponent>;
50}
51
52/// Default evaluator implementation.
53#[derive(Debug, Default)]
54#[non_exhaustive]
55pub struct HclEvaluator;
56
57impl HclEvaluator {
58    /// Construct a new evaluator. Free-standing convenience; the default
59    /// `HclEvaluator::default()` is equivalent.
60    #[must_use]
61    pub const fn new() -> Self {
62        Self
63    }
64}
65
66/// Post-evaluation view of a [`Component`].
67///
68/// All collections are owned; the raw component is held behind an `Arc` so
69/// span lookups remain cheap (per [13-evaluator.md § 2]).
70///
71/// Construct via [`HclEvaluator::evaluate`] in production code, or via
72/// [`EvaluatedComponent::from_component`] in tests when you want a
73/// transparent wrapper around a hand-built [`Component`].
74#[derive(Clone, Debug)]
75#[non_exhaustive]
76pub struct EvaluatedComponent {
77    /// The original component (kept for spans).
78    pub raw: Arc<Component>,
79    /// Variables with `default` reduced where possible.
80    pub variables: Vec<Variable>,
81    /// Locals after the fixpoint pass; each [`Local::value`] is either
82    /// fully resolved or a partially reduced subtree.
83    pub locals: Vec<Local>,
84    /// Provider blocks with `region_expr` / `profile_expr` / `assume_role`
85    /// expressions reduced.
86    pub providers: Vec<ProviderBlock>,
87    /// Resources with `count_expr`, `for_each_expr`, and every attribute
88    /// reduced.
89    pub resources: Vec<Resource>,
90    /// Module call sites with inputs reduced.
91    pub modules: Vec<ModuleCall>,
92    /// Output blocks with their value reduced.
93    pub outputs: Vec<Output>,
94    /// Per-evaluation diagnostics — appended to the workspace's diagnostics
95    /// vector by the orchestrator (Phase 5).
96    pub diagnostics: Vec<Diagnostic>,
97}
98
99impl EvaluatedComponent {
100    /// Build an `EvaluatedComponent` that mirrors the contents of `c`
101    /// **as-if** evaluation had been a no-op — every collection is cloned
102    /// straight out of the source component. Used by integration / graph
103    /// tests that want a synthetic `EvaluatedComponent` without spinning
104    /// up the full evaluator. Production code should call
105    /// [`HclEvaluator::evaluate`].
106    #[must_use]
107    pub fn from_component(c: Component) -> Self {
108        Self {
109            variables: c.variables.clone(),
110            locals: c.locals.clone(),
111            providers: c.providers.clone(),
112            resources: c.resources.clone(),
113            modules: c.modules.clone(),
114            outputs: c.outputs.clone(),
115            diagnostics: Vec::new(),
116            raw: Arc::new(c),
117        }
118    }
119}
120
121impl Evaluator for HclEvaluator {
122    #[tracing::instrument(
123        skip(self, component, ctx),
124        fields(
125            component_id = ?component.id,
126            component_path = %component.path.display(),
127            // repo_vars / cascade_locals can carry secrets-shaped strings
128            // (`TF_VAR_*` from env) — record only counts, never values.
129            n_repo_vars = ctx.repo_vars.len(),
130            n_cascade_locals = ctx.cascade_locals.len(),
131        ),
132    )]
133    fn evaluate(&self, component: &Component, ctx: &EvalContext) -> Result<EvaluatedComponent> {
134        let mut diagnostics: Vec<Diagnostic> = Vec::new();
135
136        // Step 1 — bind variables.
137        let (variables, var_bindings) = bind_variables(&component.variables, ctx);
138
139        // Build initial vars: repo_vars (CLI / .tfvars) overrides component
140        // defaults.
141        let mut vars: Map = var_bindings;
142        for (k, v) in &ctx.repo_vars {
143            override_or_push(&mut vars, k, v);
144        }
145
146        // Cascade locals start as initial locals namespace; component
147        // locals add on top.
148        let mut locals_namespace: Map = ctx.cascade_locals.clone();
149
150        // Step 2 — solve locals.
151        let mut scope = Scope::new(
152            vars.clone(),
153            locals_namespace.clone(),
154            ctx.workspace_root.as_ref(),
155            &ctx.env_vars,
156            &ctx.limits,
157            &ctx.funcs,
158            ctx.environment.clone(),
159        );
160
161        let locals = match solve_locals(&component.locals, &mut scope) {
162            Ok(reduced) => reduced,
163            Err(EvalError::Cycle { participants }) => {
164                let participant_names: Vec<String> = participants
165                    .iter()
166                    .map(|p| p.as_str().to_string())
167                    .collect();
168                let summary = participant_names.join(", ");
169                diagnostics.push(
170                    Diagnostic::new(
171                        Severity::Error,
172                        "TF1401",
173                        format!("cycle in locals: {summary}"),
174                    )
175                    .with_span(component_span_for_diag(component)),
176                );
177                // Skip resolving locals — they stay as their source
178                // expressions in the returned component.
179                component.locals.clone()
180            }
181            Err(other) => {
182                diagnostics.push(Diagnostic::new(
183                    Severity::Warn,
184                    "TF1499",
185                    format!("locals reduction failed: {other}"),
186                ));
187                component.locals.clone()
188            }
189        };
190
191        // Inherit successful locals into the namespace.
192        for l in &locals {
193            if let Expression::Literal(v) = &l.value {
194                override_or_push(&mut locals_namespace, &l.name, v);
195            }
196        }
197        scope.locals = locals_namespace;
198
199        // Step 3 — reduce providers / resources / modules / outputs.
200        let providers = component
201            .providers
202            .iter()
203            .map(|p| reduce_provider(p, &scope))
204            .collect();
205        let resources = component
206            .resources
207            .iter()
208            .map(|r| reduce_resource(r, &scope))
209            .collect();
210        let modules = component
211            .modules
212            .iter()
213            .map(|m| reduce_module(m, &scope))
214            .collect();
215        let outputs = component
216            .outputs
217            .iter()
218            .map(|o| reduce_output(o, &scope))
219            .collect();
220
221        Ok(EvaluatedComponent {
222            raw: Arc::new(component.clone()),
223            variables,
224            locals,
225            providers,
226            resources,
227            modules,
228            outputs,
229            diagnostics,
230        })
231    }
232}
233
234/// Bind variables: defaults eagerly reduced against an empty scope.
235/// Returns both the post-reduction variable list (for the IR) and a Map
236/// suitable for seeding the reducer's `var.*` namespace.
237fn bind_variables(variables: &[Variable], ctx: &EvalContext) -> (Vec<Variable>, Map) {
238    let mut out: Vec<Variable> = Vec::with_capacity(variables.len());
239    let mut bindings: Map = Vec::new();
240
241    // Reduce variable defaults against a *minimal* scope (no locals yet,
242    // no other variable bindings). Variables in Terraform cannot reference
243    // other variables — this is a parse-time rule (I-EVAL-2).
244    let empty = Map::new();
245    let minimal_scope = Scope::new(
246        Map::new(),
247        empty,
248        ctx.workspace_root.as_ref(),
249        &ctx.env_vars,
250        &ctx.limits,
251        &ctx.funcs,
252        ctx.environment.clone(),
253    );
254
255    for var in variables {
256        let reduced_default = var
257            .default
258            .as_ref()
259            .map(|d| reduce_expression(d, &minimal_scope));
260
261        let bound: Option<&crate::ir::Value> = reduced_default.as_ref().and_then(|d| match d {
262            Expression::Literal(v) => Some(v),
263            _ => None,
264        });
265        if let Some(v) = bound {
266            bindings.push((Arc::clone(&var.name), v.clone()));
267        }
268
269        out.push(
270            Variable::builder()
271                .name(Arc::clone(&var.name))
272                .description(var.description.clone())
273                .type_expr(var.type_expr.clone())
274                .default(reduced_default)
275                .sensitive(var.sensitive)
276                .span(var.span.clone())
277                .build(),
278        );
279    }
280
281    (out, bindings)
282}
283
284fn override_or_push(map: &mut Map, key: &Arc<str>, value: &crate::ir::Value) {
285    if let Some(slot) = map.iter_mut().find(|(k, _)| k == key) {
286        slot.1 = value.clone();
287    } else {
288        map.push((Arc::clone(key), value.clone()));
289    }
290}
291
292fn reduce_attrs(attrs: &AttributeMap, scope: &Scope<'_>) -> AttributeMap {
293    attrs
294        .iter()
295        .map(|(k, v)| (Arc::clone(k), reduce_expression(v, scope)))
296        .collect()
297}
298
299fn reduce_provider(p: &ProviderBlock, scope: &Scope<'_>) -> ProviderBlock {
300    ProviderBlock::builder()
301        .local_name(Arc::clone(&p.local_name))
302        .alias(p.alias.clone())
303        .source_addr(p.source_addr.clone())
304        .region_expr(p.region_expr.as_ref().map(|e| reduce_expression(e, scope)))
305        .profile_expr(p.profile_expr.as_ref().map(|e| reduce_expression(e, scope)))
306        .assume_role(p.assume_role.clone())
307        .raw(reduce_attrs(&p.raw, scope))
308        .span(p.span.clone())
309        .build()
310}
311
312fn reduce_resource(r: &Resource, scope: &Scope<'_>) -> Resource {
313    Resource::builder()
314        .address(r.address.clone())
315        .kind(r.kind)
316        .type_(Arc::clone(&r.type_))
317        .name(Arc::clone(&r.name))
318        .provider_ref(r.provider_ref.clone())
319        .count_expr(r.count_expr.as_ref().map(|e| reduce_expression(e, scope)))
320        .for_each_expr(
321            r.for_each_expr
322                .as_ref()
323                .map(|e| reduce_expression(e, scope)),
324        )
325        .depends_on(r.depends_on.clone())
326        .attributes(reduce_attrs(&r.attributes, scope))
327        .span(r.span.clone())
328        .build()
329}
330
331fn reduce_module(m: &ModuleCall, scope: &Scope<'_>) -> ModuleCall {
332    ModuleCall::builder()
333        .address(m.address.clone())
334        .source_raw(Arc::clone(&m.source_raw))
335        .source(m.source.clone())
336        .resolved(m.resolved)
337        .providers(m.providers.clone())
338        .inputs(reduce_attrs(&m.inputs, scope))
339        .count_expr(m.count_expr.as_ref().map(|e| reduce_expression(e, scope)))
340        .for_each_expr(
341            m.for_each_expr
342                .as_ref()
343                .map(|e| reduce_expression(e, scope)),
344        )
345        .span(m.span.clone())
346        .build()
347}
348
349fn reduce_output(o: &Output, scope: &Scope<'_>) -> Output {
350    Output::builder()
351        .name(Arc::clone(&o.name))
352        .value(reduce_expression(&o.value, scope))
353        .description(o.description.clone())
354        .sensitive(o.sensitive)
355        .span(o.span.clone())
356        .build()
357}
358
359fn component_span_for_diag(component: &Component) -> crate::ir::Span {
360    // Use the first source file's path when one is recorded; else fall
361    // back to a synthetic span. The byte range / line / column are always
362    // the synthetic 1:1 placeholders — cycle diagnostics are not anchored
363    // to a specific reference site (the cycle has many).
364    if let Some(file) = component.files.first() {
365        return crate::ir::Span::new(Arc::clone(&file.path), 0..0, 1, 1)
366            .unwrap_or_else(|_| crate::ir::Span::synthetic());
367    }
368    crate::ir::Span::synthetic()
369}
370
371#[cfg(test)]
372#[allow(
373    clippy::unwrap_used,
374    clippy::expect_used,
375    clippy::panic,
376    clippy::indexing_slicing
377)]
378mod tests {
379    use std::path::{Path, PathBuf};
380
381    use super::*;
382    use crate::{
383        eval::{context::EvalLimits, registry::FuncRegistry},
384        ir::{
385            Address, BinaryOp, ComponentId, ComponentKind, Expression, Local, ProviderBlock,
386            Resource, ResourceKind, Span, SymbolKind, Symbolic, Value, Variable,
387        },
388    };
389
390    fn make_ctx() -> EvalContext {
391        EvalContext {
392            workspace_root: Arc::from(Path::new("/tmp/repo")),
393            environment: None,
394            env_vars: super::super::EnvVarMode::default(),
395            repo_vars: vec![(Arc::from("region"), Value::Str(Arc::from("us-east-2")))],
396            cascade_locals: Vec::new(),
397            funcs: Arc::new(FuncRegistry::default_with_stdlib()),
398            limits: EvalLimits::default(),
399        }
400    }
401
402    fn var(name: &str) -> Variable {
403        Variable::builder()
404            .name(Arc::<str>::from(name))
405            .span(Span::synthetic())
406            .build()
407    }
408
409    fn var_ref(name: &str, kind: SymbolKind) -> Expression {
410        Expression::Unresolved(
411            Symbolic::builder()
412                .kind(kind)
413                .source(Arc::<str>::from(name))
414                .span(Span::synthetic())
415                .build(),
416        )
417    }
418
419    #[test]
420    fn test_should_resolve_provider_region_from_var() {
421        let provider = ProviderBlock::builder()
422            .local_name(Arc::<str>::from("aws"))
423            .region_expr(Some(var_ref("var.region", SymbolKind::Var)))
424            .span(Span::synthetic())
425            .build();
426        let component = Component::builder()
427            .id(ComponentId::from_index(0))
428            .path(Arc::<Path>::from(PathBuf::from("c")))
429            .kind(ComponentKind::Component)
430            .variables(vec![var("region")])
431            .providers(vec![provider])
432            .build();
433        let evald = HclEvaluator.evaluate(&component, &make_ctx()).unwrap();
434        let region = evald.providers[0].region_expr.as_ref().unwrap();
435        assert_eq!(
436            region,
437            &Expression::Literal(Value::Str(Arc::from("us-east-2")))
438        );
439    }
440
441    #[test]
442    fn test_should_use_default_when_no_repo_var() {
443        let mut ctx = make_ctx();
444        ctx.repo_vars.clear();
445        let var_block = Variable::builder()
446            .name(Arc::<str>::from("region"))
447            .default(Some(Expression::Literal(Value::Str(Arc::from(
448                "us-west-1",
449            )))))
450            .span(Span::synthetic())
451            .build();
452        let provider = ProviderBlock::builder()
453            .local_name(Arc::<str>::from("aws"))
454            .region_expr(Some(var_ref("var.region", SymbolKind::Var)))
455            .span(Span::synthetic())
456            .build();
457        let component = Component::builder()
458            .id(ComponentId::from_index(0))
459            .path(Arc::<Path>::from(PathBuf::from("c")))
460            .kind(ComponentKind::Component)
461            .variables(vec![var_block])
462            .providers(vec![provider])
463            .build();
464        let evald = HclEvaluator.evaluate(&component, &ctx).unwrap();
465        let region = evald.providers[0].region_expr.as_ref().unwrap();
466        assert_eq!(
467            region,
468            &Expression::Literal(Value::Str(Arc::from("us-west-1")))
469        );
470    }
471
472    #[test]
473    fn test_should_resolve_local_from_var() {
474        // local.zone = "zone-" + var.region
475        let local = Local::builder()
476            .name(Arc::<str>::from("zone"))
477            .value(Expression::BinaryOp {
478                op: BinaryOp::Add,
479                lhs: Box::new(Expression::Literal(Value::Str(Arc::from("zone-")))),
480                rhs: Box::new(var_ref("var.region", SymbolKind::Var)),
481                span: Span::synthetic(),
482            })
483            .span(Span::synthetic())
484            .build();
485        let component = Component::builder()
486            .id(ComponentId::from_index(0))
487            .path(Arc::<Path>::from(PathBuf::from("c")))
488            .kind(ComponentKind::Component)
489            .variables(vec![var("region")])
490            .locals(vec![local])
491            .build();
492        let evald = HclEvaluator.evaluate(&component, &make_ctx()).unwrap();
493        assert_eq!(
494            evald.locals[0].value,
495            Expression::Literal(Value::Str(Arc::from("zone-us-east-2")))
496        );
497    }
498
499    #[test]
500    fn test_should_emit_cycle_diagnostic() {
501        let a = Local::builder()
502            .name(Arc::<str>::from("a"))
503            .value(var_ref("local.b", SymbolKind::Local))
504            .span(Span::synthetic())
505            .build();
506        let b = Local::builder()
507            .name(Arc::<str>::from("b"))
508            .value(var_ref("local.a", SymbolKind::Local))
509            .span(Span::synthetic())
510            .build();
511        let component = Component::builder()
512            .id(ComponentId::from_index(0))
513            .path(Arc::<Path>::from(PathBuf::from("c")))
514            .kind(ComponentKind::Component)
515            .locals(vec![a, b])
516            .build();
517        let evald = HclEvaluator.evaluate(&component, &make_ctx()).unwrap();
518        assert!(
519            evald
520                .diagnostics
521                .iter()
522                .any(|d| d.severity == Severity::Error && &*d.code == "TF1401"),
523            "{:?}",
524            evald.diagnostics
525        );
526        // Locals remain in their original form.
527        assert!(matches!(evald.locals[0].value, Expression::Unresolved(_)));
528    }
529
530    #[test]
531    fn test_should_reduce_resource_attribute_with_var() {
532        let resource = Resource::builder()
533            .address(Address::new("aws_iam_role.r").unwrap())
534            .kind(ResourceKind::Managed)
535            .type_(Arc::<str>::from("aws_iam_role"))
536            .name(Arc::<str>::from("r"))
537            .attributes(vec![(
538                Arc::from("name"),
539                Expression::TemplateConcat(vec![
540                    Expression::Literal(Value::Str(Arc::from("role-"))),
541                    var_ref("var.region", SymbolKind::Var),
542                ]),
543            )])
544            .span(Span::synthetic())
545            .build();
546        let component = Component::builder()
547            .id(ComponentId::from_index(0))
548            .path(Arc::<Path>::from(PathBuf::from("c")))
549            .kind(ComponentKind::Component)
550            .variables(vec![var("region")])
551            .resources(vec![resource])
552            .build();
553        let evald = HclEvaluator.evaluate(&component, &make_ctx()).unwrap();
554        let attrs = &evald.resources[0].attributes;
555        let (_, v) = attrs.iter().find(|(k, _)| k.as_ref() == "name").unwrap();
556        assert_eq!(
557            v,
558            &Expression::Literal(Value::Str(Arc::from("role-us-east-2")))
559        );
560    }
561
562    #[test]
563    fn test_should_keep_module_outputs_unresolved() {
564        // resource attribute = module.foo.id → stays as Unresolved
565        let attr = var_ref("module.foo.id", SymbolKind::Module);
566        let resource = Resource::builder()
567            .address(Address::new("aws_iam_role.r").unwrap())
568            .kind(ResourceKind::Managed)
569            .type_(Arc::<str>::from("aws_iam_role"))
570            .name(Arc::<str>::from("r"))
571            .attributes(vec![(Arc::from("id"), attr.clone())])
572            .span(Span::synthetic())
573            .build();
574        let component = Component::builder()
575            .id(ComponentId::from_index(0))
576            .path(Arc::<Path>::from(PathBuf::from("c")))
577            .kind(ComponentKind::Component)
578            .resources(vec![resource])
579            .build();
580        let evald = HclEvaluator.evaluate(&component, &make_ctx()).unwrap();
581        let (_, v) = evald.resources[0]
582            .attributes
583            .iter()
584            .find(|(k, _)| k.as_ref() == "id")
585            .unwrap();
586        assert_eq!(v, &attr);
587    }
588
589    #[test]
590    fn test_should_resolve_terraform_workspace() {
591        let mut ctx = make_ctx();
592        ctx.environment = Some(Arc::from("staging"));
593        let resource = Resource::builder()
594            .address(Address::new("aws_iam_role.r").unwrap())
595            .kind(ResourceKind::Managed)
596            .type_(Arc::<str>::from("aws_iam_role"))
597            .name(Arc::<str>::from("r"))
598            .attributes(vec![(
599                Arc::from("env"),
600                var_ref("terraform.workspace", SymbolKind::Terraform),
601            )])
602            .span(Span::synthetic())
603            .build();
604        let component = Component::builder()
605            .id(ComponentId::from_index(0))
606            .path(Arc::<Path>::from(PathBuf::from("c")))
607            .kind(ComponentKind::Component)
608            .resources(vec![resource])
609            .build();
610        let evald = HclEvaluator.evaluate(&component, &ctx).unwrap();
611        let (_, v) = evald.resources[0]
612            .attributes
613            .iter()
614            .find(|(k, _)| k.as_ref() == "env")
615            .unwrap();
616        assert_eq!(v, &Expression::Literal(Value::Str(Arc::from("staging"))));
617    }
618
619    #[test]
620    fn test_evaluator_is_send_sync() {
621        const fn assert_send_sync<T: Send + Sync + 'static>() {}
622        assert_send_sync::<HclEvaluator>();
623        assert_send_sync::<Box<dyn Evaluator>>();
624        assert_send_sync::<EvalContext>();
625        assert_send_sync::<EvaluatedComponent>();
626    }
627}