hclhil: interface to treat HIL templates as expressions

This commit is contained in:
Martin Atkins 2017-05-21 22:09:17 -07:00
parent fde586e193
commit e4fdbb6b15
4 changed files with 634 additions and 4 deletions

View File

@ -49,3 +49,21 @@ func ParseFile(filename string) (*zcl.File, zcl.Diagnostics) {
return Parse(src, filename)
}
// ParseTemplate attempts to parse the given buffer as a HIL template and,
// if successful, returns a zcl.Expression for the value represented by it.
//
// The returned file is valid only if the returned diagnostics returns false
// from its HasErrors method. If HasErrors returns true, the file represents
// the subset of data that was able to be parsed, which may be none.
func ParseTemplate(src []byte, filename string) (zcl.Expression, zcl.Diagnostics) {
return parseTemplate(src, filename, zcl.Pos{Line: 1, Column: 1})
}
// ParseTemplateEmbedded is like ParseTemplate but is for templates that are
// embedded in a file in another language. Practically-speaking this just
// offsets the source positions returned in diagnostics, etc to be relative
// to the given position.
func ParseTemplateEmbedded(src []byte, filename string, startPos zcl.Pos) (zcl.Expression, zcl.Diagnostics) {
return parseTemplate(src, filename, startPos)
}

View File

@ -4,6 +4,8 @@ import (
"github.com/apparentlymart/go-zcl/zcl"
hclparser "github.com/hashicorp/hcl/hcl/parser"
hcltoken "github.com/hashicorp/hcl/hcl/token"
hilast "github.com/hashicorp/hil/ast"
hilparser "github.com/hashicorp/hil/parser"
)
// errorRange attempts to extract a source range from the given error,
@ -12,12 +14,16 @@ import (
// errorRange understands HCL's "PosError" type, which wraps an error
// with a source position.
func errorRange(err error) *zcl.Range {
if perr, ok := err.(*hclparser.PosError); ok {
rng := rangeFromHCLPos(perr.Pos)
switch terr := err.(type) {
case *hclparser.PosError:
rng := rangeFromHCLPos(terr.Pos)
return &rng
case *hilparser.ParseError:
rng := rangeFromHILPos(terr.Pos)
return &rng
default:
return nil
}
return nil
}
func rangeFromHCLPos(pos hcltoken.Pos) zcl.Range {
@ -37,3 +43,25 @@ func rangeFromHCLPos(pos hcltoken.Pos) zcl.Range {
},
}
}
func rangeFromHILPos(pos hilast.Pos) zcl.Range {
// HIL only marks single positions rather than ranges, so we adapt this
// by creating a single-character range at the given position.
// HIL also doesn't track byte offsets, so we will hard-code these to
// zero so that no position can be considered to be "inside" these
// from a byte offset perspective.
return zcl.Range{
Filename: pos.Filename,
Start: zcl.Pos{
Byte: 0,
Line: pos.Line,
Column: pos.Column,
},
End: zcl.Pos{
Byte: 0,
Line: pos.Line,
Column: pos.Column + 1,
},
}
}

397
zcl/hclhil/template.go Normal file
View File

@ -0,0 +1,397 @@
package hclhil
import (
"fmt"
"strconv"
"github.com/apparentlymart/go-cty/cty"
"github.com/apparentlymart/go-cty/cty/function"
"github.com/apparentlymart/go-zcl/zcl"
"github.com/hashicorp/hil"
hilast "github.com/hashicorp/hil/ast"
)
func parseTemplate(src []byte, filename string, startPos zcl.Pos) (zcl.Expression, zcl.Diagnostics) {
hilStartPos := hilast.Pos{
Filename: filename,
Line: startPos.Line,
Column: startPos.Column,
// HIL positions don't have byte offsets, so we ignore startPos.Byte here
}
rootNode, err := hil.ParseWithPosition(string(src), hilStartPos)
if err != nil {
return nil, zcl.Diagnostics{
{
Severity: zcl.DiagError,
Summary: "Syntax error in template",
Detail: fmt.Sprintf("The template could not be parsed: %s", err),
Subject: errorRange(err),
},
}
}
return &templateExpression{
node: rootNode,
}, nil
}
type templateExpression struct {
node hilast.Node
}
func (e *templateExpression) Value(ctx *zcl.EvalContext) (cty.Value, zcl.Diagnostics) {
cfg := hilEvalConfig(ctx)
return ctyValueFromHILNode(e.node, cfg)
}
func (e *templateExpression) Range() zcl.Range {
return rangeFromHILPos(e.node.Pos())
}
func (e *templateExpression) StartRange() zcl.Range {
return rangeFromHILPos(e.node.Pos())
}
func hilEvalConfig(ctx *zcl.EvalContext) *hil.EvalConfig {
cfg := &hil.EvalConfig{
GlobalScope: &hilast.BasicScope{
VarMap: map[string]hilast.Variable{},
FuncMap: map[string]hilast.Function{},
},
}
if ctx == nil {
return cfg
}
if ctx.Variables != nil {
for name, val := range ctx.Variables {
cfg.GlobalScope.VarMap[name] = hilVariableForInput(hilVariableFromCtyValue(val))
}
}
if ctx.Functions != nil {
for name, hf := range ctx.Functions {
cfg.GlobalScope.FuncMap[name] = hilFunctionFromCtyFunction(hf)
}
}
return cfg
}
func ctyValueFromHILNode(node hilast.Node, cfg *hil.EvalConfig) (cty.Value, zcl.Diagnostics) {
result, err := hil.Eval(node, cfg)
if err != nil {
return cty.DynamicVal, zcl.Diagnostics{
{
Severity: zcl.DiagError,
Summary: "Template evaluation failed",
Detail: fmt.Sprintf("Error while evaluating template: %s", err),
Subject: rangeFromHILPos(node.Pos()).Ptr(),
},
}
}
return ctyValueFromHILResult(result), nil
}
func ctyValueFromHILResult(result hil.EvaluationResult) cty.Value {
switch result.Type {
case hil.TypeString:
return cty.StringVal(result.Value.(string))
case hil.TypeBool:
return cty.BoolVal(result.Value.(bool))
case hil.TypeList:
varsI := result.Value.([]interface{})
if len(varsI) == 0 {
return cty.ListValEmpty(cty.String)
}
vals := make([]cty.Value, len(varsI))
for i, varI := range varsI {
hv, err := hil.InterfaceToVariable(varI)
if err != nil {
panic("HIL returned type that can't be converted back to variable")
}
vals[i] = ctyValueFromHILVariable(hv)
}
return cty.TupleVal(vals)
case hil.TypeMap:
varsI := result.Value.(map[string]interface{})
if len(varsI) == 0 {
return cty.MapValEmpty(cty.String)
}
vals := make(map[string]cty.Value)
for key, varI := range varsI {
hv, err := hil.InterfaceToVariable(varI)
if err != nil {
panic("HIL returned type that can't be converted back to variable")
}
vals[key] = ctyValueFromHILVariable(hv)
}
return cty.ObjectVal(vals)
case hil.TypeUnknown:
// HIL doesn't have typed unknowns, so we have to return dynamic
return cty.DynamicVal
default:
// should never happen
panic(fmt.Sprintf("unsupported EvaluationResult type %s", result.Type))
}
}
func ctyValueFromHILVariable(vr hilast.Variable) cty.Value {
switch vr.Type {
case hilast.TypeBool:
return cty.BoolVal(vr.Value.(bool))
case hilast.TypeString:
return cty.StringVal(vr.Value.(string))
case hilast.TypeInt:
return cty.NumberIntVal(vr.Value.(int64))
case hilast.TypeFloat:
return cty.NumberFloatVal(vr.Value.(float64))
case hilast.TypeList:
vars := vr.Value.([]hilast.Variable)
if len(vars) == 0 {
return cty.ListValEmpty(cty.String)
}
vals := make([]cty.Value, len(vars))
for i, v := range vars {
vals[i] = ctyValueFromHILVariable(v)
}
return cty.TupleVal(vals)
case hilast.TypeMap:
vars := vr.Value.(map[string]hilast.Variable)
if len(vars) == 0 {
return cty.MapValEmpty(cty.String)
}
vals := make(map[string]cty.Value)
for key, v := range vars {
vals[key] = ctyValueFromHILVariable(v)
}
return cty.ObjectVal(vals)
case hilast.TypeAny, hilast.TypeUnknown:
return cty.DynamicVal
default:
// should never happen
panic(fmt.Sprintf("unsupported HIL Variable type %s", vr.Type))
}
}
func hilVariableFromCtyValue(val cty.Value) hilast.Variable {
if !val.IsKnown() {
return hilast.Variable{
Type: hilast.TypeUnknown,
Value: hil.UnknownValue,
}
}
if val.IsNull() {
// HIL doesn't actually support nulls, so we'll cheat a bit and
// use an unknown. This is not quite right since nulls are supposed
// to fail when evaluated, but it should suffice as a compatibility
// shim since HIL-using applications probably won't be generating
// nulls anyway.
return hilast.Variable{
Type: hilast.TypeUnknown,
Value: hil.UnknownValue,
}
}
ty := val.Type()
switch ty {
case cty.String:
return hilast.Variable{
Type: hilast.TypeString,
Value: val.AsString(),
}
case cty.Number:
// cty doesn't distinguish between floats and ints, so we'll
// just always use floats here and depend on automatic conversions
// to produce ints where needed.
bf := val.AsBigFloat()
f, _ := bf.Float64()
return hilast.Variable{
Type: hilast.TypeFloat,
Value: f,
}
case cty.Bool:
return hilast.Variable{
Type: hilast.TypeBool,
Value: val.True(),
}
}
switch {
case ty.IsListType() || ty.IsSetType() || ty.IsTupleType():
// HIL doesn't have sets, so we'll just turn them into lists
// HIL doesn't support tuples either, so any tuples without consistent
// element types will fail HIL's check for consistent types, but that's
// okay since we don't intend to change HIL semantics here.
vars := []hilast.Variable{}
it := val.ElementIterator()
for it.Next() {
_, ev := it.Element()
vars = append(vars, hilVariableFromCtyValue(ev))
}
return hilast.Variable{
Type: hilast.TypeList,
Value: vars,
}
case ty.IsMapType():
vars := map[string]hilast.Variable{}
it := val.ElementIterator()
for it.Next() {
kv, ev := it.Element()
k := kv.AsString()
vars[k] = hilVariableFromCtyValue(ev)
}
return hilast.Variable{
Type: hilast.TypeMap,
Value: vars,
}
case ty.IsObjectType():
// HIL doesn't support objects, so objects that don't have consistent
// attribute types will fail HIL's check for consistent types. That's
// okay since we don't intend to change HIL semantics here.
vars := map[string]interface{}{}
atys := ty.AttributeTypes()
for k := range atys {
vars[k] = hilVariableFromCtyValue(val.GetAttr(k))
}
return hilast.Variable{
Type: hilast.TypeMap,
Value: vars,
}
case ty.IsCapsuleType():
// Can't do anything reasonable with capsule types, so we'll just
// treat them as unknown and let the caller deal with it as an error.
return hilast.Variable{
Type: hilast.TypeUnknown,
Value: hil.UnknownValue,
}
default:
// Should never happen if we've done our job right here
panic(fmt.Sprintf("don't know how to convert %#v into a HIL variable", ty))
}
}
// hilVariableForInput constrains the given variable to be of the types HIL
// accepts as input, which entails converting all primitive types to string.
func hilVariableForInput(v hilast.Variable) hilast.Variable {
switch v.Type {
case hilast.TypeFloat:
return hilast.Variable{
Type: hilast.TypeString,
Value: strconv.FormatFloat(v.Value.(float64), 'f', -1, 64),
}
case hilast.TypeBool:
if v.Value.(bool) {
return hilast.Variable{
Type: hilast.TypeString,
Value: "true",
}
} else {
return hilast.Variable{
Type: hilast.TypeString,
Value: "false",
}
}
case hilast.TypeList:
inVars := v.Value.([]hilast.Variable)
outVars := make([]hilast.Variable, len(inVars))
for i, inVar := range inVars {
outVars[i] = hilVariableForInput(inVar)
}
return hilast.Variable{
Type: hilast.TypeList,
Value: outVars,
}
case hilast.TypeMap:
inVars := v.Value.(map[string]hilast.Variable)
outVars := make(map[string]hilast.Variable)
for k, inVar := range inVars {
outVars[k] = hilVariableForInput(inVar)
}
return hilast.Variable{
Type: hilast.TypeMap,
Value: outVars,
}
default:
return v
}
}
func hilTypeFromCtyType(ty cty.Type) hilast.Type {
switch ty {
case cty.String:
return hilast.TypeString
case cty.Number:
return hilast.TypeFloat
case cty.Bool:
return hilast.TypeBool
case cty.DynamicPseudoType:
// Assume we're using this as a type specification, so we'd rather
// have TypeAny than TypeUnknown.
return hilast.TypeAny
}
switch {
case ty.IsListType() || ty.IsSetType() || ty.IsTupleType():
return hilast.TypeList
case ty.IsMapType(), ty.IsObjectType():
return hilast.TypeMap
default:
return hilast.TypeUnknown
}
}
func hilFunctionFromCtyFunction(f function.Function) hilast.Function {
hf := hilast.Function{}
params := f.Params()
varParam := f.VarParam()
hf.ArgTypes = make([]hilast.Type, len(params))
staticTypes := make([]cty.Type, len(params))
for i, param := range params {
hf.ArgTypes[i] = hilTypeFromCtyType(param.Type)
staticTypes[i] = param.Type
}
if varParam != nil {
hf.Variadic = true
hf.VariadicType = hilTypeFromCtyType(varParam.Type)
}
retType, err := f.ReturnType(staticTypes)
if err == nil {
hf.ReturnType = hilTypeFromCtyType(retType)
} else {
hf.ReturnType = hilTypeFromCtyType(cty.DynamicPseudoType)
}
hf.Callback = func(hilArgs []interface{}) (interface{}, error) {
args := make([]cty.Value, len(hilArgs))
for i, hilArg := range hilArgs {
var hilType hilast.Type
if i < len(hf.ArgTypes) {
hilType = hf.ArgTypes[i]
} else {
hilType = hf.VariadicType
}
args[i] = ctyValueFromHILVariable(hilast.Variable{
Type: hilType,
Value: hilArg,
})
}
result, err := f.Call(args)
if err != nil {
return nil, err
}
hilResult := hilVariableFromCtyValue(result)
return hilResult.Value, nil
}
return hf
}

187
zcl/hclhil/template_test.go Normal file
View File

@ -0,0 +1,187 @@
package hclhil
import (
"testing"
"github.com/apparentlymart/go-cty/cty"
"github.com/apparentlymart/go-cty/cty/function"
"github.com/apparentlymart/go-cty/cty/function/stdlib"
"github.com/apparentlymart/go-zcl/zcl"
)
func TestTemplateExpression(t *testing.T) {
tests := []struct {
input string
ctx *zcl.EvalContext
want cty.Value
diagCount int
}{
{
``,
nil,
cty.StringVal(""),
0,
},
{
`hello`,
nil,
cty.StringVal("hello"),
0,
},
{
`hello ${"world"}`,
nil,
cty.StringVal("hello world"),
0,
},
{
`${"hello"}`,
nil,
cty.StringVal("hello"),
0,
},
{
`Hello ${planet}!`,
&zcl.EvalContext{
Variables: map[string]cty.Value{
"planet": cty.StringVal("Earth"),
},
},
cty.StringVal("Hello Earth!"),
0,
},
{
`${names}`,
&zcl.EvalContext{
Variables: map[string]cty.Value{
"names": cty.ListVal([]cty.Value{
cty.StringVal("Ermintrude"),
cty.StringVal("Tom"),
}),
},
},
cty.TupleVal([]cty.Value{
cty.StringVal("Ermintrude"),
cty.StringVal("Tom"),
}),
0,
},
{
`${doodads}`,
&zcl.EvalContext{
Variables: map[string]cty.Value{
"doodads": cty.MapVal(map[string]cty.Value{
"Captain": cty.StringVal("Ermintrude"),
"First Officer": cty.StringVal("Tom"),
}),
},
},
cty.ObjectVal(map[string]cty.Value{
"Captain": cty.StringVal("Ermintrude"),
"First Officer": cty.StringVal("Tom"),
}),
0,
},
{
`${names}`,
&zcl.EvalContext{
Variables: map[string]cty.Value{
"names": cty.TupleVal([]cty.Value{
cty.StringVal("Ermintrude"),
cty.NumberIntVal(5),
}),
},
},
cty.TupleVal([]cty.Value{
cty.StringVal("Ermintrude"),
cty.StringVal("5"),
}),
0,
},
{
`${messytuple}`,
&zcl.EvalContext{
Variables: map[string]cty.Value{
"messytuple": cty.TupleVal([]cty.Value{
cty.StringVal("Ermintrude"),
cty.ListValEmpty(cty.String),
}),
},
},
cty.TupleVal([]cty.Value{
cty.StringVal("Ermintrude"),
cty.ListValEmpty(cty.String), // HIL's sloppy type checker actually lets us get away with this
}),
0,
},
{
`number ${num}`,
&zcl.EvalContext{
Variables: map[string]cty.Value{
"num": cty.NumberIntVal(5),
},
},
cty.StringVal("number 5"),
0,
},
{
`${length("hello")}`,
&zcl.EvalContext{
Functions: map[string]function.Function{
"length": stdlib.StrlenFunc,
},
},
cty.StringVal("5"), // HIL always stringifies numbers on output
0,
},
{
`${true}`,
nil,
cty.StringVal("true"), // HIL always stringifies bools on output
0,
},
{
`cannot ${names}`,
&zcl.EvalContext{
Variables: map[string]cty.Value{
"names": cty.ListVal([]cty.Value{
cty.StringVal("Ermintrude"),
cty.StringVal("Tom"),
}),
},
},
cty.DynamicVal,
1, // can't concatenate a list
},
{
`${syntax error`,
nil,
cty.NilVal,
1,
},
}
for _, test := range tests {
t.Run(test.input, func(t *testing.T) {
expr, diags := ParseTemplate([]byte(test.input), "test.hil")
if expr != nil {
val, valDiags := expr.Value(test.ctx)
diags = append(diags, valDiags...)
if !val.RawEquals(test.want) {
t.Errorf("wrong result\ngot: %#v\nwant: %#v", val, test.want)
}
} else {
if test.want != cty.NilVal {
t.Errorf("Unexpected diagnostics during parse: %s", diags.Error())
}
}
if len(diags) != test.diagCount {
t.Errorf("wrong number of diagnostics %d; want %d", len(diags), test.diagCount)
for _, diag := range diags {
t.Logf(" - %s", diag.Error())
}
}
})
}
}