Prototype custom functions (and some other hacks)

```hcl
function "hw" {
  description = "who to say hello to"

  parameter "name" {
    type = string
  }

  parameter "info" {
    type = string
    variadic = true
  }

  scratch {
    message = "welcome to the World!"
    value = "Hello ${param.name}, ${scratch.message}${join(".", param.info)}"
  }

  return {
    value = scratch.value
  }
}

output "callfn" {
  value = module::hw("Jeoff", " This is a custom function", " It supports varargs")
}
```

Signed-off-by: Christian Mesh <christianmesh1@gmail.com>
This commit is contained in:
Christian Mesh
2025-11-03 13:04:40 -05:00
parent 481798ab36
commit a9d4b15280
10 changed files with 488 additions and 4 deletions

View File

@@ -39,11 +39,13 @@ type Function struct {
const (
FunctionNamespaceProvider = "provider"
FunctionNamespaceCore = "core"
FunctionNamespaceModule = "module"
)
var FunctionNamespaces = []string{
FunctionNamespaceProvider,
FunctionNamespaceCore,
FunctionNamespaceModule,
}
func ParseFunction(input string) Function {

View File

@@ -0,0 +1,387 @@
// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package configs
import (
"fmt"
"slices"
"strings"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/opentofu/opentofu/internal/lang/marks"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
)
type Function struct {
Name string
Description string
// Deprecated?
Parameters []*FunctionParameter
Scratch map[string]*FunctionScratch
Return FunctionReturn
DeclRange hcl.Range
}
type FunctionParameter struct {
Name string
DeclRange hcl.Range
Type cty.Type
Validations []*CheckRule
// Unsure at this point
Sensitive bool
Ephemeral bool
Nullable bool
Variadic bool
}
type FunctionScratch struct {
Name string
Expr hcl.Expression
DeclRange hcl.Range
}
type FunctionReturn struct {
Expr hcl.Expression
Sensitive bool
Preconditions []*CheckRule
DeclRange hcl.Range
}
func decodeFunctionBlock(block *hcl.Block) (*Function, hcl.Diagnostics) {
var diags hcl.Diagnostics
fn := &Function{
Name: block.Labels[0],
DeclRange: block.DefRange,
Scratch: map[string]*FunctionScratch{},
}
if !hclsyntax.ValidIdentifier(fn.Name) {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid function name",
Detail: badIdentifierDetail,
Subject: &block.LabelRanges[0],
})
}
content, moreDiags := block.Body.Content(functionBlockSchema)
diags = append(diags, moreDiags...)
if attr, ok := content.Attributes["description"]; ok {
decodeDiags := gohcl.DecodeExpression(attr.Expr, nil, &fn.Description)
diags = append(diags, decodeDiags...)
}
for _, block := range content.Blocks {
switch block.Type {
case "parameter":
param := &FunctionParameter{
Name: block.Labels[0],
DeclRange: block.DefRange,
}
content, moreDiags := block.Body.Content(functionParameterBlockSchema)
diags = append(diags, moreDiags...)
if attr, exists := content.Attributes["type"]; exists {
ty, _, _, tyDiags := decodeVariableType(attr.Expr)
diags = append(diags, tyDiags...)
//param.ConstraintType = ty
//param.Typeaults = tyaults
param.Type = ty.WithoutOptionalAttributesDeep()
}
if attr, exists := content.Attributes["sensitive"]; exists {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &param.Sensitive)
diags = append(diags, valDiags...)
}
if attr, exists := content.Attributes["ephemeral"]; exists {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &param.Ephemeral)
diags = append(diags, valDiags...)
}
if attr, exists := content.Attributes["nullable"]; exists {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &param.Nullable)
diags = append(diags, valDiags...)
} else {
// The current default is true, which is subject to change in a future
// language edition.
}
if attr, exists := content.Attributes["variadic"]; exists {
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &param.Variadic)
diags = append(diags, valDiags...)
}
for _, block := range content.Blocks {
switch block.Type {
case "validation":
vv, moreDiags := decodeVariableValidationBlock(param.Name, block, false)
diags = append(diags, moreDiags...)
param.Validations = append(param.Validations, vv)
default:
// The above cases should be exhaustive for all block types
// defined in functionReturnBlockSchema
panic(fmt.Sprintf("unhandled block type %q", block.Type))
}
}
fn.Parameters = append(fn.Parameters, param)
case "scratch":
attrs, moreDiags := block.Body.JustAttributes()
diags = append(diags, moreDiags...)
for name, attr := range attrs {
if !hclsyntax.ValidIdentifier(name) {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid scratch value name",
Detail: badIdentifierDetail,
Subject: &attr.NameRange,
})
}
if _, ok := fn.Scratch[name]; ok {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate scratch entry",
Detail: fmt.Sprintf("The function block already has a scratch entry named %s.", name),
Subject: &block.DefRange,
})
continue
}
fn.Scratch[name] = &FunctionScratch{
Name: name,
Expr: attr.Expr,
DeclRange: attr.Range,
}
}
case "return":
if !fn.Return.DeclRange.Empty() {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate return block",
Detail: fmt.Sprintf("The function block already has a return block at %s.", fn.Return.DeclRange),
Subject: &block.DefRange,
})
continue
}
content, moreDiags := block.Body.Content(functionReturnBlockSchema)
diags = append(diags, moreDiags...)
fn.Return.Expr = content.Attributes["value"].Expr
fn.Return.DeclRange = block.DefRange
if attr, ok := content.Attributes["sensitive"]; ok {
decodeDiags := gohcl.DecodeExpression(attr.Expr, nil, &fn.Return.Sensitive)
diags = append(diags, decodeDiags...)
}
}
}
if fn.Return.DeclRange.Empty() {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing return block",
Detail: "The function block is missing a return block",
Subject: &block.DefRange,
})
}
// TODO sort params by decl order
return fn, diags
}
func (fn *Function) Implementation(parentCtx *hcl.EvalContext) (function.Function, hcl.Diagnostics) {
var diags hcl.Diagnostics
spec := &function.Spec{
Description: fn.Description,
Type: function.StaticReturnType(cty.DynamicPseudoType), // I don't know if we care to try to make this smarter
}
for i, param := range fn.Parameters {
entry := function.Parameter{
Name: param.Name,
//Description: param.Description,
Type: param.Type,
AllowNull: param.Nullable,
AllowUnknown: true,
AllowDynamicType: true,
AllowMarked: true,
}
if param.Variadic {
if i != len(fn.Parameters)-1 {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid parameter",
Detail: "Variadic parameters must be the final parameter defined in a function",
Subject: &fn.DeclRange,
})
continue
}
spec.VarParam = &entry
} else {
spec.Params = append(spec.Params, entry)
}
}
spec.Impl = func(args []cty.Value, retType cty.Type) (cty.Value, error) {
hclCtx := parentCtx.NewChild()
hclCtx.Variables = map[string]cty.Value{}
paramMap := map[string]cty.Value{}
for i, param := range spec.Params {
paramMap[param.Name] = args[i]
}
if spec.VarParam != nil {
// TODO check indexes
paramMap[spec.VarParam.Name] = cty.TupleVal(args[len(spec.Params):])
}
hclCtx.Variables["param"] = cty.ObjectVal(paramMap)
// Mini Graph Time!
var stack []string
scratch := map[string]cty.Value{}
var addScratch func(*FunctionScratch)
addScratch = func(entry *FunctionScratch) {
if _, ok := scratch[entry.Name]; ok {
// Already added
return
}
if slices.Contains(stack, entry.Name) {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Circular scratch dependency",
Detail: strings.Join(append(stack, entry.Name), ", "),
Subject: &entry.DeclRange,
})
scratch[entry.Name] = cty.NilVal
return
}
// Push
stack = append(stack, entry.Name)
// Pop
defer func() {
stack = stack[:len(stack)-1]
}()
for _, v := range entry.Expr.Variables() {
if v.RootName() == "scratch" {
if len(v) < 1 {
panic("booo")
}
attr, ok := v[1].(hcl.TraverseAttr)
if !ok {
// Handle error elsewhere
continue
}
dep, ok := fn.Scratch[attr.Name]
if !ok {
// Handle error elsewhere
continue
}
addScratch(dep)
}
}
val, valDiags := entry.Expr.Value(hclCtx)
diags = append(diags, valDiags...)
scratch[entry.Name] = val
hclCtx.Variables["scratch"] = cty.ObjectVal(scratch)
}
for _, scratch := range fn.Scratch {
addScratch(scratch)
}
// TODO preconditions
// TODO provider functions?
val, valDiags := fn.Return.Expr.Value(hclCtx)
diags = append(diags, valDiags...)
if fn.Return.Sensitive {
val = val.Mark(marks.Sensitive)
}
if diags.HasErrors() {
return val, diags
}
return val, nil
}
return function.New(spec), diags
}
var functionBlockSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: "description"},
},
Blocks: []hcl.BlockHeaderSchema{
{Type: "parameter", LabelNames: []string{"name"}},
{Type: "scratch"},
{Type: "return"},
},
}
var functionParameterBlockSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{
Name: "type",
},
{
Name: "sensitive",
},
{
Name: "ephemeral",
},
{
Name: "nullable",
},
{
Name: "variadic",
},
},
Blocks: []hcl.BlockHeaderSchema{
{
Type: "validation",
},
},
}
var functionReturnBlockSchema = &hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{
Name: "value",
Required: true,
},
{
Name: "sensitive",
},
},
Blocks: []hcl.BlockHeaderSchema{
{Type: "precondition"},
},
}

View File

@@ -62,6 +62,8 @@ type Module struct {
Tests map[string]*TestFile
CustomFunctions map[string]*Function
// IsOverridden indicates if the module is being overridden. It's used in
// testing framework to not call the underlying module.
IsOverridden bool
@@ -115,6 +117,9 @@ type File struct {
Removed []*Removed
Checks []*Check
ModuleDefs []*ModuleDef
Functions []*Function
}
// SelectiveLoader allows the consumer to only load and validate the portions of files needed for the given operations/contexts
@@ -182,6 +187,7 @@ func NewModuleUneval(primaryFiles, overrideFiles []*File, sourceDir string, load
DataResources: map[string]*Resource{},
EphemeralResources: map[string]*Resource{},
Checks: map[string]*Check{},
CustomFunctions: map[string]*Function{},
ProviderMetas: map[addrs.Provider]*ProviderMeta{},
Tests: map[string]*TestFile{},
SourceDir: sourceDir,
@@ -596,6 +602,19 @@ func (m *Module) appendFile(file *File) hcl.Diagnostics {
m.Removed = append(m.Removed, file.Removed...)
for _, fn := range file.Functions {
if existing, exists := m.CustomFunctions[fn.Name]; exists {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Duplicate function",
Detail: fmt.Sprintf("A function named %q was already defined at %s. Functions must have unique names within a module.", existing.Name, existing.DeclRange),
Subject: &fn.DeclRange,
})
continue
}
m.CustomFunctions[fn.Name] = fn
}
return diags
}

View File

@@ -0,0 +1,12 @@
package configs
import "github.com/hashicorp/hcl/v2"
type ModuleDef struct {
Name string
Contents *Module
}
func decodeModuleDefBlock(block *hcl.Block) (*ModuleDef, hcl.Diagnostics) {
return nil, nil
}

View File

@@ -0,0 +1,18 @@
package configs
type ModuleAccessSafety string
const (
ModuleAccessSafeModule = ModuleAccessSafety("module")
ModuleAccessSafeTree = ModuleAccessSafety("tree")
ModuleAccessUnsafe = ModuleAccessSafety("unsafe")
)
type ModuleMeta struct {
Access *ModuleAccessSafety
}
type ModulePackageMeta struct {
DefaultAccess *ModuleAccessSafety
OverrideAccess map[string]*ModuleAccessSafety
}

View File

@@ -223,6 +223,26 @@ func loadConfigFileBody(body hcl.Body, filename string, override bool, allowExpe
if cfg != nil {
file.Removed = append(file.Removed, cfg)
}
case "define":
if len(block.Labels) < 2 {
panic("TODO diags")
}
switch block.Labels[0] {
case "module":
cfg, cfgDiags := decodeModuleDefBlock(block)
diags = append(diags, cfgDiags...)
if cfg != nil {
file.ModuleDefs = append(file.ModuleDefs, cfg)
}
default:
panic("TODO diags")
}
case "function":
cfg, cfgDiags := decodeFunctionBlock(block)
diags = append(diags, cfgDiags...)
if cfg != nil {
file.Functions = append(file.Functions, cfg)
}
default:
// Should never happen because the above cases should be exhaustive
@@ -328,6 +348,14 @@ var configFileSchema = &hcl.BodySchema{
{
Type: "removed",
},
{
Type: "define",
LabelNames: []string{"type", "name"},
},
{
Type: "function",
LabelNames: []string{"name"},
},
},
}

View File

@@ -348,6 +348,13 @@ func (s *Scope) evalContext(ctx context.Context, parent *hcl.EvalContext, refs [
// provider-defined functions below.
maps.Copy(hclCtx.Functions, s.Functions())
for name, fn := range s.CustomFunctions {
// TODO recursion limitations
impl, fnDiags := fn.Implementation(hclCtx)
diags = diags.Append(fnDiags)
hclCtx.Functions[addrs.FunctionNamespaceModule+"::"+name] = impl
}
// Easy path for common case where there are no references at all.
if len(refs) == 0 {
return hclCtx, diags

View File

@@ -74,9 +74,14 @@ type Scope struct {
PlanTimestamp time.Time
ProviderFunctions ProviderFunction
CustomFunctions map[string]CustomFunction
}
type ProviderFunction func(context.Context, addrs.ProviderFunction, tfdiags.SourceRange) (*function.Function, tfdiags.Diagnostics)
type CustomFunction interface {
Implementation(*hcl.EvalContext) (function.Function, hcl.Diagnostics)
}
// SetActiveExperiments allows a caller to declare that a set of experiments
// is active for the module that the receiving Scope belongs to, which might

View File

@@ -454,10 +454,15 @@ func (c *BuiltinEvalContext) EvaluationScope(self addrs.Referenceable, source ad
mc := c.Evaluator.Config.DescendentForInstance(c.PathValue)
if mc == nil || mc.Module.ProviderRequirements == nil {
return c.Evaluator.Scope(data, self, source, nil)
return c.Evaluator.Scope(data, self, source, nil, nil)
}
scope := c.Evaluator.Scope(data, self, source, func(ctx context.Context, pf addrs.ProviderFunction, rng tfdiags.SourceRange) (*function.Function, tfdiags.Diagnostics) {
crap := map[string]lang.CustomFunction{}
for name, fn := range mc.Module.CustomFunctions {
crap[name] = fn
}
scope := c.Evaluator.Scope(data, self, source, crap, func(ctx context.Context, pf addrs.ProviderFunction, rng tfdiags.SourceRange) (*function.Function, tfdiags.Diagnostics) {
providedBy, ok := c.ProviderFunctionTracker.Lookup(c.PathValue.Module(), pf)
if !ok {
// This should not be possible if references are tracked correctly

View File

@@ -77,7 +77,7 @@ type Evaluator struct {
// If the "self" argument is nil then the "self" object is not available
// in evaluated expressions. Otherwise, it behaves as an alias for the given
// address.
func (e *Evaluator) Scope(data lang.Data, self addrs.Referenceable, source addrs.Referenceable, functions lang.ProviderFunction) *lang.Scope {
func (e *Evaluator) Scope(data lang.Data, self addrs.Referenceable, source addrs.Referenceable, customFunctions map[string]lang.CustomFunction, providerFuncs lang.ProviderFunction) *lang.Scope {
return &lang.Scope{
Data: data,
ParseRef: addrs.ParseRef,
@@ -86,7 +86,8 @@ func (e *Evaluator) Scope(data lang.Data, self addrs.Referenceable, source addrs
PureOnly: e.Operation != walkApply && e.Operation != walkDestroy && e.Operation != walkEval,
BaseDir: ".", // Always current working directory for now.
PlanTimestamp: e.PlanTimestamp,
ProviderFunctions: functions,
ProviderFunctions: providerFuncs,
CustomFunctions: customFunctions,
}
}