diff --git a/internal/terraform/lang/config_block.go b/internal/terraform/lang/config_block.go index 45093dfb1..402af85f5 100644 --- a/internal/terraform/lang/config_block.go +++ b/internal/terraform/lang/config_block.go @@ -13,12 +13,44 @@ type configBlockFactory interface { New(*hclsyntax.Block) (ConfigBlock, error) } + +type labelCandidates map[string][]CompletionCandidate + +type completableLabels struct { + logger *log.Logger + block Block + labels labelCandidates +} + +func (cl *completableLabels) completionCandidatesAtPos(pos hcl.Pos) (CompletionCandidates, error) { + list := &completeList{ + candidates: make([]CompletionCandidate, 0), + } + l, ok := cl.block.LabelAtPos(pos) + if !ok { + cl.logger.Printf("label not found at %#v", pos) + return list, nil + } + candidates, ok := cl.labels[l.Name] + if !ok { + cl.logger.Printf("label %q doesn't have completion candidates", l.Name) + return list, nil + } + + cl.logger.Printf("completing label %q ...", l.Name) + for _, c := range candidates { + list.candidates = append(list.candidates, c) + } + list.Sort() + + return list, nil +} + // completableBlock provides common completion functionality // for any Block implementation type completableBlock struct { logger *log.Logger - - block Block + block Block } func (cb *completableBlock) completionCandidatesAtPos(pos hcl.Pos) (CompletionCandidates, error) { @@ -27,7 +59,6 @@ func (cb *completableBlock) completionCandidatesAtPos(pos hcl.Pos) (CompletionCa } if !cb.block.PosInBody(pos) { - // Avoid autocompleting outside of body, for now cb.logger.Println("avoiding completion outside of block body") return nil, nil } @@ -37,6 +68,7 @@ func (cb *completableBlock) completionCandidatesAtPos(pos hcl.Pos) (CompletionCa return nil, nil } + // Completing the body (attributes and nested blocks) b, ok := cb.block.BlockAtPos(pos) if !ok { // This should never happen as the completion @@ -92,6 +124,23 @@ func (l *completeList) IsComplete() bool { return true } +type labelCandidate struct { + label string + detail string +} + +func (c *labelCandidate) Label() string { + return c.label +} + +func (c *labelCandidate) Detail() string { + return c.detail +} + +func (c *labelCandidate) Snippet(pos hcl.Pos) (hcl.Pos, string) { + return pos, c.label +} + type attributeCandidate struct { Name string Attr *Attribute diff --git a/internal/terraform/lang/datasource_block.go b/internal/terraform/lang/datasource_block.go index be95b199f..b9fe9b9d4 100644 --- a/internal/terraform/lang/datasource_block.go +++ b/internal/terraform/lang/datasource_block.go @@ -81,10 +81,6 @@ func (r *datasourceBlock) CompletionCandidatesAtPos(pos hcl.Pos) (CompletionCand return nil, &noSchemaReaderErr{r.BlockType()} } - cb := &completableBlock{ - logger: r.logger, - } - var schemaBlock *tfjson.SchemaBlock if r.Type() != "" { rSchema, err := r.sr.DataSourceSchema(r.Type()) @@ -93,7 +89,39 @@ func (r *datasourceBlock) CompletionCandidatesAtPos(pos hcl.Pos) (CompletionCand } schemaBlock = rSchema.Block } - cb.block = ParseBlock(r.hclBlock, r.Labels(), schemaBlock) + block := ParseBlock(r.hclBlock, r.Labels(), schemaBlock) + + if block.PosInLabels(pos) { + dataSources, err := r.sr.DataSources() + if err != nil { + return nil, err + } + + cl := &completableLabels{ + logger: r.logger, + block: block, + labels: labelCandidates{ + "type": dataSourceCandidates(dataSources), + }, + } + + return cl.completionCandidatesAtPos(pos) + } + cb := &completableBlock{ + logger: r.logger, + block: block, + } return cb.completionCandidatesAtPos(pos) } + +func dataSourceCandidates(dataSources []schema.DataSource) []CompletionCandidate { + candidates := []CompletionCandidate{} + for _, ds := range dataSources { + candidates = append(candidates, &labelCandidate{ + label: ds.Name, + detail: ds.Provider, + }) + } + return candidates +} diff --git a/internal/terraform/lang/datasource_block_test.go b/internal/terraform/lang/datasource_block_test.go index 2db3ca414..85c7ad190 100644 --- a/internal/terraform/lang/datasource_block_test.go +++ b/internal/terraform/lang/datasource_block_test.go @@ -170,6 +170,25 @@ func TestDataSourceBlock_completionCandidatesAtPos(t *testing.T) { []renderedCandidate{}, errors.New("error getting schema"), }, + { + "datasource names", + `data "" "" { +}`, + simpleSchema, + nil, + hcl.Pos{Line: 1, Column: 5, Byte: 6}, + []renderedCandidate{ + { + Label: "custom_ds", + Detail: "custom", + Snippet: renderedSnippet{ + Pos: hcl.Pos{Line: 1, Column: 5, Byte: 6}, + Text: "custom_ds", + }, + }, + }, + nil, + }, } for i, tc := range testCases { diff --git a/internal/terraform/lang/hcl_block.go b/internal/terraform/lang/hcl_block.go index cd7f8c9ab..5bd642e1f 100644 --- a/internal/terraform/lang/hcl_block.go +++ b/internal/terraform/lang/hcl_block.go @@ -49,6 +49,27 @@ func (b *parsedBlock) Range() hcl.Range { return b.hclBlock.Range() } +func (b *parsedBlock) PosInLabels(pos hcl.Pos) bool { + for _, rng := range b.hclBlock.LabelRanges { + if rng.ContainsPos(pos) { + return true + } + } + + return false +} + +func (b *parsedBlock) LabelAtPos(pos hcl.Pos) (*Label, bool) { + for i, rng := range b.hclBlock.LabelRanges { + if rng.ContainsPos(pos) { + // TODO: Guard against crashes when user sets label where we don't expect it + return b.labels[i], true + } + } + + return nil, false +} + func (b *parsedBlock) PosInBody(pos hcl.Pos) bool { for _, blockType := range b.BlockTypesMap { for _, b := range blockType.BlockList { diff --git a/internal/terraform/lang/provider_block.go b/internal/terraform/lang/provider_block.go index e4ae16d7f..72e46cf42 100644 --- a/internal/terraform/lang/provider_block.go +++ b/internal/terraform/lang/provider_block.go @@ -71,10 +71,6 @@ func (p *providerBlock) CompletionCandidatesAtPos(pos hcl.Pos) (CompletionCandid return nil, &noSchemaReaderErr{p.BlockType()} } - cb := &completableBlock{ - logger: p.logger, - } - var schemaBlock *tfjson.SchemaBlock if p.RawName() != "" { pSchema, err := p.sr.ProviderConfigSchema(p.RawName()) @@ -83,7 +79,39 @@ func (p *providerBlock) CompletionCandidatesAtPos(pos hcl.Pos) (CompletionCandid } schemaBlock = pSchema.Block } - cb.block = ParseBlock(p.hclBlock, p.Labels(), schemaBlock) + block := ParseBlock(p.hclBlock, p.Labels(), schemaBlock) + + if block.PosInLabels(pos) { + providers, err := p.sr.Providers() + if err != nil { + return nil, err + } + + cl := &completableLabels{ + logger: p.logger, + block: block, + labels: labelCandidates{ + "name": providerCandidates(providers), + }, + } + + return cl.completionCandidatesAtPos(pos) + } + cb := &completableBlock{ + logger: p.logger, + block: block, + } return cb.completionCandidatesAtPos(pos) } + +func providerCandidates(names []string) []CompletionCandidate { + candidates := []CompletionCandidate{} + for _, name := range names { + candidates = append(candidates, &labelCandidate{ + label: name, + detail: "provider", + }) + } + return candidates +} diff --git a/internal/terraform/lang/provider_block_test.go b/internal/terraform/lang/provider_block_test.go index d6df81c5c..a20076c3b 100644 --- a/internal/terraform/lang/provider_block_test.go +++ b/internal/terraform/lang/provider_block_test.go @@ -161,6 +161,26 @@ func TestProviderBlock_completionCandidatesAtPos(t *testing.T) { []renderedCandidate{}, errors.New("error getting schema"), }, + { + "provider names", + `provider "" { + +}`, + simpleSchema, + nil, + hcl.Pos{Line: 1, Column: 9, Byte: 10}, + []renderedCandidate{ + { + Label: "custom", + Detail: "provider", + Snippet: renderedSnippet{ + Pos: hcl.Pos{Line: 1, Column: 9, Byte: 10}, + Text: "custom", + }, + }, + }, + nil, + }, } for i, tc := range testCases { diff --git a/internal/terraform/lang/resource_block.go b/internal/terraform/lang/resource_block.go index f3788f106..b227431cc 100644 --- a/internal/terraform/lang/resource_block.go +++ b/internal/terraform/lang/resource_block.go @@ -81,10 +81,6 @@ func (r *resourceBlock) CompletionCandidatesAtPos(pos hcl.Pos) (CompletionCandid return nil, &noSchemaReaderErr{r.BlockType()} } - cb := &completableBlock{ - logger: r.logger, - } - var schemaBlock *tfjson.SchemaBlock if r.Type() != "" { rSchema, err := r.sr.ResourceSchema(r.Type()) @@ -93,7 +89,38 @@ func (r *resourceBlock) CompletionCandidatesAtPos(pos hcl.Pos) (CompletionCandid } schemaBlock = rSchema.Block } - cb.block = ParseBlock(r.hclBlock, r.Labels(), schemaBlock) + block := ParseBlock(r.hclBlock, r.Labels(), schemaBlock) + + if block.PosInLabels(pos) { + resources, err := r.sr.Resources() + if err != nil { + return nil, err + } + cl := &completableLabels{ + logger: r.logger, + block: block, + labels: labelCandidates{ + "type": resourceCandidates(resources), + }, + } + return cl.completionCandidatesAtPos(pos) + } + + cb := &completableBlock{ + logger: r.logger, + block: block, + } return cb.completionCandidatesAtPos(pos) } + +func resourceCandidates(resources []schema.Resource) []CompletionCandidate { + candidates := []CompletionCandidate{} + for _, r := range resources { + candidates = append(candidates, &labelCandidate{ + label: r.Name, + detail: r.Provider, + }) + } + return candidates +} diff --git a/internal/terraform/lang/resource_block_test.go b/internal/terraform/lang/resource_block_test.go index cf4db0209..522d98777 100644 --- a/internal/terraform/lang/resource_block_test.go +++ b/internal/terraform/lang/resource_block_test.go @@ -170,6 +170,26 @@ func TestResourceBlock_completionCandidatesAtPos(t *testing.T) { []renderedCandidate{}, errors.New("error getting schema"), }, + { + "resource names", + `resource "" "" { + +}`, + simpleSchema, + nil, + hcl.Pos{Line: 1, Column: 9, Byte: 10}, + []renderedCandidate{ + { + Label: "custom_rs", + Detail: "custom", + Snippet: renderedSnippet{ + Pos: hcl.Pos{Line: 1, Column: 9, Byte: 10}, + Text: "custom_rs", + }, + }, + }, + nil, + }, } for i, tc := range testCases { diff --git a/internal/terraform/lang/types.go b/internal/terraform/lang/types.go index 5e3e85a97..bb17635df 100644 --- a/internal/terraform/lang/types.go +++ b/internal/terraform/lang/types.go @@ -29,7 +29,9 @@ type ConfigBlock interface { // which keeps track of the related schema type Block interface { BlockAtPos(pos hcl.Pos) (Block, bool) + LabelAtPos(pos hcl.Pos) (*Label, bool) Range() hcl.Range + PosInLabels(pos hcl.Pos) bool PosInBody(pos hcl.Pos) bool PosInAttribute(pos hcl.Pos) bool Attributes() map[string]*Attribute diff --git a/internal/terraform/schema/schema_storage.go b/internal/terraform/schema/schema_storage.go index 6da914dff..81961ecbc 100644 --- a/internal/terraform/schema/schema_storage.go +++ b/internal/terraform/schema/schema_storage.go @@ -16,8 +16,11 @@ import ( type Reader interface { ProviderConfigSchema(name string) (*tfjson.Schema, error) + Providers() ([]string, error) ResourceSchema(rType string) (*tfjson.Schema, error) + Resources() ([]Resource, error) DataSourceSchema(dsType string) (*tfjson.Schema, error) + DataSources() ([]DataSource, error) } type Writer interface { @@ -26,6 +29,16 @@ type Writer interface { StartWatching(*exec.Executor) error } +type Resource struct { + Name string + Provider string +} + +type DataSource struct { + Name string + Provider string +} + type Storage struct { ps *tfjson.ProviderSchemas w watcher @@ -158,6 +171,20 @@ func (s *Storage) ProviderConfigSchema(name string) (*tfjson.Schema, error) { return schema.ConfigSchema, nil } +func (s *Storage) Providers() ([]string, error) { + ps, err := s.schema() + if err != nil { + return nil, err + } + + providers := make([]string, 0) + for name, _ := range ps.Schemas { + providers = append(providers, name) + } + + return providers, nil +} + func (s *Storage) ResourceSchema(rType string) (*tfjson.Schema, error) { s.logger.Printf("Reading %q resource schema", rType) @@ -179,6 +206,25 @@ func (s *Storage) ResourceSchema(rType string) (*tfjson.Schema, error) { return nil, &SchemaUnavailableErr{"resource", rType} } +func (s *Storage) Resources() ([]Resource, error) { + ps, err := s.schema() + if err != nil { + return nil, err + } + + resources := make([]Resource, 0) + for provider, schema := range ps.Schemas { + for name, _ := range schema.ResourceSchemas { + resources = append(resources, Resource{ + Provider: provider, + Name: name, + }) + } + } + + return resources, nil +} + func (s *Storage) DataSourceSchema(dsType string) (*tfjson.Schema, error) { s.logger.Printf("Reading %q datasource schema", dsType) @@ -200,6 +246,25 @@ func (s *Storage) DataSourceSchema(dsType string) (*tfjson.Schema, error) { return nil, &SchemaUnavailableErr{"data", dsType} } +func (s *Storage) DataSources() ([]DataSource, error) { + ps, err := s.schema() + if err != nil { + return nil, err + } + + dataSources := make([]DataSource, 0) + for provider, schema := range ps.Schemas { + for name, _ := range schema.DataSourceSchemas { + dataSources = append(dataSources, DataSource{ + Provider: provider, + Name: name, + }) + } + } + + return dataSources, nil +} + // watcher creates a new Watcher instance // if one doesn't exist yet or returns an existing one func (s *Storage) watcher() (watcher, error) { diff --git a/internal/terraform/schema/schema_storage_mock.go b/internal/terraform/schema/schema_storage_mock.go index 3feefdf0c..0b8c86cfc 100644 --- a/internal/terraform/schema/schema_storage_mock.go +++ b/internal/terraform/schema/schema_storage_mock.go @@ -19,8 +19,11 @@ type MockReader struct { ProviderSchemas *tfjson.ProviderSchemas ProviderSchemaErr error + ProvidersErr error ResourceSchemaErr error + ResourcesErr error DataSourceSchemaErr error + DataSourcesErr error } func (r *MockReader) storage() *Storage { @@ -33,6 +36,12 @@ func (r *MockReader) ProviderConfigSchema(name string) (*tfjson.Schema, error) { } return r.storage().ProviderConfigSchema(name) } +func (r *MockReader) Providers() ([]string, error) { + if r.ProviderSchemaErr != nil { + return nil, r.ProviderSchemaErr + } + return r.storage().Providers() +} func (r *MockReader) ResourceSchema(rType string) (*tfjson.Schema, error) { if r.ResourceSchemaErr != nil { @@ -40,6 +49,12 @@ func (r *MockReader) ResourceSchema(rType string) (*tfjson.Schema, error) { } return r.storage().ResourceSchema(rType) } +func (r *MockReader) Resources() ([]Resource, error) { + if r.ResourceSchemaErr != nil { + return nil, r.ResourceSchemaErr + } + return r.storage().Resources() +} func (r *MockReader) DataSourceSchema(dsType string) (*tfjson.Schema, error) { if r.DataSourceSchemaErr != nil { @@ -47,3 +62,9 @@ func (r *MockReader) DataSourceSchema(dsType string) (*tfjson.Schema, error) { } return r.storage().DataSourceSchema(dsType) } +func (r *MockReader) DataSources() ([]DataSource, error) { + if r.DataSourceSchemaErr != nil { + return nil, r.DataSourceSchemaErr + } + return r.storage().DataSources() +} diff --git a/internal/terraform/schema/schema_storage_test.go b/internal/terraform/schema/schema_storage_test.go index b27116772..b14454c6e 100644 --- a/internal/terraform/schema/schema_storage_test.go +++ b/internal/terraform/schema/schema_storage_test.go @@ -45,3 +45,42 @@ func TestDataSourceSchema_noSchema(t *testing.T) { t.Fatalf("Error doesn't match: %s", diff) } } + +func TestDataSources_noSchema(t *testing.T) { + s := NewStorage() + expectedErr := &NoSchemaAvailableErr{} + _, err := s.DataSources() + if err == nil { + t.Fatalf("Expected error (%q)", expectedErr.Error()) + } + if !errors.Is(err, expectedErr) { + diff := cmp.Diff(expectedErr, err) + t.Fatalf("Error doesn't match: %s", diff) + } +} + +func TestProviders_noSchema(t *testing.T) { + s := NewStorage() + expectedErr := &NoSchemaAvailableErr{} + _, err := s.Providers() + if err == nil { + t.Fatalf("Expected error (%q)", expectedErr.Error()) + } + if !errors.Is(err, expectedErr) { + diff := cmp.Diff(expectedErr, err) + t.Fatalf("Error doesn't match: %s", diff) + } +} + +func TestResources_noSchema(t *testing.T) { + s := NewStorage() + expectedErr := &NoSchemaAvailableErr{} + _, err := s.Resources() + if err == nil { + t.Fatalf("Expected error (%q)", expectedErr.Error()) + } + if !errors.Is(err, expectedErr) { + diff := cmp.Diff(expectedErr, err) + t.Fatalf("Error doesn't match: %s", diff) + } +}