diff --git a/hclpack/didyoumean.go b/hclpack/didyoumean.go new file mode 100644 index 0000000..ec4aa79 --- /dev/null +++ b/hclpack/didyoumean.go @@ -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 "" +} diff --git a/hclpack/doc.go b/hclpack/doc.go new file mode 100644 index 0000000..5e7f76b --- /dev/null +++ b/hclpack/doc.go @@ -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 diff --git a/hclpack/expression.go b/hclpack/expression.go new file mode 100644 index 0000000..480c8f7 --- /dev/null +++ b/hclpack/expression.go @@ -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' +) diff --git a/hclpack/expression_test.go b/hclpack/expression_test.go new file mode 100644 index 0000000..07048a8 --- /dev/null +++ b/hclpack/expression_test.go @@ -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) + } + }) + } +} diff --git a/hclpack/exprsourcetype_string.go b/hclpack/exprsourcetype_string.go new file mode 100644 index 0000000..55b8f2e --- /dev/null +++ b/hclpack/exprsourcetype_string.go @@ -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) + ")" + } +} diff --git a/hclpack/structure.go b/hclpack/structure.go new file mode 100644 index 0000000..a9038bd --- /dev/null +++ b/hclpack/structure.go @@ -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, + } +} diff --git a/hclpack/structure_test.go b/hclpack/structure_test.go new file mode 100644 index 0000000..0d5f11c --- /dev/null +++ b/hclpack/structure_test.go @@ -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)) + } + }) + } + +}