From 26f1e48014b0224ba0be934467ee5e6b0c5567d7 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 25 Jul 2017 18:34:56 -0700 Subject: [PATCH] ext/userfunc: extension for user-defined functions This package provides helper function that looks in a given body for blocks that define functions, returning a function map suitable for use in a zcl.EvalContext. --- ext/userfunc/README.md | 22 +++++ ext/userfunc/decode.go | 137 ++++++++++++++++++++++++++++ ext/userfunc/decode_test.go | 174 ++++++++++++++++++++++++++++++++++++ ext/userfunc/doc.go | 22 +++++ ext/userfunc/public.go | 42 +++++++++ 5 files changed, 397 insertions(+) create mode 100644 ext/userfunc/README.md create mode 100644 ext/userfunc/decode.go create mode 100644 ext/userfunc/decode_test.go create mode 100644 ext/userfunc/doc.go create mode 100644 ext/userfunc/public.go diff --git a/ext/userfunc/README.md b/ext/userfunc/README.md new file mode 100644 index 0000000..71f1b8a --- /dev/null +++ b/ext/userfunc/README.md @@ -0,0 +1,22 @@ +# zcl User Functions Extension + +This zcl extension allows a calling application to support user-defined +functions. + +Functions are defined via a specific block type, like this: + +```zcl +function "add" { + params = ["a", "b"] + result = a + b +} +``` + +The extension is implemented as a pre-processor for `cty.Body` objects. Given +a body that may contain functions, the `DecodeUserFunctions` function searches +for blocks that define functions and returns a functions map suitable for +inclusion in a `zcl.EvalContext`. It also returns a new `cty.Body` that +contains the remainder of the content from the given body, allowing for +further processing of remaining content. + +For more information, see [the godoc reference](http://godoc.org/github.com/zclconf/go-zcl/ext/userfunc). diff --git a/ext/userfunc/decode.go b/ext/userfunc/decode.go new file mode 100644 index 0000000..d1e4ffe --- /dev/null +++ b/ext/userfunc/decode.go @@ -0,0 +1,137 @@ +package userfunc + +import ( + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" + "github.com/zclconf/go-zcl/gozcl" + "github.com/zclconf/go-zcl/zcl" +) + +var funcBodySchema = &zcl.BodySchema{ + Attributes: []zcl.AttributeSchema{ + { + Name: "params", + Required: true, + }, + { + Name: "variadic_param", + Required: false, + }, + { + Name: "result", + Required: true, + }, + }, +} + +func decodeUserFunctions(body zcl.Body, blockType string, contextFunc ContextFunc) (funcs map[string]function.Function, remain zcl.Body, diags zcl.Diagnostics) { + schema := &zcl.BodySchema{ + Blocks: []zcl.BlockHeaderSchema{ + { + Type: blockType, + LabelNames: []string{"name"}, + }, + }, + } + + content, remain, diags := body.PartialContent(schema) + if diags.HasErrors() { + return nil, remain, diags + } + + // first call to getBaseCtx will populate context, and then the same + // context will be used for all subsequent calls. It's assumed that + // all functions in a given body should see an identical context. + var baseCtx *zcl.EvalContext + getBaseCtx := func() *zcl.EvalContext { + if baseCtx == nil { + if contextFunc != nil { + baseCtx = contextFunc() + } + } + // baseCtx might still be nil here, and that's okay + return baseCtx + } + + funcs = make(map[string]function.Function) + for _, block := range content.Blocks { + name := block.Labels[0] + funcContent, funcDiags := block.Body.Content(funcBodySchema) + diags = append(diags, funcDiags...) + if funcDiags.HasErrors() { + continue + } + + paramsExpr := funcContent.Attributes["params"].Expr + resultExpr := funcContent.Attributes["result"].Expr + var varParamExpr zcl.Expression + if funcContent.Attributes["variadic_param"] != nil { + varParamExpr = funcContent.Attributes["variadic_param"].Expr + } + + var params []string + var varParam string + + paramsDiags := gozcl.DecodeExpression(paramsExpr, nil, ¶ms) + diags = append(diags, paramsDiags...) + if paramsDiags.HasErrors() { + continue + } + if varParamExpr != nil { + paramsDiags := gozcl.DecodeExpression(varParamExpr, nil, &varParam) + diags = append(diags, paramsDiags...) + if paramsDiags.HasErrors() { + continue + } + } + + spec := &function.Spec{} + for _, paramName := range params { + spec.Params = append(spec.Params, function.Parameter{ + Name: paramName, + Type: cty.DynamicPseudoType, + }) + } + if varParamExpr != nil { + spec.VarParam = &function.Parameter{ + Name: varParam, + Type: cty.DynamicPseudoType, + } + } + impl := func(args []cty.Value) (cty.Value, error) { + ctx := getBaseCtx() + ctx = ctx.NewChild() + ctx.Variables = make(map[string]cty.Value) + + // The cty function machinery guarantees that we have at least + // enough args to fill all of our params. + for i, paramName := range params { + ctx.Variables[paramName] = args[i] + } + if spec.VarParam != nil { + varArgs := args[len(params):] + ctx.Variables[varParam] = cty.TupleVal(varArgs) + } + + result, diags := resultExpr.Value(ctx) + if diags.HasErrors() { + // Smuggle the diagnostics out via the error channel, since + // a diagnostics sequence implements error. Caller can + // type-assert this to recover the individual diagnostics + // if desired. + return cty.DynamicVal, diags + } + return result, nil + } + spec.Type = func(args []cty.Value) (cty.Type, error) { + val, err := impl(args) + return val.Type(), err + } + spec.Impl = func(args []cty.Value, retType cty.Type) (cty.Value, error) { + return impl(args) + } + funcs[name] = function.New(spec) + } + + return funcs, remain, diags +} diff --git a/ext/userfunc/decode_test.go b/ext/userfunc/decode_test.go new file mode 100644 index 0000000..2533979 --- /dev/null +++ b/ext/userfunc/decode_test.go @@ -0,0 +1,174 @@ +package userfunc + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-zcl/zcl" + "github.com/zclconf/go-zcl/zcl/zclsyntax" +) + +func TestDecodeUserFunctions(t *testing.T) { + tests := []struct { + src string + testExpr string + baseCtx *zcl.EvalContext + want cty.Value + diagCount int + }{ + { + ` +function "greet" { + params = ["name"] + result = "Hello, ${name}." +} +`, + `greet("Ermintrude")`, + nil, + cty.StringVal("Hello, Ermintrude."), + 0, + }, + { + ` +function "greet" { + params = ["name"] + result = "Hello, ${name}." +} +`, + `greet()`, + nil, + cty.DynamicVal, + 1, // missing value for "name" + }, + { + ` +function "greet" { + params = ["name"] + result = "Hello, ${name}." +} +`, + `greet("Ermintrude", "extra")`, + nil, + cty.DynamicVal, + 1, // too many arguments + }, + { + ` +function "add" { + params = ["a", "b"] + result = a + b +} +`, + `add(1, 5)`, + nil, + cty.NumberIntVal(6), + 0, + }, + { + ` +function "argstuple" { + params = [] + variadic_param = "args" + result = args +} +`, + `argstuple("a", true, 1)`, + nil, + cty.TupleVal([]cty.Value{cty.StringVal("a"), cty.True, cty.NumberIntVal(1)}), + 0, + }, + { + ` +function "missing_var" { + params = [] + result = nonexist +} +`, + `missing_var()`, + nil, + cty.DynamicVal, + 1, // no variable named "nonexist" + }, + { + ` +function "closure" { + params = [] + result = upvalue +} +`, + `closure()`, + &zcl.EvalContext{ + Variables: map[string]cty.Value{ + "upvalue": cty.True, + }, + }, + cty.True, + 0, + }, + { + ` +function "neg" { + params = ["val"] + result = -val +} +function "add" { + params = ["a", "b"] + result = a + b +} +`, + `neg(add(1, 3))`, + nil, + cty.NumberIntVal(-4), + 0, + }, + { + ` +function "neg" { + parrams = ["val"] + result = -val +} +`, + `null`, + nil, + cty.NullVal(cty.DynamicPseudoType), + 2, // missing attribute "params", and unknown attribute "parrams" + }, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("%02d", i), func(t *testing.T) { + f, diags := zclsyntax.ParseConfig([]byte(test.src), "config", zcl.Pos{Line: 1, Column: 1}) + if f == nil || f.Body == nil { + t.Fatalf("got nil file or body") + } + + funcs, _, funcsDiags := decodeUserFunctions(f.Body, "function", func() *zcl.EvalContext { + return test.baseCtx + }) + diags = append(diags, funcsDiags...) + + expr, exprParseDiags := zclsyntax.ParseExpression([]byte(test.testExpr), "testexpr", zcl.Pos{Line: 1, Column: 1}) + diags = append(diags, exprParseDiags...) + if expr == nil { + t.Fatalf("parsing test expr returned nil") + } + + got, exprDiags := expr.Value(&zcl.EvalContext{ + Functions: funcs, + }) + diags = append(diags, exprDiags...) + + 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) + } + } + + if !got.RawEquals(test.want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want) + } + }) + } +} diff --git a/ext/userfunc/doc.go b/ext/userfunc/doc.go new file mode 100644 index 0000000..b8cdf09 --- /dev/null +++ b/ext/userfunc/doc.go @@ -0,0 +1,22 @@ +// Package userfunc implements a zcl extension that allows user-defined +// functions in zcl configuration. +// +// Using this extension requires some integration effort on the part of the +// calling application, to pass any declared functions into a zcl evaluation +// context after processing. +// +// The function declaration syntax looks like this: +// +// function "foo" { +// params = ["name"] +// result = "Hello, ${name}!" +// } +// +// When a user-defined function is called, the expression given for the "result" +// attribute is evaluated in an isolated evaluation context that defines variables +// named after the given parameter names. +// +// The block name "function" may be overridden by the calling application, if +// that default name conflicts with an existing block or attribute name in +// the application. +package userfunc diff --git a/ext/userfunc/public.go b/ext/userfunc/public.go new file mode 100644 index 0000000..169278b --- /dev/null +++ b/ext/userfunc/public.go @@ -0,0 +1,42 @@ +package userfunc + +import ( + "github.com/zclconf/go-cty/cty/function" + "github.com/zclconf/go-zcl/zcl" +) + +// A ContextFunc is a callback used to produce the base EvalContext for +// running a particular set of functions. +// +// This is a function rather than an EvalContext directly to allow functions +// to be decoded before their context is complete. This will be true, for +// example, for applications that wish to allow functions to refer to themselves. +// +// The simplest use of a ContextFunc is to give user functions access to the +// same global variables and functions available elsewhere in an application's +// configuration language, but more complex applications may use different +// contexts to support lexical scoping depending on where in a configuration +// structure a function declaration is found, etc. +type ContextFunc func() *zcl.EvalContext + +// DecodeUserFunctions looks for blocks of the given type in the given body +// and, for each one found, interprets it as a custom function definition. +// +// On success, the result is a mapping of function names to implementations, +// along with a new body that represents the remaining content of the given +// body which can be used for further processing. +// +// The result expression of each function is parsed during decoding but not +// evaluated until the function is called. +// +// If the given ContextFunc is non-nil, it will be called to obtain the +// context in which the function result expressions will be evaluated. If nil, +// or if it returns nil, the result expression will have access only to +// variables named after the declared parameters. A non-nil context turns +// the returned functions into closures, bound to the given context. +// +// If the returned diagnostics set has errors then the function map and +// remain body may be nil or incomplete. +func DecodeUserFunctions(body zcl.Body, blockType string, context ContextFunc) (funcs map[string]function.Function, remain zcl.Body, diags zcl.Diagnostics) { + return decodeUserFunctions(body, blockType, context) +}