hclpack: New package for wire representations of hcl bodies

In most applications it's possible to fully evaluate configuration at the
beginning and work only with resolved values after that, but in some
unusual cases it's necessary to split parsing and decoding between two
separate processes connected by a pipe or network connection.

hclpack is intended to provide compact wire formats for sending bodies
over the network such that they can be decoded and evaluated and get the
same results. This is not something that can happen fully automatically
because a hcl.Body is an abstract node rather than a physical construct,
and so access to the original source code is required to construct such
a representation, and to interpret any source ranges that emerged from
the final evaluation.
This commit is contained in:
Martin Atkins 2018-11-10 09:36:26 -08:00
parent 0a1bd51123
commit 5e07d8e1f9
7 changed files with 582 additions and 0 deletions

24
hclpack/didyoumean.go Normal file
View File

@ -0,0 +1,24 @@
package hclpack
import (
"github.com/agext/levenshtein"
)
// nameSuggestion tries to find a name from the given slice of suggested names
// that is close to the given name and returns it if found. If no suggestion
// is close enough, returns the empty string.
//
// The suggestions are tried in order, so earlier suggestions take precedence
// if the given string is similar to two or more suggestions.
//
// This function is intended to be used with a relatively-small number of
// suggestions. It's not optimized for hundreds or thousands of them.
func nameSuggestion(given string, suggestions []string) string {
for _, suggestion := range suggestions {
dist := levenshtein.Distance(given, suggestion, nil)
if dist < 3 { // threshold determined experimentally
return suggestion
}
}
return ""
}

14
hclpack/doc.go Normal file
View File

@ -0,0 +1,14 @@
// Package hclpack provides a straightforward representation of HCL block/body
// structure that can be easily serialized and deserialized for compact
// transmission (e.g. over a network) without transmitting the full source code.
//
// Expressions are retained in native syntax source form so that their
// evaluation can be delayed until a package structure is decoded by some
// other system that has enough information to populate the evaluation context.
//
// Packed structures retain source location information but do not retain
// actual source code. To make sense of source locations returned in diagnostics
// and via other APIs the caller must somehow gain access to the original source
// code that the packed representation was built from, which is a problem that
// must be solved somehow by the calling application.
package hclpack

104
hclpack/expression.go Normal file
View File

@ -0,0 +1,104 @@
package hclpack
import (
"fmt"
"github.com/hashicorp/hcl2/hcl"
"github.com/hashicorp/hcl2/hcl/hclsyntax"
"github.com/zclconf/go-cty/cty"
)
// Expression is an implementation of hcl.Expression in terms of some raw
// expression source code. The methods of this type will first parse the
// source code and then pass the call through to the real expression that
// is produced.
type Expression struct {
// Source is the raw source code of the expression, which should be parsed
// as the syntax specified by SourceType.
Source []byte
SourceType ExprSourceType
// Range_ and StartRange_ describe the physical extents of the expression
// in the original source code. SourceRange_ is its entire range while
// StartRange is just the tokens that introduce the expression type. For
// simple expression types, SourceRange and StartRange are identical.
Range_, StartRange_ hcl.Range
}
var _ hcl.Expression = (*Expression)(nil)
// Value implements the Value method of hcl.Expression but with the additional
// step of first parsing the expression source code. This implementation is
// unusual in that it can potentially return syntax errors, whereas other
// Value implementations usually work with already-parsed expressions.
func (e *Expression) Value(ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics) {
expr, diags := e.Parse()
if diags.HasErrors() {
return cty.DynamicVal, diags
}
val, moreDiags := expr.Value(ctx)
diags = append(diags, moreDiags...)
return val, diags
}
// Variables implements the Variables method of hcl.Expression but with the
// additional step of first parsing the expression source code.
//
// Since this method cannot return errors, it will return a nil slice if
// parsing fails, indicating that no variables are present. This is okay in
// practice because a subsequent call to Value would fail with syntax errors
// regardless of what variables are in the context.
func (e *Expression) Variables() []hcl.Traversal {
expr, diags := e.Parse()
if diags.HasErrors() {
return nil
}
return expr.Variables()
}
func (e *Expression) Range() hcl.Range {
return e.Range_
}
func (e *Expression) StartRange() hcl.Range {
return e.StartRange_
}
// Parse attempts to parse the source code of the receiving expression using
// its indicated source type, returning the expression if possible and any
// diagnostics produced during parsing.
func (e *Expression) Parse() (hcl.Expression, hcl.Diagnostics) {
switch e.SourceType {
case ExprNative:
return hclsyntax.ParseExpression(e.Source, e.Range_.Filename, e.Range_.Start)
case ExprTemplate:
return hclsyntax.ParseTemplate(e.Source, e.Range_.Filename, e.Range_.Start)
default:
// This should never happen for a valid Expression.
return nil, hcl.Diagnostics{
{
Severity: hcl.DiagError,
Summary: "Invalid expression source type",
Detail: fmt.Sprintf("Packed version of this expression has an invalid source type %s. This is always a bug.", e.SourceType),
Subject: &e.Range_,
},
}
}
}
// ExprSourceType defines the syntax type used for an expression's source code,
// which is then used to select a suitable parser for it when evaluating.
type ExprSourceType rune
//go:generate stringer -type ExprSourceType
const (
// ExprNative indicates that an expression must be parsed as native
// expression syntax, with hclsyntax.ParseExpression.
ExprNative ExprSourceType = 'N'
// ExprTemplate indicates that an expression must be parsed as nave
// template syntax, with hclsyntax.ParseTemplate.
ExprTemplate ExprSourceType = 'T'
)

View File

@ -0,0 +1,70 @@
package hclpack
import (
"testing"
"github.com/hashicorp/hcl2/hcl"
"github.com/zclconf/go-cty/cty"
)
func TestExpressionValue(t *testing.T) {
tests := map[string]struct {
Expr *Expression
Ctx *hcl.EvalContext
Want cty.Value
}{
"simple literal expr": {
&Expression{
Source: []byte(`"hello"`),
SourceType: ExprNative,
},
nil,
cty.StringVal("hello"),
},
"simple literal template": {
&Expression{
Source: []byte(`hello ${5}`),
SourceType: ExprTemplate,
},
nil,
cty.StringVal("hello 5"),
},
"expr with variable": {
&Expression{
Source: []byte(`foo`),
SourceType: ExprNative,
},
&hcl.EvalContext{
Variables: map[string]cty.Value{
"foo": cty.StringVal("bar"),
},
},
cty.StringVal("bar"),
},
"template with variable": {
&Expression{
Source: []byte(`foo ${foo}`),
SourceType: ExprTemplate,
},
&hcl.EvalContext{
Variables: map[string]cty.Value{
"foo": cty.StringVal("bar"),
},
},
cty.StringVal("foo bar"),
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
got, diags := test.Expr.Value(test.Ctx)
for _, diag := range diags {
t.Errorf("unexpected diagnostic: %s", diag.Error())
}
if !test.Want.RawEquals(got) {
t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want)
}
})
}
}

View File

@ -0,0 +1,21 @@
// Code generated by "stringer -type ExprSourceType"; DO NOT EDIT.
package hclpack
import "strconv"
const (
_ExprSourceType_name_0 = "ExprNative"
_ExprSourceType_name_1 = "ExprTemplate"
)
func (i ExprSourceType) String() string {
switch {
case i == 78:
return _ExprSourceType_name_0
case i == 84:
return _ExprSourceType_name_1
default:
return "ExprSourceType(" + strconv.FormatInt(int64(i), 10) + ")"
}
}

256
hclpack/structure.go Normal file
View File

@ -0,0 +1,256 @@
package hclpack
import (
"fmt"
"github.com/hashicorp/hcl2/hcl"
)
// Body is an implementation of hcl.Body.
type Body struct {
Attributes map[string]Attribute
ChildBlocks []Block
MissingItemRange_ hcl.Range
}
var _ hcl.Body = (*Body)(nil)
// Content is an implementation of the method of the same name on hcl.Body.
func (b *Body) Content(schema *hcl.BodySchema) (*hcl.BodyContent, hcl.Diagnostics) {
return b.content(schema, nil)
}
// PartialContent is an implementation of the method of the same name on hcl.Body.
//
// The returned "remain" body may share some backing objects with the receiver,
// so neither the receiver nor the returned remain body, or any descendent
// objects within them, may be mutated after this method is used.
func (b *Body) PartialContent(schema *hcl.BodySchema) (*hcl.BodyContent, hcl.Body, hcl.Diagnostics) {
remain := &Body{}
content, diags := b.content(schema, remain)
return content, remain, diags
}
func (b *Body) content(schema *hcl.BodySchema, remain *Body) (*hcl.BodyContent, hcl.Diagnostics) {
if b == nil {
b = &Body{} // We'll treat a nil body like an empty one, for convenience
}
var diags hcl.Diagnostics
var attrs map[string]*hcl.Attribute
var attrUsed map[string]struct{}
if len(b.Attributes) > 0 {
attrs = make(map[string]*hcl.Attribute, len(b.Attributes))
attrUsed = make(map[string]struct{}, len(b.Attributes))
}
for _, attrS := range schema.Attributes {
name := attrS.Name
attr, exists := b.Attributes[name]
if !exists {
if attrS.Required {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Missing required argument",
Detail: fmt.Sprintf("The argument %q is required, but no definition was found.", attrS.Name),
Subject: &b.MissingItemRange_,
})
}
continue
}
attrs[name] = attr.asHCLAttribute(name)
attrUsed[name] = struct{}{}
}
for name, attr := range b.Attributes {
if _, used := attrUsed[name]; used {
continue
}
if remain != nil {
remain.setAttribute(name, attr)
continue
}
var suggestions []string
for _, attrS := range schema.Attributes {
if _, defined := attrs[name]; defined {
continue
}
suggestions = append(suggestions, attrS.Name)
}
suggestion := nameSuggestion(name, suggestions)
if suggestion != "" {
suggestion = fmt.Sprintf(" Did you mean %q?", suggestion)
} else {
// Is there a block of the same name?
for _, blockS := range schema.Blocks {
if blockS.Type == name {
suggestion = fmt.Sprintf(" Did you mean to define a block of type %q?", name)
break
}
}
}
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Unsupported argument",
Detail: fmt.Sprintf("An argument named %q is not expected here.%s", name, suggestion),
Subject: &attr.NameRange,
})
}
blocksWanted := make(map[string]hcl.BlockHeaderSchema)
for _, blockS := range schema.Blocks {
blocksWanted[blockS.Type] = blockS
}
var blocks []*hcl.Block
for _, block := range b.ChildBlocks {
blockTy := block.Type
blockS, wanted := blocksWanted[blockTy]
if !wanted {
if remain != nil {
remain.appendBlock(block)
continue
}
var suggestions []string
for _, blockS := range schema.Blocks {
suggestions = append(suggestions, blockS.Type)
}
suggestion := nameSuggestion(blockTy, suggestions)
if suggestion != "" {
suggestion = fmt.Sprintf(" Did you mean %q?", suggestion)
} else {
// Is there an attribute of the same name?
for _, attrS := range schema.Attributes {
if attrS.Name == blockTy {
suggestion = fmt.Sprintf(" Did you mean to define argument %q?", blockTy)
break
}
}
}
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Unsupported block type",
Detail: fmt.Sprintf("Blocks of type %q are not expected here.%s", blockTy, suggestion),
Subject: &block.TypeRange,
})
continue
}
if len(block.Labels) != len(blockS.LabelNames) {
if len(blockS.LabelNames) == 0 {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Extraneous label for %s", blockTy),
Detail: fmt.Sprintf(
"No labels are expected for %s blocks.", blockTy,
),
Subject: &block.DefRange,
Context: &block.DefRange,
})
} else {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Wrong label count for %s", blockTy),
Detail: fmt.Sprintf(
"%s blocks expect %d label(s), but got %d.",
blockTy, len(blockS.LabelNames), len(block.Labels),
),
Subject: &block.DefRange,
Context: &block.DefRange,
})
}
continue
}
blocks = append(blocks, block.asHCLBlock())
}
return &hcl.BodyContent{
Attributes: attrs,
Blocks: blocks,
MissingItemRange: b.MissingItemRange_,
}, diags
}
// JustAttributes is an implementation of the method of the same name on hcl.Body.
func (b *Body) JustAttributes() (hcl.Attributes, hcl.Diagnostics) {
var diags hcl.Diagnostics
if len(b.ChildBlocks) > 0 {
for _, block := range b.ChildBlocks {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: fmt.Sprintf("Unexpected %s block", block.Type),
Detail: "Blocks are not allowed here.",
Context: &block.TypeRange,
})
}
// We'll continue processing anyway, and return any attributes we find
// so that the caller can do careful partial analysis.
}
if len(b.Attributes) == 0 {
return nil, diags
}
ret := make(hcl.Attributes, len(b.Attributes))
for n, a := range b.Attributes {
ret[n] = a.asHCLAttribute(n)
}
return ret, diags
}
// MissingItemRange is an implementation of the method of the same name on hcl.Body.
func (b *Body) MissingItemRange() hcl.Range {
return b.MissingItemRange_
}
func (b *Body) setAttribute(name string, attr Attribute) {
if b.Attributes == nil {
b.Attributes = make(map[string]Attribute)
}
b.Attributes[name] = attr
}
func (b *Body) appendBlock(block Block) {
b.ChildBlocks = append(b.ChildBlocks, block)
}
// Block represents a nested block within a body.
type Block struct {
Type string
Labels []string
Body Body
DefRange, TypeRange hcl.Range
LabelRanges []hcl.Range
}
func (b *Block) asHCLBlock() *hcl.Block {
return &hcl.Block{
Type: b.Type,
Labels: b.Labels,
Body: &b.Body,
TypeRange: b.TypeRange,
DefRange: b.DefRange,
LabelRanges: b.LabelRanges,
}
}
// Attribute represents an attribute definition within a body.
type Attribute struct {
Expr Expression
Range, NameRange hcl.Range
}
func (a *Attribute) asHCLAttribute(name string) *hcl.Attribute {
return &hcl.Attribute{
Name: name,
Expr: &a.Expr,
Range: a.Range,
NameRange: a.NameRange,
}
}

93
hclpack/structure_test.go Normal file
View File

@ -0,0 +1,93 @@
package hclpack
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/hcl2/hcl"
)
func TestBodyContent(t *testing.T) {
tests := map[string]struct {
Body *Body
Schema *hcl.BodySchema
Want *hcl.BodyContent
}{
"empty": {
&Body{},
&hcl.BodySchema{},
&hcl.BodyContent{},
},
"nil": {
nil,
&hcl.BodySchema{},
&hcl.BodyContent{},
},
"attribute": {
&Body{
Attributes: map[string]Attribute{
"foo": {
Expr: Expression{
Source: []byte(`"hello"`),
SourceType: ExprNative,
},
},
},
},
&hcl.BodySchema{
Attributes: []hcl.AttributeSchema{
{Name: "foo", Required: true},
{Name: "bar", Required: false},
},
},
&hcl.BodyContent{
Attributes: hcl.Attributes{
"foo": {
Name: "foo",
Expr: &Expression{
Source: []byte(`"hello"`),
SourceType: ExprNative,
},
},
},
},
},
"block": {
&Body{
ChildBlocks: []Block{
{
Type: "foo",
},
},
},
&hcl.BodySchema{
Blocks: []hcl.BlockHeaderSchema{
{Type: "foo"},
},
},
&hcl.BodyContent{
Blocks: hcl.Blocks{
{
Type: "foo",
Body: &Body{},
},
},
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
got, diags := test.Body.Content(test.Schema)
for _, diag := range diags {
t.Errorf("unexpected diagnostic: %s", diag.Error())
}
if !cmp.Equal(test.Want, got) {
t.Errorf("wrong result\n%s", cmp.Diff(test.Want, got))
}
})
}
}