From b8c8114d3c7ea88c569eb0bb7274db661e34bfa3 Mon Sep 17 00:00:00 2001 From: James Pogran Date: Fri, 28 Oct 2022 09:03:26 -0400 Subject: [PATCH] Implement for_each and each.* extensions (#145) This provides completion hints inside blocks for `for_each` and `each.*` references within `resource`, `data` and `module` blocks anywhere the `for_each` meta-argument is supported. It detects if `for_each` is used already and does not suggest duplicates and detects when it is necessary to provide `each.*` completions. This does not complete the values for `each.key` and `each.value`, e.g. `each.value.something`. This also provides hover support for `for_each` and `each.*` references using the documentation as source. This also provides semantic token support for `for_each` and `each.*` references. Closes https://github.com/hashicorp/terraform-ls/issues/861 --- decoder/body_candidates.go | 8 + decoder/body_extensions_test.go | 402 +++++++++++++++++++++++++++++++ decoder/candidates.go | 10 + decoder/decoder.go | 47 ++++ decoder/expression_candidates.go | 4 + decoder/hover.go | 6 + decoder/hover_test.go | 165 +++++++++++++ decoder/semantic_tokens.go | 97 +++++--- decoder/semantic_tokens_test.go | 167 +++++++++++++ schema/body_schema.go | 6 +- schema/schema.go | 10 + 11 files changed, 887 insertions(+), 35 deletions(-) diff --git a/decoder/body_candidates.go b/decoder/body_candidates.go index c4edafe3..26f1ed2b 100644 --- a/decoder/body_candidates.go +++ b/decoder/body_candidates.go @@ -26,6 +26,14 @@ func (d *PathDecoder) bodySchemaCandidates(body *hclsyntax.Body, schema *schema. candidates.List = append(candidates.List, attributeSchemaToCandidate("count", countAttributeSchema(), editRng)) } } + + if schema.Extensions.ForEach { + // check if for_each attribute is already declared, so we don't + // suggest a duplicate + if _, present := body.Attributes["for_each"]; !present { + candidates.List = append(candidates.List, attributeSchemaToCandidate("for_each", forEachAttributeSchema(), editRng)) + } + } } if len(schema.Attributes) > 0 { diff --git a/decoder/body_extensions_test.go b/decoder/body_extensions_test.go index e0b07d4c..90bb5164 100644 --- a/decoder/body_extensions_test.go +++ b/decoder/body_extensions_test.go @@ -460,6 +460,408 @@ variable "test" { }, }), }, + { + "foreach attribute completion", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + {Name: "type"}, {Name: "name"}, + }, + Body: &schema.BodySchema{ + Extensions: &schema.BodyExtensions{ + ForEach: true, + }, + }, + }, + }, + }, + reference.Targets{}, + `resource "aws_instance" "foo" { + +}`, + hcl.Pos{Line: 2, Column: 1, Byte: 32}, + lang.CompleteCandidates([]lang.Candidate{{ + Label: "for_each", + Description: lang.MarkupContent{ + Value: "A meta-argument that accepts a map or a set of strings, and creates an instance for each item in that map or set.\n\n**Note**: A given block cannot use both `count` and `for_each`.", + Kind: lang.MarkdownKind, + }, + Detail: "optional, map of any single type or set of string", + 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: "for_each", + Snippet: "for_each = ", + }, + Kind: lang.AttributeCandidateKind, + TriggerSuggest: true, + }, + }), + }, + { + "foreach attribute completion does not complete foreach when extensions not enabled", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + {Name: "type"}, {Name: "name"}, + }, + Body: &schema.BodySchema{ + Extensions: &schema.BodyExtensions{ + ForEach: false, + }, + }, + }, + }, + }, + reference.Targets{}, + `resource "aws_instance" "foo" { + +}`, + hcl.Pos{Line: 2, Column: 1, Byte: 32}, + lang.CompleteCandidates([]lang.Candidate{}), + }, + { + "each.* value completion", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + {Name: "type"}, {Name: "name"}, + }, + Body: &schema.BodySchema{ + Extensions: &schema.BodyExtensions{ + ForEach: true, + }, + Attributes: map[string]*schema.AttributeSchema{ + "thing": { + IsOptional: true, + Expr: schema.ExprConstraints{ + schema.TraversalExpr{ + OfType: cty.String, + }, + }, + }, + }, + }, + }, + }, + }, + reference.Targets{}, + `resource "aws_instance" "foo" { +for_each = { + a_group = "eastus" + another_group = "westus2" +} +thing = +}`, + hcl.Pos{Line: 6, Column: 8, Byte: 101}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "each.key", + Detail: "string", + Kind: lang.TraversalCandidateKind, + Description: lang.MarkupContent{ + Value: "The map key (or set member) corresponding to this instance", + Kind: lang.MarkdownKind, + }, + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 6, Column: 9, Byte: 102}, + End: hcl.Pos{Line: 6, Column: 9, Byte: 102}, + }, + NewText: "each.key", + Snippet: "each.key", + }, + }, + { + Label: "each.value", + Detail: "any type", + Kind: lang.TraversalCandidateKind, + Description: lang.MarkupContent{ + Value: "The map value corresponding to this instance. (If a set was provided, this is the same as `each.key`.)", + Kind: lang.MarkdownKind, + }, + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 6, Column: 9, Byte: 102}, + End: hcl.Pos{Line: 6, Column: 9, Byte: 102}, + }, + NewText: "each.value", + Snippet: "each.value", + }, + }, + }), + }, + { + "each.* does not complete when not needed", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + {Name: "type"}, {Name: "name"}, + }, + Body: &schema.BodySchema{ + Extensions: &schema.BodyExtensions{ + ForEach: true, + }, + Attributes: map[string]*schema.AttributeSchema{ + "thing": { + IsOptional: true, + Expr: schema.ExprConstraints{ + schema.TraversalExpr{ + OfType: cty.String, + }, + }, + }, + }, + }, + }, + }, + }, + reference.Targets{}, + `resource "aws_instance" "foo" { +thing = +}`, + hcl.Pos{Line: 2, Column: 9, Byte: 40}, + lang.CompleteCandidates([]lang.Candidate{}), + }, + { + "each.* does not complete when extension not enabled", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + {Name: "type"}, {Name: "name"}, + }, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "thing": { + IsOptional: true, + Expr: schema.ExprConstraints{ + schema.TraversalExpr{ + OfType: cty.Number, + }, + }, + }, + }, + Extensions: &schema.BodyExtensions{ + ForEach: false, + }, + }, + }, + }, + }, + reference.Targets{}, + `resource "aws_instance" "foo" { + thing = +}`, + hcl.Pos{Line: 2, Column: 8, Byte: 41}, + lang.CompleteCandidates([]lang.Candidate{}), + }, + { + "for_each does not complete more than once", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + {Name: "type"}, {Name: "name"}, + }, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "thing": { + IsOptional: true, + Expr: schema.ExprConstraints{ + schema.TraversalExpr{ + OfType: cty.Number, + }, + }, + }, + }, + Extensions: &schema.BodyExtensions{ + ForEach: true, + }, + }, + }, + }, + }, + reference.Targets{}, + `resource "aws_instance" "foo" { +for_each = { + a_group = "eastus" + another_group = "westus2" +} + +}`, + hcl.Pos{Line: 6, Column: 1, Byte: 94}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "thing", + Detail: "optional, number", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 6, Column: 1, Byte: 94}, + End: hcl.Pos{Line: 6, Column: 1, Byte: 94}, + }, + NewText: "thing", + Snippet: "thing = ", + }, + TriggerSuggest: true, + Kind: lang.AttributeCandidateKind, + }, + }), + }, + { + "each.* completes when inside nested blocks", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + {Name: "type"}, {Name: "name"}, + }, + Body: &schema.BodySchema{ + Extensions: &schema.BodyExtensions{ + ForEach: true, + }, + Blocks: map[string]*schema.BlockSchema{ + "foo": { + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "thing": { + IsOptional: true, + Expr: schema.ExprConstraints{ + schema.TraversalExpr{ + OfType: cty.Number, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + reference.Targets{}, + `resource "aws_instance" "foo" { +for_each = { + a_group = "eastus" + another_group = "westus2" +} +foo { + thing = +} +}`, + hcl.Pos{Line: 7, Column: 11, Byte: 109}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "each.key", + Detail: "string", + Kind: lang.TraversalCandidateKind, + Description: lang.MarkupContent{ + Value: "The map key (or set member) corresponding to this instance", + Kind: lang.MarkdownKind, + }, + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 7, Column: 10, Byte: 109}, + End: hcl.Pos{Line: 7, Column: 10, Byte: 109}, + }, + NewText: "each.key", + Snippet: "each.key", + }, + }, + { + Label: "each.value", + Detail: "any type", + Kind: lang.TraversalCandidateKind, + Description: lang.MarkupContent{ + Value: "The map value corresponding to this instance. (If a set was provided, this is the same as `each.key`.)", + Kind: lang.MarkdownKind, + }, + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 7, Column: 10, Byte: 109}, + End: hcl.Pos{Line: 7, Column: 10, Byte: 109}, + }, + NewText: "each.value", + Snippet: "each.value", + }, + }, + }), + }, + { + "each.* does not complete for for_each", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + {Name: "type"}, {Name: "name"}, + }, + Body: &schema.BodySchema{ + Extensions: &schema.BodyExtensions{ + ForEach: true, + }, + Attributes: map[string]*schema.AttributeSchema{ + "thing": { + IsOptional: true, + Expr: schema.ExprConstraints{ + schema.TraversalExpr{ + OfType: cty.String, + }, + }, + }, + }, + }, + }, + }, + }, + reference.Targets{}, + `resource "aws_instance" "foo" { +for_each = +}`, + hcl.Pos{Line: 2, Column: 12, Byte: 43}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: `{ "key" = any type }`, + Detail: "map of any single type", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 12, Byte: 43}, + End: hcl.Pos{Line: 2, Column: 12, Byte: 43}, + }, + NewText: "{\n \"key\" = \n}", + Snippet: "{\n \"${1:key}\" = ${2}\n}", + }, + Kind: lang.MapCandidateKind, + }, + { + Label: "[ string ]", + Detail: "set of string", + TextEdit: lang.TextEdit{ + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 12, Byte: 43}, + End: hcl.Pos{Line: 2, Column: 12, Byte: 43}, + }, + NewText: `[ "" ]`, + Snippet: `[ "${1:value}" ]`, + }, + Kind: lang.SetCandidateKind, + }, + }), + }, } for i, tc := range testCases { diff --git a/decoder/candidates.go b/decoder/candidates.go index c8cf5acd..014dbed3 100644 --- a/decoder/candidates.go +++ b/decoder/candidates.go @@ -55,6 +55,13 @@ func (d *PathDecoder) candidatesAtPos(ctx context.Context, body *hclsyntax.Body, ctx = schema.WithActiveCount(ctx) } } + + if bodySchema.Extensions.ForEach { + if _, present := body.Attributes["for_each"]; present { + // append to context we need foreach completed + ctx = schema.WithActiveForEach(ctx) + } + } } for _, attr := range body.Attributes { @@ -62,6 +69,9 @@ func (d *PathDecoder) candidatesAtPos(ctx context.Context, body *hclsyntax.Body, if bodySchema.Extensions != nil && bodySchema.Extensions.Count && attr.Name == "count" { return d.attrValueCandidatesAtPos(ctx, attr, countAttributeSchema(), outerBodyRng, pos) } + if bodySchema.Extensions != nil && bodySchema.Extensions.ForEach && attr.Name == "for_each" { + return d.attrValueCandidatesAtPos(ctx, attr, forEachAttributeSchema(), outerBodyRng, pos) + } if aSchema, ok := bodySchema.Attributes[attr.Name]; ok { return d.attrValueCandidatesAtPos(ctx, attr, aSchema, outerBodyRng, pos) } diff --git a/decoder/decoder.go b/decoder/decoder.go index fff99ea8..2c1ebe5c 100644 --- a/decoder/decoder.go +++ b/decoder/decoder.go @@ -172,6 +172,20 @@ func countAttributeSchema() *schema.AttributeSchema { } } +func forEachAttributeSchema() *schema.AttributeSchema { + return &schema.AttributeSchema{ + IsOptional: true, + Expr: schema.ExprConstraints{ + schema.TraversalExpr{OfType: cty.Map(cty.DynamicPseudoType)}, + schema.TraversalExpr{OfType: cty.Set(cty.String)}, + schema.LiteralTypeExpr{Type: cty.Map(cty.DynamicPseudoType)}, + schema.LiteralTypeExpr{Type: cty.Set(cty.String)}, + }, + Description: lang.Markdown("A meta-argument that accepts a map or a set of strings, and creates an instance for each item in that map or set.\n\n" + + "**Note**: A given block cannot use both `count` and `for_each`."), + } +} + func countIndexHoverData(rng hcl.Range) *lang.HoverData { return &lang.HoverData{ Content: lang.Markdown("`count.index` _number_\n\nThe distinct index number (starting with 0) corresponding to the instance"), @@ -192,3 +206,36 @@ func countIndexCandidate(editRng hcl.Range) lang.Candidate { }, } } + +func foreachEachCandidate(editRng hcl.Range) []lang.Candidate { + return []lang.Candidate{ + { + Label: "each.key", + Detail: "string", + Description: lang.MarkupContent{ + Value: "The map key (or set member) corresponding to this instance", + Kind: lang.MarkdownKind, + }, + Kind: lang.TraversalCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "each.key", + Snippet: "each.key", + Range: editRng, + }, + }, + { + Label: "each.value", + Detail: "any type", + Description: lang.MarkupContent{ + Value: "The map value corresponding to this instance. (If a set was provided, this is the same as `each.key`.)", + Kind: lang.MarkdownKind, + }, + Kind: lang.TraversalCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "each.value", + Snippet: "each.value", + Range: editRng, + }, + }, + } +} diff --git a/decoder/expression_candidates.go b/decoder/expression_candidates.go index 25fb86cd..f5118f15 100644 --- a/decoder/expression_candidates.go +++ b/decoder/expression_candidates.go @@ -331,6 +331,10 @@ func (d *PathDecoder) constraintToCandidates(ctx context.Context, constraint sch if schema.ActiveCountFromContext(ctx) && attr.Name != "count" { candidates = append(candidates, countIndexCandidate(editRng)) } + if schema.ActiveForEachFromContext(ctx) && attr.Name != "for_each" { + candidates = append(candidates, foreachEachCandidate(editRng)...) + } + candidates = append(candidates, d.candidatesForTraversalConstraint(c, outerBodyRng, prefixRng, editRng)...) case schema.TupleConsExpr: candidates = append(candidates, lang.Candidate{ diff --git a/decoder/hover.go b/decoder/hover.go index 9c61b557..ebf5ae99 100644 --- a/decoder/hover.go +++ b/decoder/hover.go @@ -51,6 +51,10 @@ func (d *PathDecoder) hoverAtPos(ctx context.Context, body *hclsyntax.Body, body ctx = schema.WithActiveCount(ctx) } } + + if bodySchema.Extensions.ForEach { + ctx = schema.WithActiveForEach(ctx) + } } for name, attr := range body.Attributes { @@ -58,6 +62,8 @@ func (d *PathDecoder) hoverAtPos(ctx context.Context, body *hclsyntax.Body, body var aSchema *schema.AttributeSchema if bodySchema.Extensions != nil && bodySchema.Extensions.Count && name == "count" { aSchema = countAttributeSchema() + } else if bodySchema.Extensions != nil && bodySchema.Extensions.ForEach && name == "for_each" { + aSchema = forEachAttributeSchema() } else { var ok bool aSchema, ok = bodySchema.Attributes[attr.Name] diff --git a/decoder/hover_test.go b/decoder/hover_test.go index 73925de4..5bbbe867 100644 --- a/decoder/hover_test.go +++ b/decoder/hover_test.go @@ -1073,3 +1073,168 @@ func TestDecoder_HoverAtPos_extension(t *testing.T) { }) } } + +func TestDecoder_HoverAtPos_foreach_extension(t *testing.T) { + testCases := []struct { + name string + bodySchema *schema.BodySchema + config string + pos hcl.Pos + expectedData *lang.HoverData + }{ + { + "for_each attribute name", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "myblock": { + Labels: []*schema.LabelSchema{ + {Name: "type", IsDepKey: true}, + {Name: "name"}, + }, + Body: &schema.BodySchema{ + Extensions: &schema.BodyExtensions{ + ForEach: true, + }, + }, + }, + }, + }, + `myblock "foo" "bar" { + for_each = { + + } +} +`, + hcl.Pos{Line: 2, Column: 5, Byte: 26}, + &lang.HoverData{ + Content: lang.MarkupContent{ + Value: "**for_each** _optional, map of any single type or set of string_\n\n" + + "A meta-argument that accepts a map or a set of strings, and creates an instance for each item in that map or set.\n\n" + + "**Note**: A given block cannot use both `count` and `for_each`.", + Kind: lang.MarkdownKind, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 3, Byte: 24}, + End: hcl.Pos{Line: 4, Column: 3, Byte: 42}, + }, + }, + }, + { + "each.key attribute key", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "myblock": { + Labels: []*schema.LabelSchema{ + {Name: "type", IsDepKey: true}, + {Name: "name"}, + }, + Body: &schema.BodySchema{ + Extensions: &schema.BodyExtensions{ + ForEach: true, + }, + Attributes: map[string]*schema.AttributeSchema{ + "foo": { + IsOptional: true, + Expr: schema.ExprConstraints{ + schema.TraversalExpr{ + OfType: cty.String, + }, + }, + }, + }, + }, + }, + }, + }, + `myblock "foo" "bar" { + foo = each.key + for_each = { + thing = "bar" + woot = 3 + } +} +`, + hcl.Pos{Line: 5, Column: 16, Byte: 73}, + &lang.HoverData{ + Content: lang.Markdown("_map of dynamic_"), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 3, Column: 13, Byte: 50}, + End: hcl.Pos{Line: 6, Column: 3, Byte: 81}, + }, + }, + }, + { + "each.value attribute value", + &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "myblock": { + Labels: []*schema.LabelSchema{ + {Name: "type", IsDepKey: true}, + {Name: "name"}, + }, + Body: &schema.BodySchema{ + Extensions: &schema.BodyExtensions{ + ForEach: true, + }, + Attributes: map[string]*schema.AttributeSchema{ + "foo": { + IsOptional: true, + Expr: schema.ExprConstraints{ + schema.TraversalExpr{ + OfType: cty.String, + }, + }, + }, + }, + }, + }, + }, + }, + `myblock "foo" "bar" { + foo = each.value + for_each = { + thing = "bar" + woot = 3 + } +} +`, + hcl.Pos{Line: 5, Column: 16, Byte: 73}, + &lang.HoverData{ + Content: lang.Markdown("_map of dynamic_"), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 3, Column: 13, Byte: 52}, + End: hcl.Pos{Line: 6, Column: 3, Byte: 83}, + }, + }, + }, + } + + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.name), func(t *testing.T) { + ctx := context.Background() + + f, diags := hclsyntax.ParseConfig([]byte(tc.config), "test.tf", hcl.InitialPos) + if diags != nil { + t.Fatal(diags) + } + + d := testPathDecoder(t, &PathContext{ + Schema: tc.bodySchema, + Files: map[string]*hcl.File{ + "test.tf": f, + }, + }) + + data, err := d.HoverAtPos(ctx, "test.tf", tc.pos) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(tc.expectedData, data, ctydebug.CmpOptions); diff != "" { + t.Fatalf("hover data mismatch: %s", diff) + } + }) + } +} diff --git a/decoder/semantic_tokens.go b/decoder/semantic_tokens.go index 11f0089f..29eb2a86 100644 --- a/decoder/semantic_tokens.go +++ b/decoder/semantic_tokens.go @@ -52,16 +52,20 @@ func (d *PathDecoder) tokensForBody(ctx context.Context, body *hclsyntax.Body, b ctx = schema.WithActiveCount(ctx) } } + + if bodySchema.Extensions.ForEach { + // append to context we need count provided + ctx = schema.WithActiveForEach(ctx) + } } for name, attr := range body.Attributes { attrSchema, ok := bodySchema.Attributes[name] if !ok { if bodySchema.Extensions != nil && name == "count" && bodySchema.Extensions.Count { - attrSchema = &schema.AttributeSchema{ - IsOptional: true, - Expr: schema.LiteralTypeOnly(cty.Number), - } + attrSchema = countAttributeSchema() + } else if bodySchema.Extensions != nil && name == "for_each" && bodySchema.Extensions.ForEach { + attrSchema = forEachAttributeSchema() } else { if bodySchema.AnyAttribute == nil { // unknown attribute @@ -130,6 +134,10 @@ func (d *PathDecoder) tokensForBody(ctx context.Context, body *hclsyntax.Body, b ctx = schema.WithActiveCount(ctx) } } + if blockSchema.Body.Extensions.ForEach { + // append to context we need each.* provided + ctx = schema.WithActiveForEach(ctx) + } } tokens = append(tokens, d.tokensForBody(ctx, block.Body, blockSchema.Body, blockModifiers)...) } @@ -164,42 +172,29 @@ func (d *PathDecoder) tokensForExpression(ctx context.Context, expr hclsyntax.Ex if err != nil { return tokens } + countAvailable := schema.ActiveCountFromContext(ctx) countIndexAttr := lang.Address{ - lang.RootStep{ - Name: "count", - }, - lang.AttrStep{ - Name: "index", - }, + lang.RootStep{Name: "count"}, lang.AttrStep{Name: "index"}, } if address.Equals(countIndexAttr) && countAvailable { - traversal := eType.AsTraversal() + tokens = append(tokens, semanticTokensForTraversalExpression(eType.AsTraversal())...) - tokens = append(tokens, lang.SemanticToken{ - Type: lang.TokenTraversalStep, - Modifiers: []lang.SemanticTokenModifier{}, - Range: traversal[0].SourceRange(), - }) + return tokens + } + + foreachAvailable := schema.ActiveForEachFromContext(ctx) + eachKeyAddress := lang.Address{ + lang.RootStep{Name: "each"}, lang.AttrStep{Name: "key"}, + } + eachValueAddress := lang.Address{ + lang.RootStep{Name: "each"}, lang.AttrStep{Name: "value"}, + } + + if (address.Equals(eachKeyAddress) || address.Equals(eachValueAddress)) && foreachAvailable { + tokens = append(tokens, semanticTokensForTraversalExpression(eType.AsTraversal())...) - tokens = append(tokens, lang.SemanticToken{ - Type: lang.TokenTraversalStep, - Modifiers: []lang.SemanticTokenModifier{}, - Range: hcl.Range{ - Filename: traversal[1].SourceRange().Filename, - Start: hcl.Pos{ - Line: traversal[1].SourceRange().Start.Line, - Column: traversal[1].SourceRange().Start.Column + 1, - Byte: traversal[1].SourceRange().Start.Byte + 1, - }, - End: hcl.Pos{ - Line: traversal[1].SourceRange().End.Line, - Column: traversal[1].SourceRange().End.Column + 1, - Byte: traversal[1].SourceRange().End.Byte + 1, - }, - }, - }) return tokens } @@ -606,3 +601,39 @@ func tokensForTupleConsExpr(expr *hclsyntax.TupleConsExpr, exprType cty.Type) [] return tokens } + +func semanticTokensForTraversalExpression(traversal hcl.Traversal) []lang.SemanticToken { + if len(traversal) == 0 { + return nil + } + + tokens := make([]lang.SemanticToken, 0) + + tokens = append(tokens, lang.SemanticToken{ + Type: lang.TokenTraversalStep, + Modifiers: []lang.SemanticTokenModifier{}, + Range: traversal[0].SourceRange(), + }) + + for i := 1; i < len(traversal); i++ { + tokens = append(tokens, lang.SemanticToken{ + Type: lang.TokenTraversalStep, + Modifiers: []lang.SemanticTokenModifier{}, + Range: hcl.Range{ + Filename: traversal[i].SourceRange().Filename, + Start: hcl.Pos{ + Line: traversal[i].SourceRange().Start.Line, + Column: traversal[i].SourceRange().Start.Column + 1, + Byte: traversal[i].SourceRange().Start.Byte + 1, + }, + End: hcl.Pos{ + Line: traversal[i].SourceRange().End.Line, + Column: traversal[i].SourceRange().End.Column + 1, + Byte: traversal[i].SourceRange().End.Byte + 1, + }, + }, + }) + } + + return tokens +} diff --git a/decoder/semantic_tokens_test.go b/decoder/semantic_tokens_test.go index c1575c1c..f99340ab 100644 --- a/decoder/semantic_tokens_test.go +++ b/decoder/semantic_tokens_test.go @@ -1635,3 +1635,170 @@ resource "foobar" "name" { t.Fatalf("unexpected tokens: %s", diff) } } + +func TestDecoder_SemanticTokensInFile_extensions_for_each(t *testing.T) { + bodySchema := &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Body: &schema.BodySchema{ + Extensions: &schema.BodyExtensions{ + ForEach: true, + }, + Attributes: map[string]*schema.AttributeSchema{ + "for_each": { + Expr: schema.ExprConstraints{ + schema.TraversalExpr{OfType: cty.Map(cty.String)}, + schema.LiteralTypeExpr{Type: cty.Set(cty.String)}, + }, + }, + "thing": { + Expr: schema.LiteralTypeOnly(cty.String), + }, + "thing_other": { + Expr: schema.LiteralTypeOnly(cty.String), + }, + }, + }, + Labels: []*schema.LabelSchema{ + { + Name: "type", + IsDepKey: true, + SemanticTokenModifiers: lang.SemanticTokenModifiers{ + lang.TokenModifierDependent, + }, + }, + {Name: "name"}, + }, + }, + }, + } + + testCfg := []byte(` +resource "foobar" "name" { + for_each = { + a_group = "eastus" + } + thing = each.key + thing_other = each.value +} +`) + + f, pDiags := hclsyntax.ParseConfig(testCfg, "test.tf", hcl.InitialPos) + if len(pDiags) > 0 { + t.Fatal(pDiags) + } + + d := testPathDecoder(t, &PathContext{ + Schema: bodySchema, + Files: map[string]*hcl.File{ + "test.tf": f, + }, + }) + + ctx := context.Background() + + tokens, err := d.SemanticTokensInFile(ctx, "test.tf") + if err != nil { + t.Fatal(err) + } + + expectedTokens := []lang.SemanticToken{ + { // resource + Type: lang.TokenBlockType, + Modifiers: []lang.SemanticTokenModifier{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 1}, + End: hcl.Pos{Line: 2, Column: 9, Byte: 9}, + }, + }, + { // foobar + Type: lang.TokenBlockLabel, + Modifiers: []lang.SemanticTokenModifier{ + lang.TokenModifierDependent, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 10, Byte: 10}, + End: hcl.Pos{Line: 2, Column: 18, Byte: 18}, + }, + }, + { // name + Type: lang.TokenBlockLabel, + Modifiers: []lang.SemanticTokenModifier{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 19, Byte: 19}, + End: hcl.Pos{Line: 2, Column: 25, Byte: 25}, + }, + }, + { // for_each + Type: lang.TokenAttrName, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 3, Column: 2, Byte: 29}, + End: hcl.Pos{Line: 3, Column: 10, Byte: 37}, + }, + }, + { // thing + Type: lang.TokenAttrName, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 6, Column: 2, Byte: 67}, + End: hcl.Pos{Line: 6, Column: 7, Byte: 72}, + }, + }, + { // each + Type: lang.TokenTraversalStep, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 6, Column: 10, Byte: 75}, + End: hcl.Pos{Line: 6, Column: 14, Byte: 79}, + }, + }, + { // key + Type: lang.TokenTraversalStep, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 6, Column: 15, Byte: 80}, + End: hcl.Pos{Line: 6, Column: 19, Byte: 84}, + }, + }, + { // thing_other + Type: lang.TokenAttrName, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 7, Column: 2, Byte: 85}, + End: hcl.Pos{Line: 7, Column: 13, Byte: 96}, + }, + }, + { // each + Type: lang.TokenTraversalStep, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 7, Column: 16, Byte: 99}, + End: hcl.Pos{Line: 7, Column: 20, Byte: 103}, + }, + }, + { // value + Type: lang.TokenTraversalStep, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 7, Column: 21, Byte: 104}, + End: hcl.Pos{Line: 7, Column: 27, Byte: 110}, + }, + }, + } + + diff := cmp.Diff(expectedTokens, tokens) + if diff != "" { + t.Fatalf("unexpected tokens: %s", diff) + } +} diff --git a/schema/body_schema.go b/schema/body_schema.go index 492982a7..b86653a2 100644 --- a/schema/body_schema.go +++ b/schema/body_schema.go @@ -51,7 +51,8 @@ type BodySchema struct { } type BodyExtensions struct { - Count bool // count attribute + count.index refs + Count bool // count attribute + count.index refs + ForEach bool // for_each attribute + each.* refs } func (be *BodyExtensions) Copy() *BodyExtensions { @@ -60,7 +61,8 @@ func (be *BodyExtensions) Copy() *BodyExtensions { } return &BodyExtensions{ - Count: be.Count, + Count: be.Count, + ForEach: be.ForEach, } } diff --git a/schema/schema.go b/schema/schema.go index 6cd3e34a..8dc1b168 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -22,3 +22,13 @@ func WithActiveCount(ctx context.Context) context.Context { func ActiveCountFromContext(ctx context.Context) bool { return ctx.Value(bodyActiveCountCtxKey{}) != nil } + +type bodyActiveForEachCtxKey struct{} + +func WithActiveForEach(ctx context.Context) context.Context { + return context.WithValue(ctx, bodyActiveForEachCtxKey{}, true) +} + +func ActiveForEachFromContext(ctx context.Context) bool { + return ctx.Value(bodyActiveForEachCtxKey{}) != nil +}