diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go index d15ef32ba..61caee111 100644 --- a/internal/lsp/completion.go +++ b/internal/lsp/completion.go @@ -41,13 +41,7 @@ func CompletionItem(candidate lang.CompletionCandidate, pos hcl.Pos, snippetSupp InsertTextFormat: lsp.ITFSnippet, Detail: candidate.Detail(), Documentation: doc, - TextEdit: &lsp.TextEdit{ - Range: lsp.Range{ - Start: lsp.Position{Line: pos.Line - 1, Character: pos.Column - 1}, - End: lsp.Position{Line: pos.Line - 1, Character: pos.Column - 1}, - }, - NewText: candidate.Snippet(), - }, + TextEdit: textEdit(candidate.Snippet(), pos), } } @@ -57,12 +51,21 @@ func CompletionItem(candidate lang.CompletionCandidate, pos hcl.Pos, snippetSupp InsertTextFormat: lsp.ITFPlainText, Detail: candidate.Detail(), Documentation: doc, - TextEdit: &lsp.TextEdit{ - Range: lsp.Range{ - Start: lsp.Position{Line: pos.Line - 1, Character: pos.Column - 1}, - End: lsp.Position{Line: pos.Line - 1, Character: pos.Column - 1}, - }, - NewText: candidate.PlainText(), - }, + TextEdit: textEdit(candidate.PlainText(), pos), } } + +func textEdit(te lang.TextEdit, pos hcl.Pos) *lsp.TextEdit { + rng := te.Range() + if rng == nil { + rng = &hcl.Range{ + Start: pos, + End: pos, + } + } + + return &lsp.TextEdit{ + NewText: te.NewText(), + Range: hclRangeToLSP(*rng), + } +} diff --git a/internal/terraform/lang/config_block.go b/internal/terraform/lang/config_block.go index b7bd5d7bf..6d9c2702b 100644 --- a/internal/terraform/lang/config_block.go +++ b/internal/terraform/lang/config_block.go @@ -20,11 +20,11 @@ type configBlockFactory interface { type labelCandidates map[string][]*labelCandidate type completableLabels struct { - logger *log.Logger + logger *log.Logger maxCandidates int - parsedLabels []*ParsedLabel - tBlock ihcl.TokenizedBlock - labels labelCandidates + parsedLabels []*ParsedLabel + tBlock ihcl.TokenizedBlock + labels labelCandidates } func (cl *completableLabels) maxCompletionCandidates() int { @@ -51,7 +51,7 @@ func (cl *completableLabels) completionCandidatesAtPos(pos hcl.Pos) (CompletionC cl.logger.Printf("completing label %q ...", l.Name) - prefix := prefixAtPos(cl.tBlock, pos) + prefix, prefixRng := prefixAtPos(cl.tBlock, pos) for _, c := range candidates { if len(list.candidates) >= cl.maxCompletionCandidates() { @@ -61,7 +61,7 @@ func (cl *completableLabels) completionCandidatesAtPos(pos hcl.Pos) (CompletionC if !strings.HasPrefix(c.Label(), prefix) { continue } - c.prefix = prefix + c.prefixRng = prefixRng list.candidates = append(list.candidates, c) } list.Sort() @@ -72,11 +72,11 @@ func (cl *completableLabels) completionCandidatesAtPos(pos hcl.Pos) (CompletionC // completableBlock provides common completion functionality // for any Block implementation type completableBlock struct { - logger *log.Logger + logger *log.Logger maxCandidates int - parsedLabels []*ParsedLabel - tBlock ihcl.TokenizedBlock - schema *tfjson.SchemaBlock + parsedLabels []*ParsedLabel + tBlock ihcl.TokenizedBlock + schema *tfjson.SchemaBlock } func (cl *completableBlock) maxCompletionCandidates() int { @@ -94,15 +94,11 @@ func (cb *completableBlock) completionCandidatesAtPos(pos hcl.Pos) (CompletionCa block := ParseBlock(cb.tBlock, cb.schema) if !block.PosInBody(pos) { + // TODO: Allow this (requires access to the parser/all block types here) cb.logger.Println("avoiding completion outside of block body") return nil, nil } - if block.PosInAttribute(pos) { - cb.logger.Println("avoiding completion in the middle of existing attribute") - return nil, nil - } - // Completing the body (attributes and nested blocks) b, ok := block.BlockAtPos(pos) if !ok { @@ -112,7 +108,8 @@ func (cb *completableBlock) completionCandidatesAtPos(pos hcl.Pos) (CompletionCa return nil, nil } - prefix := prefixAtPos(cb.tBlock, pos) + prefix, prefixRng := prefixAtPos(cb.tBlock, pos) + cb.logger.Printf("completing block: %#v, %#v", prefix, prefixRng) for name, attr := range b.Attributes() { if len(list.candidates) >= cb.maxCompletionCandidates() { @@ -126,9 +123,9 @@ func (cb *completableBlock) completionCandidatesAtPos(pos hcl.Pos) (CompletionCa continue } list.candidates = append(list.candidates, &attributeCandidate{ - Name: name, - Attr: attr, - Prefix: prefix, + Name: name, + Attr: attr, + PrefixRange: prefixRng, }) } @@ -143,11 +140,15 @@ func (cb *completableBlock) completionCandidatesAtPos(pos hcl.Pos) (CompletionCa if block.ReachedMaxItems() { continue } - list.candidates = append(list.candidates, &nestedBlockCandidate{ + + nbc := &nestedBlockCandidate{ Name: name, BlockType: block, - Prefix: prefix, - }) + } + if prefixRng != nil { + nbc.PrefixRange = prefixRng + } + list.candidates = append(list.candidates, nbc) } list.Sort() @@ -183,7 +184,7 @@ type labelCandidate struct { label string detail string documentation MarkupContent - prefix string + prefixRng *hcl.Range } func (c *labelCandidate) Label() string { @@ -198,18 +199,21 @@ func (c *labelCandidate) Documentation() MarkupContent { return c.documentation } -func (c *labelCandidate) Snippet() string { +func (c *labelCandidate) Snippet() TextEdit { return c.PlainText() } -func (c *labelCandidate) PlainText() string { - return strings.TrimPrefix(c.label, c.prefix) +func (c *labelCandidate) PlainText() TextEdit { + return &textEdit{ + newText: c.label, + rng: c.prefixRng, + } } type attributeCandidate struct { - Name string - Attr *Attribute - Prefix string + Name string + Attr *Attribute + PrefixRange *hcl.Range } func (c *attributeCandidate) Label() string { @@ -236,19 +240,24 @@ func (c *attributeCandidate) Documentation() MarkupContent { return PlainText("") } -func (c *attributeCandidate) Snippet() string { - name := strings.TrimPrefix(c.Name, c.Prefix) - return fmt.Sprintf("%s = %s", name, snippetForAttrType(0, c.Attr.Schema().AttributeType)) +func (c *attributeCandidate) Snippet() TextEdit { + return &textEdit{ + newText: fmt.Sprintf("%s = %s", c.Name, snippetForAttrType(0, c.Attr.Schema().AttributeType)), + rng: c.PrefixRange, + } } -func (c *attributeCandidate) PlainText() string { - return strings.TrimPrefix(c.Name, c.Prefix) +func (c *attributeCandidate) PlainText() TextEdit { + return &textEdit{ + newText: c.Name, + rng: c.PrefixRange, + } } type nestedBlockCandidate struct { - Name string - BlockType *BlockType - Prefix string + Name string + BlockType *BlockType + PrefixRange *hcl.Range } func (c *nestedBlockCandidate) Label() string { @@ -269,11 +278,16 @@ func (c *nestedBlockCandidate) Documentation() MarkupContent { return PlainText(c.BlockType.Schema().Block.Description) } -func (c *nestedBlockCandidate) Snippet() string { - name := strings.TrimPrefix(c.Name, c.Prefix) - return snippetForNestedBlock(name) +func (c *nestedBlockCandidate) Snippet() TextEdit { + return &textEdit{ + newText: snippetForNestedBlock(c.Name), + rng: c.PrefixRange, + } } -func (c *nestedBlockCandidate) PlainText() string { - return strings.TrimPrefix(c.Name, c.Prefix) +func (c *nestedBlockCandidate) PlainText() TextEdit { + return &textEdit{ + newText: c.Name, + rng: c.PrefixRange, + } } diff --git a/internal/terraform/lang/config_block_test.go b/internal/terraform/lang/config_block_test.go index 18806a6b9..30d779bb3 100644 --- a/internal/terraform/lang/config_block_test.go +++ b/internal/terraform/lang/config_block_test.go @@ -291,16 +291,16 @@ func TestCompletableLabels_CompletionCandidatesAtPos_overLimit(t *testing.T) { }`) cl := &completableLabels{ - logger: testLogger(), + logger: testLogger(), parsedLabels: []*ParsedLabel{ {Name: "type", Range: hcl.Range{ Filename: "/test.tf", - Start: hcl.Pos{Line: 1, Column: 10, Byte: 9}, - End: hcl.Pos{Line: 1, Column: 12, Byte: 11}, + Start: hcl.Pos{Line: 1, Column: 10, Byte: 9}, + End: hcl.Pos{Line: 1, Column: 12, Byte: 11}, }}, }, - tBlock: tBlock, - labels: map[string][]*labelCandidate{ + tBlock: tBlock, + labels: map[string][]*labelCandidate{ "type": []*labelCandidate{ {label: "aaa"}, {label: "bbb"}, @@ -328,16 +328,16 @@ func TestCompletableLabels_CompletionCandidatesAtPos_matchingLimit(t *testing.T) }`) cl := &completableLabels{ - logger: testLogger(), + logger: testLogger(), parsedLabels: []*ParsedLabel{ {Name: "type", Range: hcl.Range{ Filename: "/test.tf", - Start: hcl.Pos{Line: 1, Column: 10, Byte: 9}, - End: hcl.Pos{Line: 1, Column: 12, Byte: 11}, + Start: hcl.Pos{Line: 1, Column: 10, Byte: 9}, + End: hcl.Pos{Line: 1, Column: 12, Byte: 11}, }}, }, - tBlock: tBlock, - labels: map[string][]*labelCandidate{ + tBlock: tBlock, + labels: map[string][]*labelCandidate{ "type": []*labelCandidate{ {label: "aaa"}, {label: "bbb"}, @@ -359,6 +359,62 @@ func TestCompletableLabels_CompletionCandidatesAtPos_matchingLimit(t *testing.T) } } +func TestCompletableLabels_CompletionCandidatesAtPos_withPrefix(t *testing.T) { + tBlock := newTestBlock(t, `resource "prov_xyz" { +}`) + + cl := &completableLabels{ + logger: testLogger(), + parsedLabels: []*ParsedLabel{ + {Name: "type", Range: hcl.Range{ + Filename: "/test.tf", + Start: hcl.Pos{Line: 1, Column: 10, Byte: 9}, + End: hcl.Pos{Line: 1, Column: 20, Byte: 19}, + }}, + }, + tBlock: tBlock, + labels: map[string][]*labelCandidate{ + "type": []*labelCandidate{ + {label: "prov_aaa"}, + {label: "prov_bbb"}, + {label: "ccc"}, + }, + }, + } + c, err := cl.completionCandidatesAtPos(hcl.Pos{Line: 1, Column: 16, Byte: 15}) + if err != nil { + t.Fatal(err) + } + + if c.Len() != 2 { + t.Fatalf("Expected exactly 2 candidate, %d given", c.Len()) + } + + candidates := c.List() + te := candidates[0].PlainText() + expectedTextEdit := &textEdit{ + newText: "prov_aaa", + rng: &hcl.Range{ + Filename: "/test.tf", + Start: hcl.Pos{ + Line: 1, + Column: 11, + Byte: 10, + }, + End: hcl.Pos{ + Line: 1, + Column: 19, + Byte: 18, + }, + }, + } + + opts := cmp.AllowUnexported(textEdit{}) + if diff := cmp.Diff(expectedTextEdit, te, opts); diff != "" { + t.Fatalf("Text edit doesn't match: %s", diff) + } +} + func renderCandidates(list CompletionCandidates, pos hcl.Pos) []renderedCandidate { if list == nil { return []renderedCandidate{} @@ -377,7 +433,7 @@ func renderCandidates(list CompletionCandidates, pos hcl.Pos) []renderedCandidat Documentation: doc, Snippet: renderedSnippet{ Pos: pos, - Text: text, + Text: text.NewText(), }, } } diff --git a/internal/terraform/lang/parser.go b/internal/terraform/lang/parser.go index a462d2dc2..9ac9f3731 100644 --- a/internal/terraform/lang/parser.go +++ b/internal/terraform/lang/parser.go @@ -33,7 +33,7 @@ type parser struct { logger *log.Logger maxCandidates int - schemaReader schema.Reader + schemaReader schema.Reader } func ParserSupportsTerraform(v string) error { @@ -70,7 +70,7 @@ func FindCompatibleParser(v string) (Parser, error) { func newParser() *parser { return &parser{ - logger: log.New(ioutil.Discard, "", 0), + logger: log.New(ioutil.Discard, "", 0), maxCandidates: defaultMaxCompletionCandidates, } } @@ -125,7 +125,7 @@ func (p *parser) BlockTypeCandidates(file ihcl.TokenizedFile, pos hcl.Pos) Compl candidates: make([]CompletionCandidate, 0), } - prefix := prefixAtPos(file, pos) + prefix, prefixRng := prefixAtPos(file, pos) for name, t := range bTypes { if len(list.candidates) >= p.maxCandidates { list.isIncomplete = true @@ -139,6 +139,7 @@ func (p *parser) BlockTypeCandidates(file ihcl.TokenizedFile, pos hcl.Pos) Compl LabelSchema: t.LabelSchema(), documentation: t.Documentation(), prefix: prefix, + prefixRng: prefixRng, }) } @@ -150,19 +151,25 @@ type completableBlockType struct { LabelSchema LabelSchema documentation MarkupContent prefix string + prefixRng *hcl.Range } func (bt *completableBlockType) Label() string { return bt.TypeName } -func (bt *completableBlockType) PlainText() string { - return strings.TrimPrefix(bt.TypeName, bt.prefix) +func (bt *completableBlockType) PlainText() TextEdit { + return &textEdit{ + newText: bt.TypeName, + rng: bt.prefixRng, + } } -func (bt *completableBlockType) Snippet() string { - typeName := strings.TrimPrefix(bt.TypeName, bt.prefix) - return snippetForBlock(typeName, bt.LabelSchema) +func (bt *completableBlockType) Snippet() TextEdit { + return &textEdit{ + newText: snippetForBlock(bt.TypeName, bt.LabelSchema), + rng: bt.prefixRng, + } } func (bt *completableBlockType) Detail() string { diff --git a/internal/terraform/lang/types.go b/internal/terraform/lang/types.go index 24947419f..e6f804660 100644 --- a/internal/terraform/lang/types.go +++ b/internal/terraform/lang/types.go @@ -76,8 +76,26 @@ type CompletionCandidate interface { Label() string Detail() string Documentation() MarkupContent - Snippet() string - PlainText() string + Snippet() TextEdit + PlainText() TextEdit +} + +type TextEdit interface { + Range() *hcl.Range + NewText() string +} + +type textEdit struct { + newText string + rng *hcl.Range +} + +func (te *textEdit) Range() *hcl.Range { + return te.rng +} + +func (te *textEdit) NewText() string { + return te.newText } // MarkupContent reflects lsp.MarkupContent diff --git a/internal/terraform/lang/utils.go b/internal/terraform/lang/utils.go index b2df239db..6318d8f25 100644 --- a/internal/terraform/lang/utils.go +++ b/internal/terraform/lang/utils.go @@ -18,18 +18,18 @@ func PosInLabels(b *hclsyntax.Block, pos hcl.Pos) bool { return false } -func prefixAtPos(looker TokenAtPosLooker, pos hcl.Pos) string { +func prefixAtPos(looker TokenAtPosLooker, pos hcl.Pos) (string, *hcl.Range) { token, err := looker.TokenAtPosition(pos) if err != nil { - return "" + return "", nil } switch token.Type { case hclsyntax.TokenIdent, hclsyntax.TokenQuotedLit, hclsyntax.TokenStringLit: - return string(token.Bytes[:pos.Byte-token.Range.Start.Byte]) + return string(token.Bytes[:pos.Byte-token.Range.Start.Byte]), token.Range.Ptr() } - return "" + return "", nil } type TokenAtPosLooker interface {