From 3406e0cceafaba2d86cdb97cef17e6a76f467efe Mon Sep 17 00:00:00 2001 From: James Pogran Date: Fri, 23 Sep 2022 09:56:43 -0400 Subject: [PATCH] Support count and count.index completion in blocks This provides completion hints inside blocks for `count.index` references within resource, data and module blocks anywhere the `count` meta-argument is supported. It detects if count is used already and does not suggest duplicates. --- context/context.go | 30 +++ decoder/body_candidates.go | 36 +++ decoder/body_extensions_test.go | 387 +++++++++++++++++++++++++++++++ decoder/candidates.go | 11 + decoder/candidates_test.go | 16 +- decoder/expression_candidates.go | 27 ++- schema/body_schema.go | 24 ++ 7 files changed, 521 insertions(+), 10 deletions(-) create mode 100644 context/context.go create mode 100644 decoder/body_extensions_test.go diff --git a/context/context.go b/context/context.go new file mode 100644 index 00000000..ffa489b3 --- /dev/null +++ b/context/context.go @@ -0,0 +1,30 @@ +package context + +import ( + "context" + + "github.com/hashicorp/hcl-lang/schema" +) + +type bodyExtCtxKey struct{} + +type bodyActiveCountCtxKey struct{} + +func WithExtensions(ctx context.Context, ext *schema.BodyExtensions) context.Context { + return context.WithValue(ctx, bodyExtCtxKey{}, ext) +} + +func WithActiveCount(ctx context.Context) context.Context { + return context.WithValue(ctx, bodyActiveCountCtxKey{}, true) +} + +func ExtensionsFromContext(ctx context.Context) (*schema.BodyExtensions, bool) { + ext, ok := ctx.Value(bodyExtCtxKey{}).(*schema.BodyExtensions) + return ext, ok +} + +func ActiveCountFromContext(ctx context.Context) bool { + // _, ok := ctx.Value(bodyActiveCountCtxKey{}).(bool) + // return ok + return ctx.Value(bodyActiveCountCtxKey{}) != nil +} diff --git a/decoder/body_candidates.go b/decoder/body_candidates.go index 6f388f96..8707cb41 100644 --- a/decoder/body_candidates.go +++ b/decoder/body_candidates.go @@ -17,6 +17,18 @@ func (d *PathDecoder) bodySchemaCandidates(body *hclsyntax.Body, schema *schema. candidates := lang.NewCandidates() count := 0 + if schema.Extensions != nil { + // check if this schema supports Count attribute + if schema.Extensions.Count { + countPresent := isAttributePresent(body, "count") + // check if Count is already used inside this body, so we don't + // suggest a duplicate + if !countPresent { + candidates.List = append(candidates.List, countAttributeCandidate(editRng)) + } + } + } + if len(schema.Attributes) > 0 { attrNames := sortedAttributeNames(schema.Attributes) for _, name := range attrNames { @@ -135,3 +147,27 @@ func isBlockDeclarable(body *hclsyntax.Body, blockType string, bSchema *schema.B } return true } + +func isAttributePresent(body *hclsyntax.Body, attributeName string) bool { + attributePresent := false + for attrName := range body.Attributes { + if attrName == attributeName { + attributePresent = true + } + } + return attributePresent +} + +func countAttributeCandidate(editRng hcl.Range) lang.Candidate { + return lang.Candidate{ + Label: "count", + Detail: "optional, number", + Description: lang.PlainText("The distinct index number (starting with 0) corresponding to the instance"), + Kind: lang.AttributeCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "count", + Snippet: "count = ${1:1}", + Range: editRng, + }, + } +} diff --git a/decoder/body_extensions_test.go b/decoder/body_extensions_test.go new file mode 100644 index 00000000..8879c748 --- /dev/null +++ b/decoder/body_extensions_test.go @@ -0,0 +1,387 @@ +package decoder + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" +) + +func TestCompletionAtPos_BodySchema_Extensions(t *testing.T) { + ctx := context.Background() + + testCases := []struct { + testName string + bodySchema *schema.BodySchema + cfg string + pos hcl.Pos + expectedCandidates lang.Candidates + }{ + { + "count attribute completion", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + { + Name: "aws_instance", + }, + { + Name: "foo", + }, + }, + Body: &schema.BodySchema{ + Extensions: &schema.BodyExtensions{ + Count: true, + }, + }, + }, + }, + }, + `resource "aws_instance" "foo" { + +}`, + hcl.Pos{ + Line: 2, + Column: 1, + Byte: 32, + }, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "count", + Description: lang.MarkupContent{ + Value: "The distinct index number (starting with 0) corresponding to the instance", + Kind: lang.PlainTextKind, + }, + Detail: "optional, number", + TextEdit: lang.TextEdit{Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 2, + Column: 1, + Byte: 32, + }, + End: hcl.Pos{ + Line: 2, + Column: 1, + Byte: 32, + }, + }, NewText: "count", Snippet: "count = ${1:1}"}, + Kind: lang.AttributeCandidateKind, + }, + }), + }, + { + "count attribute completion does not complete count when extensions not enabled", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + { + Name: "aws_instance", + }, + { + Name: "foo", + }, + }, + Body: &schema.BodySchema{ + Extensions: &schema.BodyExtensions{ + Count: false, + }, + }, + }, + }, + }, + `resource "aws_instance" "foo" { + +}`, + hcl.Pos{ + Line: 2, + Column: 1, + Byte: 32, + }, + lang.CompleteCandidates([]lang.Candidate{}), + }, + { + "count.index does not complete when not needed", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + { + Name: "aws_instance", + }, + { + Name: "foo", + }, + }, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "cpu_count": { + IsOptional: true, + Expr: schema.ExprConstraints{ + schema.TraversalExpr{ + OfType: cty.Number, + }, + }, + }, + }, + Extensions: &schema.BodyExtensions{ + Count: true, + }, + }, + }, + }, + }, + `resource "aws_instance" "foo" { + cpu_count = +}`, + hcl.Pos{ + Line: 2, + Column: 8, + Byte: 44, + }, + lang.CompleteCandidates([]lang.Candidate{}), + }, + { + "count.index value completion", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + { + Name: "aws_instance", + }, + { + Name: "foo", + }, + }, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "cpu_count": { + IsOptional: true, + Expr: schema.ExprConstraints{ + schema.TraversalExpr{ + OfType: cty.Number, + }, + }, + }, + }, + Extensions: &schema.BodyExtensions{ + Count: true, + }, + }, + }, + }, + }, + `resource "aws_instance" "foo" { + count = 4 + cpu_count = +}`, + hcl.Pos{ + Line: 3, + Column: 14, + Byte: 55, + }, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "count.index", + Description: lang.MarkupContent{ + Value: "The distinct index number (starting with 0) corresponding to the instance", + Kind: lang.PlainTextKind, + }, + Detail: "number", + TextEdit: lang.TextEdit{Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 3, + Column: 13, + Byte: 55, + }, + End: hcl.Pos{ + Line: 3, + Column: 13, + Byte: 55, + }, + }, NewText: "count.index", Snippet: "count.index"}, + Kind: lang.TraversalCandidateKind, + }, + }), + }, + { + "count does not complete more than once", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + { + Name: "aws_instance", + }, + { + Name: "foo", + }, + }, + Body: &schema.BodySchema{ + Extensions: &schema.BodyExtensions{ + Count: true, + }, + }, + }, + }, + }, + `resource "aws_instance" "foo" { + count = 4 + +}`, + hcl.Pos{ + Line: 3, + Column: 1, + Byte: 43, + }, + lang.CompleteCandidates([]lang.Candidate{}), + }, + { + "count.index does not complete when extension not enabled", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + { + Name: "aws_instance", + }, + { + Name: "foo", + }, + }, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "cpu_count": { + IsOptional: true, + Expr: schema.ExprConstraints{ + schema.TraversalExpr{ + OfType: cty.Number, + }, + }, + }, + }, + Extensions: &schema.BodyExtensions{ + Count: false, + }, + }, + }, + }, + }, + `resource "aws_instance" "foo" { + cpu_count = +}`, + hcl.Pos{ + Line: 2, + Column: 8, + Byte: 44, + }, + lang.CompleteCandidates([]lang.Candidate{}), + }, + { + "count.index completes when inside nested blocks", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + { + Name: "aws_instance", + }, + { + Name: "foo", + }, + }, + Body: &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "foo": { + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "cpu_count": { + IsOptional: true, + Expr: schema.ExprConstraints{ + schema.TraversalExpr{ + OfType: cty.Number, + }, + }, + }, + }, + }, + }, + }, + Extensions: &schema.BodyExtensions{ + Count: true, + }, + }, + }, + }, + }, + `resource "aws_instance" "foo" { + count = 4 + foo { + cpu_count = + } +}`, + hcl.Pos{ + Line: 4, + Column: 17, + Byte: 67, + }, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "count.index", + Description: lang.MarkupContent{ + Value: "The distinct index number (starting with 0) corresponding to the instance", + Kind: lang.PlainTextKind, + }, + Detail: "number", + TextEdit: lang.TextEdit{Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 4, + Column: 16, + Byte: 67, + }, + End: hcl.Pos{ + Line: 4, + Column: 16, + Byte: 67, + }, + }, NewText: "count.index", Snippet: "count.index"}, + Kind: lang.TraversalCandidateKind, + }, + }), + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.testName), func(t *testing.T) { + f, _ := hclsyntax.ParseConfig([]byte(tc.cfg), "test.tf", hcl.InitialPos) + + d := testPathDecoder(t, &PathContext{ + Schema: tc.bodySchema, + Files: map[string]*hcl.File{ + "test.tf": f, + }, + }) + + candidates, err := d.CandidatesAtPos(ctx, "test.tf", tc.pos) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expectedCandidates, candidates); diff != "" { + t.Fatalf("unexpected candidates: %s", diff) + } + }) + } +} diff --git a/decoder/candidates.go b/decoder/candidates.go index da68458b..13b7d07e 100644 --- a/decoder/candidates.go +++ b/decoder/candidates.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + icontext "github.com/hashicorp/hcl-lang/context" "github.com/hashicorp/hcl-lang/lang" "github.com/hashicorp/hcl-lang/schema" "github.com/hashicorp/hcl/v2" @@ -48,6 +49,16 @@ func (d *PathDecoder) candidatesAtPos(ctx context.Context, body *hclsyntax.Body, filename := body.Range().Filename + if bodySchema.Extensions != nil { + ctx = icontext.WithExtensions(ctx, bodySchema.Extensions) + if bodySchema.Extensions.Count { + if _, ok := body.Attributes["count"]; ok { + // append to context we need count completed + ctx = icontext.WithActiveCount(ctx) + } + } + } + for _, attr := range body.Attributes { if d.isPosInsideAttrExpr(attr, pos) { if aSchema, ok := bodySchema.Attributes[attr.Name]; ok { diff --git a/decoder/candidates_test.go b/decoder/candidates_test.go index 5e5ffa2a..95f7a1ba 100644 --- a/decoder/candidates_test.go +++ b/decoder/candidates_test.go @@ -8,13 +8,14 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/zclconf/go-cty-debug/ctydebug" + "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/hcl-lang/lang" "github.com/hashicorp/hcl-lang/schema" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/json" - "github.com/zclconf/go-cty-debug/ctydebug" - "github.com/zclconf/go-cty/cty" ) func TestDecoder_CandidatesAtPos_noSchema(t *testing.T) { @@ -1575,9 +1576,10 @@ func TestDecoder_CandidatesAtPos_incompleteLabel(t *testing.T) { resourceSchema := &schema.BlockSchema{ Labels: resourceLabelSchema, Body: &schema.BodySchema{ - Attributes: map[string]*schema.AttributeSchema{ - "count": {Expr: schema.LiteralTypeOnly(cty.Number), IsOptional: true}, + Extensions: &schema.BodyExtensions{ + Count: true, }, + Attributes: map[string]*schema.AttributeSchema{}, }, DependentBody: map[schema.SchemaKey]*schema.BodySchema{ schema.NewSchemaKey(schema.DependencyKeys{ @@ -1638,7 +1640,11 @@ resource "any" "ref" { hcl.Pos{Line: 3, Column: 5, Byte: 28}, lang.CompleteCandidates([]lang.Candidate{ { - Label: "count", + Label: "count", + Description: lang.MarkupContent{ + Value: "The distinct index number (starting with 0) corresponding to the instance", + Kind: lang.PlainTextKind, + }, Detail: "optional, number", TextEdit: lang.TextEdit{ Range: hcl.Range{ diff --git a/decoder/expression_candidates.go b/decoder/expression_candidates.go index b75faf28..947302f7 100644 --- a/decoder/expression_candidates.go +++ b/decoder/expression_candidates.go @@ -7,12 +7,14 @@ import ( "strconv" "strings" + "github.com/zclconf/go-cty/cty" + + icontext "github.com/hashicorp/hcl-lang/context" "github.com/hashicorp/hcl-lang/lang" "github.com/hashicorp/hcl-lang/reference" "github.com/hashicorp/hcl-lang/schema" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" - "github.com/zclconf/go-cty/cty" ) func (d *PathDecoder) attrValueCandidatesAtPos(ctx context.Context, attr *hclsyntax.Attribute, schema *schema.AttributeSchema, outerBodyRng hcl.Range, pos hcl.Pos) (lang.Candidates, error) { @@ -35,7 +37,7 @@ func (d *PathDecoder) attrValueCandidatesAtPos(ctx context.Context, attr *hclsyn return candidates, nil } - candidates.List = append(candidates.List, d.constraintToCandidates(c, outerBodyRng, prefixRng, editRng)...) + candidates.List = append(candidates.List, d.constraintToCandidates(ctx, c, outerBodyRng, prefixRng, editRng)...) count++ } } @@ -305,7 +307,7 @@ func (d *PathDecoder) candidatesFromHooks(ctx context.Context, attr *hclsyntax.A return candidates } -func (d *PathDecoder) constraintToCandidates(constraint schema.ExprConstraint, outerBodyRng, prefixRng, editRng hcl.Range) []lang.Candidate { +func (d *PathDecoder) constraintToCandidates(ctx context.Context, constraint schema.ExprConstraint, outerBodyRng, prefixRng, editRng hcl.Range) []lang.Candidate { candidates := make([]lang.Candidate, 0) switch c := constraint.(type) { @@ -328,7 +330,7 @@ func (d *PathDecoder) constraintToCandidates(constraint schema.ExprConstraint, o }, }) case schema.TraversalExpr: - candidates = append(candidates, d.candidatesForTraversalConstraint(c, outerBodyRng, prefixRng, editRng)...) + candidates = append(candidates, d.candidatesForTraversalConstraint(ctx, c, outerBodyRng, prefixRng, editRng)...) case schema.TupleConsExpr: candidates = append(candidates, lang.Candidate{ Label: fmt.Sprintf(`[%s]`, labelForConstraints(c.AnyElem)), @@ -457,9 +459,24 @@ func (d *PathDecoder) constraintToCandidates(constraint schema.ExprConstraint, o return candidates } -func (d *PathDecoder) candidatesForTraversalConstraint(tc schema.TraversalExpr, outerBodyRng, prefixRng, editRng hcl.Range) []lang.Candidate { +func (d *PathDecoder) candidatesForTraversalConstraint(ctx context.Context, tc schema.TraversalExpr, outerBodyRng, prefixRng, editRng hcl.Range) []lang.Candidate { candidates := make([]lang.Candidate, 0) + ext, ok := icontext.ExtensionsFromContext(ctx) + if ok && ext.Count && icontext.ActiveCountFromContext(ctx) { + candidates = append(candidates, lang.Candidate{ + Label: "count.index", + Detail: "number", + Description: lang.PlainText("The distinct index number (starting with 0) corresponding to the instance"), + Kind: lang.TraversalCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "count.index", + Snippet: "count.index", + Range: editRng, + }, + }) + } + if d.pathCtx.ReferenceTargets == nil { return candidates } diff --git a/schema/body_schema.go b/schema/body_schema.go index cd79cc08..cf0fb402 100644 --- a/schema/body_schema.go +++ b/schema/body_schema.go @@ -45,6 +45,29 @@ type BodySchema struct { // referenced from still unknown locations during the build of the module // schema. ImpliedOrigins ImpliedOrigins + + // Extensions represents any HCL extensions supported in this body + Extensions *BodyExtensions +} + +type BodyExtensions struct { + Count bool // count attribute + count.index refs + ForEach bool // for_each attribute + each.* refs + DynamicBlocks bool // dynamic "block-name" w/ content & for_each inside + SelfRefs bool // self.* refs which refer to outermost parent block body +} + +func (be *BodyExtensions) Copy() *BodyExtensions { + if be == nil { + return nil + } + + return &BodyExtensions{ + Count: be.Count, + ForEach: be.ForEach, + DynamicBlocks: be.DynamicBlocks, + SelfRefs: be.SelfRefs, + } } type ImpliedOrigins []ImpliedOrigin @@ -176,6 +199,7 @@ func (bs *BodySchema) Copy() *BodySchema { HoverURL: bs.HoverURL, DocsLink: bs.DocsLink.Copy(), Targets: bs.Targets.Copy(), + Extensions: bs.Extensions.Copy(), } if bs.TargetableAs != nil {