Skip to content

Commit

Permalink
Add Prefill Required Fields Completion
Browse files Browse the repository at this point in the history
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}
}
```
  • Loading branch information
jpogran committed Oct 7, 2021
1 parent 4bf451b commit 0286430
Show file tree
Hide file tree
Showing 8 changed files with 800 additions and 41 deletions.
43 changes: 40 additions & 3 deletions decoder/block_candidates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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

Expand Down
3 changes: 2 additions & 1 deletion decoder/body_candidates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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++
}

Expand Down
2 changes: 1 addition & 1 deletion decoder/candidates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
3 changes: 3 additions & 0 deletions decoder/decoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions decoder/expression_candidates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
195 changes: 159 additions & 36 deletions decoder/label_candidates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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++
}
}

Expand All @@ -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 {
Expand Down
Loading

0 comments on commit 0286430

Please sign in to comment.