From f22d3c2adf6c44c4a9fb8d754ffe8f1b25fc9a8b Mon Sep 17 00:00:00 2001 From: James Pogran Date: Wed, 29 Sep 2021 12:46:25 -0400 Subject: [PATCH] Add Prefill Required Fields Completion On block completion, provide a snippet that completes the label and also the required fields for that block. Currently users can introduce a new block into their configuration using our auto-complete snippet. This snippet only provides the lable name and completes the stanza. However the block is not considered complete/valid until it has all required attributes and blocks. This means that users have to either scroll over a potentially long list of attributes and blocks to add all required ones and/or consult documentation in a separate window. This commit modifies the label completion logic to provide an auto-complete snippet that lists all required fields (attributes and blocks) and expands the TextEdit range to the entire line instead of just to the tabstop. This will replace the entire line with the chosen snippet. The attributes and blocks are sorted alphabetically to ensure consistent ordering for each invocation. For example, when completing the `aws_appmesh_route` resource type, the `mesh_name`, `name`, `virtual_router_name` attributes and the `spec` block will fill in on accepting the completion. Then, the user can tab through to enter in the appropriate value for each field. ``` resource "aws_appmesh_route" "{2:name}" { mesh_name = "${3:value}" name = "${4:value}" virtual_router_name = "${:value}" spec { } ${0} } ``` --- decoder/block_candidates.go | 43 ++- decoder/body_candidates.go | 3 +- decoder/candidates.go | 2 +- decoder/decoder.go | 3 + decoder/expression_candidates.go | 53 ++++ decoder/label_candidates.go | 195 +++++++++--- decoder/label_candidates_test.go | 523 +++++++++++++++++++++++++++++++ schema/body_schema.go | 19 ++ 8 files changed, 800 insertions(+), 41 deletions(-) diff --git a/decoder/block_candidates.go b/decoder/block_candidates.go index e88cdeaf..6f94d758 100644 --- a/decoder/block_candidates.go +++ b/decoder/block_candidates.go @@ -9,7 +9,10 @@ import ( "github.com/hashicorp/hcl/v2" ) -func blockSchemaToCandidate(blockType string, block *schema.BlockSchema, rng hcl.Range) lang.Candidate { +// blockSchemaToCandidate generates a lang.Candidate used for auto-complete inside an editor from a BlockSchema. +// If `prefillRequiredFields` is `false`, it returns a snippet that does not expect any prefilled fields. +// If `prefillRequiredFields` is `true`, it returns a snippet that is compatiable with a list of prefilled fields from `generateRequiredFieldsSnippet` +func (d *Decoder) BlockSchemaToCandidate(blockType string, block *schema.BlockSchema, rng hcl.Range) lang.Candidate { triggerSuggest := false if len(block.Labels) > 0 { // We make some naive assumptions here for simplicity @@ -31,13 +34,14 @@ func blockSchemaToCandidate(blockType string, block *schema.BlockSchema, rng hcl Kind: lang.BlockCandidateKind, TextEdit: lang.TextEdit{ NewText: blockType, - Snippet: snippetForBlock(blockType, block), + Snippet: snippetForBlock(blockType, block, d.PrefillRequiredFields), Range: rng, }, TriggerSuggest: triggerSuggest, } } +// detailForBlock returns a `Detail` info string to display in an editor in a hover event func detailForBlock(block *schema.BlockSchema) string { detail := "Block" if block.Type != schema.BlockTypeNil { @@ -54,7 +58,40 @@ func detailForBlock(block *schema.BlockSchema) string { return strings.TrimSpace(detail) } -func snippetForBlock(blockType string, block *schema.BlockSchema) string { +// snippetForBlock takes a block and returns a formatted snippet for a user to complete inside an editor. +// If `prefillRequiredFields` is `false`, it returns a snippet that does not expect any prefilled fields. +// If `prefillRequiredFields` is `true`, it returns a snippet that is compatiable with a list of prefilled fields from `generateRequiredFieldsSnippet` +func snippetForBlock(blockType string, block *schema.BlockSchema, prefillRequiredFields bool) string { + if prefillRequiredFields { + labels := "" + + depKey := false + for _, l := range block.Labels { + if l.IsDepKey { + depKey = true + } + } + + if depKey { + for _, l := range block.Labels { + if l.IsDepKey { + labels += ` "${0}"` + } else { + labels += fmt.Sprintf(` "%s"`, l.Name) + } + } + return fmt.Sprintf("%s%s {\n}", blockType, labels) + } + + placeholder := 1 + for _, l := range block.Labels { + labels += fmt.Sprintf(` "${%d:%s}"`, placeholder, l.Name) + placeholder++ + } + + return fmt.Sprintf("%s%s {\n ${%d}\n}", blockType, labels, placeholder) + } + labels := "" placeholder := 1 diff --git a/decoder/body_candidates.go b/decoder/body_candidates.go index 2b844546..eaa7ab46 100644 --- a/decoder/body_candidates.go +++ b/decoder/body_candidates.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/hcl/v2/hclsyntax" ) +// bodySchemaCandidates returns candidates for completion of fields inside a body or block. func (d *Decoder) bodySchemaCandidates(body *hclsyntax.Body, schema *schema.BodySchema, prefixRng, editRng hcl.Range) lang.Candidates { prefix, _ := d.bytesFromRange(prefixRng) @@ -71,7 +72,7 @@ func (d *Decoder) bodySchemaCandidates(body *hclsyntax.Body, schema *schema.Body return candidates } - candidates.List = append(candidates.List, blockSchemaToCandidate(bType, block, editRng)) + candidates.List = append(candidates.List, d.BlockSchemaToCandidate(bType, block, editRng)) count++ } diff --git a/decoder/candidates.go b/decoder/candidates.go index 1059c74f..7751196e 100644 --- a/decoder/candidates.go +++ b/decoder/candidates.go @@ -117,7 +117,7 @@ func (d *Decoder) candidatesAtPos(body *hclsyntax.Body, outerBodyRng hcl.Range, return lang.ZeroCandidates(), nil } - return d.labelCandidatesFromDependentSchema(i, bSchema.DependentBody, prefixRng, rng) + return d.labelCandidatesFromDependentSchema(i, bSchema.DependentBody, prefixRng, rng, block, bSchema.Labels) } } diff --git a/decoder/decoder.go b/decoder/decoder.go index 67d1fd2a..615a7c86 100644 --- a/decoder/decoder.go +++ b/decoder/decoder.go @@ -28,6 +28,9 @@ type Decoder struct { utmMedium string // utm_content parameter, e.g. documentHover or documentLink useUtmContent bool + + // PrefillRequiredFields enriches label-based completion candidates with required attributes and blocks + PrefillRequiredFields bool } type ReferenceTargetReader func() lang.ReferenceTargets diff --git a/decoder/expression_candidates.go b/decoder/expression_candidates.go index f0c470c7..a6e6f129 100644 --- a/decoder/expression_candidates.go +++ b/decoder/expression_candidates.go @@ -673,6 +673,59 @@ func snippetForExprContraints(placeholder uint, ec schema.ExprConstraints) strin return "" } +func snippetForExprContraint(placeholder uint, ec schema.ExprConstraints) string { + e := ExprConstraints(ec) + + // TODO: implement rest of these + if t, ok := e.LiteralType(); ok { + return snippetForLiteralType(placeholder, t) + } + // case schema.LiteralValue: + // if len(ec) == 1 { + // return snippetForLiteralValue(placeholder, et.Val) + // } + // return "" + if t, ok := e.TupleConsExpr(); ok { + ec := ExprConstraints(t.AnyElem) + if ec.HasKeywordsOnly() { + return "[ ${0} ]" + } + return "[\n ${0}\n]" + } + if t, ok := e.ListExpr(); ok { + ec := ExprConstraints(t.Elem) + if ec.HasKeywordsOnly() { + return "[ ${0} ]" + } + return "[\n ${0}\n]" + } + if t, ok := e.SetExpr(); ok { + ec := ExprConstraints(t.Elem) + if ec.HasKeywordsOnly() { + return "[ ${0} ]" + } + return "[\n ${0}\n]" + } + if t, ok := e.TupleExpr(); ok { + // TODO: multiple constraints? + ec := ExprConstraints(t.Elems[0]) + if ec.HasKeywordsOnly() { + return "[ ${0} ]" + } + return "[\n ${0}\n]" + } + if t, ok := e.MapExpr(); ok { + return fmt.Sprintf("{\n ${%d:name} = %s\n }", + placeholder, + snippetForExprContraints(placeholder+1, t.Elem)) + } + if _, ok := e.ObjectExpr(); ok { + return fmt.Sprintf("{\n ${%d}\n }", placeholder+1) + } + + return "" +} + type snippetGenerator struct { placeholder uint } diff --git a/decoder/label_candidates.go b/decoder/label_candidates.go index 5f56cefb..bdf25e86 100644 --- a/decoder/label_candidates.go +++ b/decoder/label_candidates.go @@ -2,15 +2,17 @@ package decoder import ( "encoding/json" + "fmt" "sort" "strings" "github.com/hashicorp/hcl-lang/lang" "github.com/hashicorp/hcl-lang/schema" "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" ) -func (d *Decoder) labelCandidatesFromDependentSchema(idx int, db map[schema.SchemaKey]*schema.BodySchema, prefixRng, editRng hcl.Range) (lang.Candidates, error) { +func (d *Decoder) labelCandidatesFromDependentSchema(idx int, db map[schema.SchemaKey]*schema.BodySchema, prefixRng, editRng hcl.Range, block *hclsyntax.Block, labelSchemas []*schema.LabelSchema) (lang.Candidates, error) { candidates := lang.NewCandidates() candidates.IsComplete = true count := 0 @@ -35,44 +37,56 @@ func (d *Decoder) labelCandidatesFromDependentSchema(idx int, db map[schema.Sche bodySchema := db[schemaKey] for _, label := range depKeys.Labels { - if label.Index == idx { - if len(prefix) > 0 && !strings.HasPrefix(label.Value, string(prefix)) { - continue - } + if label.Index != idx { + continue + } - // Dependent keys may be duplicated where one - // key is labels-only and other one contains - // labels + attributes. - // - // Specifically in Terraform this applies to - // a resource type depending on 'provider' attribute. - // - // We do need such dependent keys elsewhere - // to know how to do completion within a block - // but this doesn't matter when completing the label itself - // unless/until we're also completing the dependent attributes. - if _, ok := foundCandidateNames[label.Value]; ok { - continue - } + if len(prefix) > 0 && !strings.HasPrefix(label.Value, string(prefix)) { + continue + } - candidates.List = append(candidates.List, lang.Candidate{ - Label: label.Value, - Kind: lang.LabelCandidateKind, - IsDeprecated: bodySchema.IsDeprecated, - TextEdit: lang.TextEdit{ - NewText: label.Value, - Snippet: label.Value, - Range: editRng, - }, - // TODO: AdditionalTextEdits: - // - prefill required fields if body is empty - // - prefill dependent attribute(s) - Detail: bodySchema.Detail, - Description: bodySchema.Description, - }) - foundCandidateNames[label.Value] = true - count++ + // Dependent keys may be duplicated where one + // key is labels-only and other one contains + // labels + attributes. + // + // Specifically in Terraform this applies to + // a resource type depending on 'provider' attribute. + // + // We do need such dependent keys elsewhere + // to know how to do completion within a block + // but this doesn't matter when completing the label itself + // unless/until we're also completing the dependent attributes. + if _, ok := foundCandidateNames[label.Value]; ok { + continue } + + te := lang.TextEdit{} + if d.PrefillRequiredFields { + snippet := generateRequiredFieldsSnippet(label.Value, bodySchema, labelSchemas, 2, 0) + te = lang.TextEdit{ + NewText: label.Value, + Snippet: snippet, + Range: hcl.RangeBetween(editRng, block.OpenBraceRange), + } + } else { + te = lang.TextEdit{ + NewText: label.Value, + Snippet: label.Value, + Range: editRng, + } + } + + candidates.List = append(candidates.List, lang.Candidate{ + Label: label.Value, + Kind: lang.LabelCandidateKind, + IsDeprecated: bodySchema.IsDeprecated, + TextEdit: te, + Detail: bodySchema.Detail, + Description: bodySchema.Description, + }) + + foundCandidateNames[label.Value] = true + count++ } } @@ -81,6 +95,115 @@ func (d *Decoder) labelCandidatesFromDependentSchema(idx int, db map[schema.Sche return candidates, nil } +// generateRequiredFieldsSnippet returns a properly formatted snippet of all required +// fields (attributes, blocks, etc). It handles the main stanza declaration and calls +// `requiredFieldsSnippet` to handle recursing through the body schema +func generateRequiredFieldsSnippet(label string, bodySchema *schema.BodySchema, labelSchemas []*schema.LabelSchema, placeholder int, indentCount int) string { + snippetText := "" + + // build a space deliminated string of dependent labels + // In Terraform, `label` is the resource we're printing, each label after is the dependent labels + // for example: resource "aws_instance" "foo" + if len(labelSchemas) > 0 { + snippetText += fmt.Sprintf("%s\"", label) + for _, l := range labelSchemas[1:] { + snippetText += fmt.Sprintf(" \"${%d:%s}\"", placeholder, l.Name) + placeholder++ + } + + // must end with a newline to have a correctly formated stanza + snippetText += " {\n" + } + + // get all required fields and build final snippet + snippetText += requiredFieldsSnippet(bodySchema, placeholder, indentCount) + + // add a final tabstop so that the user is landed in the correct place when + // they are finished tabbing through each field + snippetText += "\t${0}" + + return snippetText +} + +// requiredFieldsSnippet returns a properly formatted snippet of all required +// fields (attributes, blocks). It recurses through the Body schema to +// ensure nested fields are accounted for. It takes care to add newlines and +// tabs where necessary to have a snippet be formatted correctly in the target client +func requiredFieldsSnippet(bodySchema *schema.BodySchema, placeholder int, indentCount int) string { + // there are edge cases where we might not have a body, end early here + if bodySchema == nil { + return "" + } + + snippetText := "" + + // to handle recursion we check the value here. if its 0, this is the + // first call so we set to 0 to set a reasonable starting indent + if indentCount == 0 { + indentCount = 1 + } + indent := strings.Repeat("\t", indentCount) + + // store how many required attributes there are for the current body + reqAttr := 0 + attrNames := bodySchema.AttributeNames() + for _, attrName := range attrNames { + attr := bodySchema.Attributes[attrName] + if attr.IsRequired { + reqAttr++ + } + } + + // iterate over each attribute, skip if not required, and print snippet + attrCount := 0 + for _, attrName := range attrNames { + attr := bodySchema.Attributes[attrName] + if !attr.IsRequired { + continue + } + + valueSnippet := snippetForExprContraint(uint(placeholder), attr.Expr) + snippetText += fmt.Sprintf("%s%s = %s", indent, attrName, valueSnippet) + + // attrCount is used to tell if we are at the end of the list of attributes + // so we don't add a trailing newline. this will affect both attribute + // and block placement + attrCount++ + if attrCount <= reqAttr { + snippetText += "\n" + } + placeholder++ + } + + // iterate over each block, skip if not required, and print snippet + blockTypes := bodySchema.BlockTypes() + for _, blockType := range blockTypes { + blockSchema := bodySchema.Blocks[blockType] + if blockSchema.MinItems <= 0 { + continue + } + + // build a space deliminated string of dependent labels, if any + labels := "" + if len(blockSchema.Labels) > 0 { + for _, label := range blockSchema.Labels { + labels += fmt.Sprintf(` "${%d:%s}"`, placeholder, label.Name) + placeholder++ + } + } + + // newlines and indents here affect final snippet, be careful modifying order here + snippetText += fmt.Sprintf("%s%s%s {\n", indent, blockType, labels) + // we increment indentCount by 1 to indicate these are nested underneath + // recurse through the body to find any attributes or blocks and print snippet + snippetText += requiredFieldsSnippet(blockSchema.Body, placeholder, indentCount+1) + // final newline is needed here to properly format each block + snippetText += fmt.Sprintf("%s}\n", indent) + } + + return snippetText +} + func sortedSchemaKeys(m map[schema.SchemaKey]*schema.BodySchema) []schema.SchemaKey { keys := make([]schema.SchemaKey, 0) for k := range m { diff --git a/decoder/label_candidates_test.go b/decoder/label_candidates_test.go index 8ff77030..00cc078e 100644 --- a/decoder/label_candidates_test.go +++ b/decoder/label_candidates_test.go @@ -102,3 +102,526 @@ func TestDecoder_CandidateAtPos_incompleteLabels(t *testing.T) { t.Fatalf("unexpected candidates: %s", diff) } } + +func TestCandidatesAtPos_prefillRequiredFields(t *testing.T) { + startingConfig := "resource \"\" {\n}" + startingPos := hcl.Pos{ + Line: 1, + Column: 11, + Byte: 10, + } + wantRange := hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 11, + Byte: 10, + }, + End: hcl.Pos{ + Line: 1, + Column: 14, + Byte: 13, + }, + } + tests := []struct { + name string + prefill bool + schema *schema.BodySchema + want lang.Candidates + }{ + { + name: "one dependent label no attributes or blocks", + prefill: true, + schema: &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + { + Name: "type", + IsDepKey: true, + Completable: true, + }, + { + Name: "name", + }, + }, + DependentBody: map[schema.SchemaKey]*schema.BodySchema{ + schema.NewSchemaKey(schema.DependencyKeys{ + Labels: []schema.LabelDependent{ + {Index: 0, Value: "aws_appmesh_route"}, + }, + }): { + Attributes: map[string]*schema.AttributeSchema{ + "name": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: false, + }, + }, + }, + }, + }, + }, + }, + want: lang.CompleteCandidates([]lang.Candidate{ + { + Label: "aws_appmesh_route", + Kind: lang.LabelCandidateKind, + TextEdit: lang.TextEdit{ + Range: wantRange, + NewText: `aws_appmesh_route`, + Snippet: `aws_appmesh_route" "${2:name}" { + ${0}`, + }, + }, + }), + }, + { + name: "one dependent label one required attributes no blocks", + prefill: true, + schema: &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + { + Name: "type", + IsDepKey: true, + Completable: true, + }, + { + Name: "name", + }, + }, + DependentBody: map[schema.SchemaKey]*schema.BodySchema{ + schema.NewSchemaKey(schema.DependencyKeys{ + Labels: []schema.LabelDependent{ + {Index: 0, Value: "aws_appmesh_route"}, + }, + }): { + Attributes: map[string]*schema.AttributeSchema{ + "foo": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: true, + }, + }, + }, + }, + }, + }, + }, + want: lang.CompleteCandidates([]lang.Candidate{ + { + Label: "aws_appmesh_route", + Kind: lang.LabelCandidateKind, + TextEdit: lang.TextEdit{ + Range: wantRange, + NewText: `aws_appmesh_route`, + Snippet: `aws_appmesh_route" "${2:name}" { + foo = "${3:value}" + ${0}`, + }, + }, + }), + }, + { + name: "one dependent label multiple required attributes one required block", + prefill: true, + schema: &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + { + Name: "type", + IsDepKey: true, + Completable: true, + }, + { + Name: "name", + }, + }, + DependentBody: map[schema.SchemaKey]*schema.BodySchema{ + schema.NewSchemaKey(schema.DependencyKeys{ + Labels: []schema.LabelDependent{ + {Index: 0, Value: "aws_appmesh_route"}, + }, + }): { + Attributes: map[string]*schema.AttributeSchema{ + "name": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: true, + }, + "anothername": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: true, + }, + }, + Blocks: map[string]*schema.BlockSchema{ + "spec": { + Type: schema.BlockTypeMap, + MinItems: 1, + }, + }, + }, + }, + }, + }, + }, + want: lang.CompleteCandidates([]lang.Candidate{ + { + Label: "aws_appmesh_route", + Kind: lang.LabelCandidateKind, + TextEdit: lang.TextEdit{ + Range: wantRange, + NewText: `aws_appmesh_route`, + Snippet: `aws_appmesh_route" "${2:name}" { + anothername = "${3:value}" + name = "${4:value}" + spec { + } + ${0}`, + }, + }, + }), + }, + { + name: "one dependent label multiple required attributes one required block with required attribute", + prefill: true, + schema: &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + { + Name: "type", + IsDepKey: true, + Completable: true, + }, + { + Name: "name", + }, + }, + DependentBody: map[schema.SchemaKey]*schema.BodySchema{ + schema.NewSchemaKey(schema.DependencyKeys{ + Labels: []schema.LabelDependent{ + {Index: 0, Value: "aws_appmesh_route"}, + }, + }): { + Attributes: map[string]*schema.AttributeSchema{ + "name": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: true, + }, + "anothername": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: true, + }, + }, + Blocks: map[string]*schema.BlockSchema{ + "spec": { + Type: schema.BlockTypeMap, + MinItems: 1, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "name": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: lang.CompleteCandidates([]lang.Candidate{ + { + Label: "aws_appmesh_route", + Kind: lang.LabelCandidateKind, + TextEdit: lang.TextEdit{ + Range: wantRange, + NewText: `aws_appmesh_route`, + Snippet: `aws_appmesh_route" "${2:name}" { + anothername = "${3:value}" + name = "${4:value}" + spec { + name = "${5:value}" + } + ${0}`, + }, + }, + }), + }, + { + name: "one dependent label multiple required attributes one required block with dependent label with no required attributes", + prefill: true, + schema: &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + { + Name: "type", + IsDepKey: true, + Completable: true, + }, + { + Name: "name", + }, + }, + DependentBody: map[schema.SchemaKey]*schema.BodySchema{ + schema.NewSchemaKey(schema.DependencyKeys{ + Labels: []schema.LabelDependent{ + {Index: 0, Value: "aws_appmesh_route"}, + }, + }): { + Attributes: map[string]*schema.AttributeSchema{ + "name": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: true, + }, + "anothername": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: true, + }, + }, + Blocks: map[string]*schema.BlockSchema{ + "spec": { + Type: schema.BlockTypeMap, + Labels: []*schema.LabelSchema{ + { + Name: "key", + }, + }, + MinItems: 1, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "name": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: false, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: lang.CompleteCandidates([]lang.Candidate{ + { + Label: "aws_appmesh_route", + Kind: lang.LabelCandidateKind, + TextEdit: lang.TextEdit{ + Range: wantRange, + NewText: `aws_appmesh_route`, + Snippet: `aws_appmesh_route" "${2:name}" { + anothername = "${3:value}" + name = "${4:value}" + spec "${5:key}" { + } + ${0}`, + }, + }, + }), + }, + { + name: "one dependent label multiple required attributes one required block with dependent label with required attribute", + prefill: true, + schema: &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + { + Name: "type", + IsDepKey: true, + Completable: true, + }, + { + Name: "name", + }, + }, + DependentBody: map[schema.SchemaKey]*schema.BodySchema{ + schema.NewSchemaKey(schema.DependencyKeys{ + Labels: []schema.LabelDependent{ + {Index: 0, Value: "aws_appmesh_route"}, + }, + }): { + Attributes: map[string]*schema.AttributeSchema{ + "name": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: true, + }, + "anothername": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: true, + }, + }, + Blocks: map[string]*schema.BlockSchema{ + "spec": { + Type: schema.BlockTypeMap, + Labels: []*schema.LabelSchema{ + { + Name: "key", + }, + }, + MinItems: 1, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "name": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: lang.CompleteCandidates([]lang.Candidate{ + { + Label: "aws_appmesh_route", + Kind: lang.LabelCandidateKind, + TextEdit: lang.TextEdit{ + Range: wantRange, + NewText: `aws_appmesh_route`, + Snippet: `aws_appmesh_route" "${2:name}" { + anothername = "${3:value}" + name = "${4:value}" + spec "${5:key}" { + name = "${6:value}" + } + ${0}`, + }, + }, + }), + }, + { + name: "one dependent label multiple required attributes one required block with multiple nested required blocks with required attributes", + prefill: true, + schema: &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "resource": { + Labels: []*schema.LabelSchema{ + { + Name: "type", + IsDepKey: true, + Completable: true, + }, + { + Name: "name", + }, + }, + DependentBody: map[schema.SchemaKey]*schema.BodySchema{ + schema.NewSchemaKey(schema.DependencyKeys{ + Labels: []schema.LabelDependent{ + {Index: 0, Value: "aws_appmesh_route"}, + }, + }): { + Attributes: map[string]*schema.AttributeSchema{ + "name": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: true, + }, + "anothername": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: true, + }, + }, + Blocks: map[string]*schema.BlockSchema{ + "spec": { + Type: schema.BlockTypeList, + MinItems: 1, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "name": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: true, + }, + }, + Blocks: map[string]*schema.BlockSchema{ + "listener": { + Type: schema.BlockTypeList, + MinItems: 1, + Body: &schema.BodySchema{ + Blocks: map[string]*schema.BlockSchema{ + "port_mapping": { + Type: schema.BlockTypeList, + MinItems: 1, + Body: &schema.BodySchema{ + Attributes: map[string]*schema.AttributeSchema{ + "port": { + Expr: schema.LiteralTypeOnly(cty.Number), + IsRequired: true, + }, + "protocol": { + Expr: schema.LiteralTypeOnly(cty.String), + IsRequired: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: lang.CompleteCandidates([]lang.Candidate{ + { + Label: "aws_appmesh_route", + Kind: lang.LabelCandidateKind, + TextEdit: lang.TextEdit{ + Range: wantRange, + NewText: `aws_appmesh_route`, + Snippet: `aws_appmesh_route" "${2:name}" { + anothername = "${3:value}" + name = "${4:value}" + spec { + name = "${5:value}" + listener { + port_mapping { + port = ${6:1} + protocol = "${7:value}" + } + } + } + ${0}`, + }, + }, + }), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, _ := hclsyntax.ParseConfig([]byte(startingConfig), "test.tf", hcl.InitialPos) + + d := NewDecoder() + d.PrefillRequiredFields = tt.prefill + d.SetSchema(tt.schema) + + err := d.LoadFile("test.tf", f) + if err != nil { + t.Fatal(err) + } + + got, err := d.CandidatesAtPos("test.tf", startingPos) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Fatalf("unexpected candidates: %s", diff) + } + }) + } +} diff --git a/schema/body_schema.go b/schema/body_schema.go index 8a79cf70..00526ddb 100644 --- a/schema/body_schema.go +++ b/schema/body_schema.go @@ -2,6 +2,7 @@ package schema import ( "fmt" + "sort" "github.com/hashicorp/go-multierror" "github.com/hashicorp/hcl-lang/lang" @@ -50,6 +51,24 @@ func NewBodySchema() *BodySchema { } } +func (as *BodySchema) AttributeNames() []string { + keys := make([]string, 0, len(as.Attributes)) + for k := range as.Attributes { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func (as *BodySchema) BlockTypes() []string { + keys := make([]string, 0, len(as.Blocks)) + for k := range as.Blocks { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + func (bs *BodySchema) Validate() error { if len(bs.Attributes) > 0 && bs.AnyAttribute != nil { return fmt.Errorf("one of Attributes or AnyAttribute must be set, not both")