Files
opentf/internal/lang/exprs/evaluate.go
Martin Atkins 72a32f726d lang/eval: Beginnings of a different way to handle config eval
In "package tofu" today we try to do everything using a generic acyclic
graph model and generic graph walk, which _works_ but tends to make every
other part of the problem very hard to follow because we rely a lot on
sidecar shared mutable data structures to propagate results between the
isolated operations.

This is the beginning of an experimental new way to do it where the "graph"
is implied by a model that more closely represents how the language itself
works, with explicit modelling of the relationships between different
types of objects and letting results flow directly from one object to
another without any big shared mutable state.

There's still a lot to do before this is actually complete enough to
evaluate whether it's a viable new design, but I'm considering this a good
starting checkpoint since there's enough here to run a simple test of
propagating data all the way from input variables to output values via
intermediate local values.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2025-10-27 10:15:41 -07:00

203 lines
7.0 KiB
Go

// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package exprs
import (
"context"
"iter"
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
"github.com/opentofu/opentofu/internal/tfdiags"
)
// Evaluate attempts to evaluate the given [Evaluable] in the given [Scope],
// either returning the resulting value or some error diagnostics describing
// problems that prevented successful evaluation.
//
// Some [Evaluable] implementations (or the symbols they refer to) can block
// on potentially-time-consuming operations, in which case they should respond
// gracefully to cancellation of the given context.
//
// It's valid to pass a nil Scope, representing that no symbols or functions
// are available at all. Note that HCL's JSON syntax treats that situation
// quite differently by taking JSON strings totally literally instead of
// trying to interpret them as HCL templates, and so switching to or from
// a nil scope is typically a breaking change for what's allowed in a
// particular position.
func Evaluate(ctx context.Context, what Evalable, scope Scope) (cty.Value, tfdiags.Diagnostics) {
hclCtx, diags := buildHCLEvalContext(ctx, what, scope)
if diags.HasErrors() {
return cty.DynamicVal.Mark(EvalError), diags
}
val, moreDiags := what.Evaluate(ctx, hclCtx)
diags = diags.Append(moreDiags)
if diags.HasErrors() {
val = val.Mark(EvalError)
}
return val, diags
}
func buildHCLEvalContext(ctx context.Context, what Evalable, scope Scope) (*hcl.EvalContext, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
ret := &hcl.EvalContext{}
if scope == nil {
// A nil scope represents that nothing at all is available, which HCL
// represents as an EvalContext with nothing defined inside it.
// Note that this causes significantly different behavior for HCL's
// JSON syntax.
return ret, diags
}
symbols, moreDiags := buildSymbolTable(ctx, what.References(), scope)
ret.Variables = symbols
diags = diags.Append(moreDiags)
funcs, moreDiags := buildFunctionTable(ctx, what.FunctionCalls(), scope)
ret.Functions = funcs
diags = diags.Append(moreDiags)
return ret, diags
}
func buildSymbolTable(ctx context.Context, refs iter.Seq[hcl.Traversal], scope Scope) (map[string]cty.Value, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
// We'll first build an intermediate representation of the nested symbol
// tables, because a cty.ObjectVal result is immutable and so we need to
// collect up all of the attribute values in a mutable data structure
// and then freeze it into an immutable tree once complete.
nodes := make(map[string]*symbolTableTempNode)
References:
for traversal := range refs {
currentChildren := nodes
currentTable := SymbolTable(scope)
for i, step := range traversal {
// For our purposes here the distinction between TraverseAttr
// and TraverseRoot is unimportant, so we'll normalize to
// TraverseAttr.
if rootStep, ok := step.(hcl.TraverseRoot); ok {
step = hcl.TraverseAttr{
Name: rootStep.Name,
SrcRange: rootStep.SrcRange,
}
}
switch step := step.(type) {
case hcl.TraverseAttr:
attr, moreDiags := currentTable.ResolveAttr(step)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
continue
}
switch attr := attr.(type) {
case nestedSymbolTable:
if _, ok := currentChildren[step.Name]; !ok {
currentChildren[step.Name] = &symbolTableTempNode{
children: make(map[string]*symbolTableTempNode),
}
}
currentChildren = currentChildren[step.Name].children
currentTable = attr.SymbolTable
continue
case valueOf:
currentChildren[step.Name] = &symbolTableTempNode{
val: attr.Valuer,
remain: traversal[i+1:],
}
continue References // any remaining steps are dynamic steps through the final value, captured in "remain" above
}
default:
moreDiags := currentTable.HandleInvalidStep(tfdiags.SourceRangeFromHCL(step.SourceRange()))
diags = diags.Append(moreDiags)
continue References
}
}
// If we get here then we ran out of steps before we encountered a
// leaf Valuer, so this reference is incomplete and therefore invalid.
moreDiags := currentTable.HandleInvalidStep(tfdiags.SourceRangeFromHCL(traversal.SourceRange()))
diags = diags.Append(moreDiags)
}
ret, moreDiags := valuesForSymbolTableTempNodes(ctx, nodes)
diags = diags.Append(moreDiags)
return ret, diags
}
// symbolTableTempNode is an implementation detail of [buildSymbolTable] used
// as a mutable intermediate representation of the symbol table so that we
// can gradually assemble nested symbol tables and then turn them into
// immutable cty object values only at the end when the work is complete.
//
// A value of this type has EITHER val+remain or children alone populated.
type symbolTableTempNode struct {
val Valuer
remain hcl.Traversal
children map[string]*symbolTableTempNode
}
// valuesForSymbolTableTempNodes returns a map of [cty.Value] providing a
// frozen representation of the symbol tree rooted at the given map.
func valuesForSymbolTableTempNodes(ctx context.Context, symbols map[string]*symbolTableTempNode) (map[string]cty.Value, tfdiags.Diagnostics) {
if len(symbols) == 0 {
return nil, nil
}
var diags tfdiags.Diagnostics
ret := make(map[string]cty.Value, len(symbols))
for name, node := range symbols {
if node.val != nil {
// This is a leaf node, with a dynamic value associated with it.
moreDiags := node.val.StaticCheckTraversal(node.remain)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
ret[name] = cty.DynamicVal
continue
}
// When we take the value of the object being referred to we
// intentionally ignore any _indirect_ diagnostics that might
// cause because we only want to report diagnostics that are
// directly related to the expression we're currently
// evaluating. Whatever problems might exist in the definition
// of what we're referring to must be caught by visiting
// that thing and evaluating it directly. This is safe to do
// because the definition of Valuer requires that Value must
// always return some reasonable placeholder value to use even
// when an error occurs.
val, _ := node.val.Value(ctx)
ret[name] = val
continue
}
// This is a nested symbol table, so we'll analyze its content recursively.
childVals, moreDiags := valuesForSymbolTableTempNodes(ctx, node.children)
diags = diags.Append(moreDiags)
ret[name] = cty.ObjectVal(childVals)
}
return ret, diags
}
func buildFunctionTable(_ context.Context, calls iter.Seq[*hcl.StaticCall], scope Scope) (map[string]function.Function, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
ret := make(map[string]function.Function)
for call := range calls {
funcName := call.Name
impl, moreDiags := scope.ResolveFunc(call)
diags = diags.Append(moreDiags)
if moreDiags.HasErrors() {
continue
}
ret[funcName] = impl
}
return ret, diags
}