From 607a812343c06352a39c41739c67d833a03e344c Mon Sep 17 00:00:00 2001 From: Daniel Banck Date: Thu, 11 Aug 2022 15:07:03 +0200 Subject: [PATCH] Complete module `source` and `version` (#1024) * Update hcl-lang to b66451 * Expose registry client to completion hooks * Use Range when creating module calls * Implement completion for local module sources * Extend TF registry for getting module versions * Implement completion for module versions * Implement completion for registry module sources * Expose algolia credentials on main * Review feedback * Update goreleaser config with Algolia credentials * Add logging to registry hook --- .github/workflows/release.yml | 2 + .goreleaser.yml | 4 +- algolia.go | 7 + go.mod | 3 +- go.sum | 5 +- internal/algolia/algolia.go | 22 ++ internal/cmd/serve_command.go | 7 + internal/hooks/hooks.go | 13 +- internal/hooks/module_source_local.go | 42 +++- internal/hooks/module_source_local_test.go | 93 ++++++++ internal/hooks/module_source_registry.go | 89 +++++++ internal/hooks/module_source_registry_test.go | 222 ++++++++++++++++++ internal/hooks/module_version.go | 88 +++++++ internal/hooks/module_version_test.go | 124 ++++++++++ .../langserver/handlers/completion_hooks.go | 16 +- internal/lsp/completion.go | 1 + internal/registry/registry.go | 23 +- internal/registry/registry_test.go | 2 +- internal/state/module.go | 1 + main.go | 6 +- 20 files changed, 748 insertions(+), 22 deletions(-) create mode 100644 algolia.go create mode 100644 internal/algolia/algolia.go create mode 100644 internal/hooks/module_source_local_test.go create mode 100644 internal/hooks/module_source_registry.go create mode 100644 internal/hooks/module_source_registry_test.go create mode 100644 internal/hooks/module_version.go create mode 100644 internal/hooks/module_version_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67fdf2ed1..c5ffed2da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,8 @@ jobs: version: latest args: release env: + ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }} + ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }} HC_RELEASES_HOST: ${{ secrets.HC_RELEASES_HOST_STAGING }} HC_RELEASES_KEY: ${{ secrets.HC_RELEASES_KEY_STAGING }} CODESIGN_IMAGE: ${{ steps.codesign.outputs.image }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 7de187b2c..fea74795a 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -14,7 +14,7 @@ builds: - -trimpath - -tags=preloadschema ldflags: - - '-s -w -X "main.version={{ .RawVersion }}" -X "main.prerelease={{ if .IsSnapshot }}snapshot.{{ .ShortCommit }}{{ else }}{{ .Prerelease }}{{ end }}"' + - '-s -w -X "main.version={{ .RawVersion }}" -X "main.prerelease={{ if .IsSnapshot }}snapshot.{{ .ShortCommit }}{{ else }}{{ .Prerelease }}{{ end }}" -X "main.algoliaAppID={{ .Env.ALGOLIA_APP_ID }}" -X "main.algoliaAPIKey={{ .Env.ALGOLIA_API_KEY }}"' goarch: - '386' - amd64 @@ -40,7 +40,7 @@ builds: - -trimpath - -tags=preloadschema ldflags: - - '-s -w -X "main.version={{ .RawVersion }}" -X "main.prerelease={{ if .IsSnapshot }}snapshot.{{ .ShortCommit }}{{ else }}{{ .Prerelease }}{{ end }}"' + - '-s -w -X "main.version={{ .RawVersion }}" -X "main.prerelease={{ if .IsSnapshot }}snapshot.{{ .ShortCommit }}{{ else }}{{ .Prerelease }}{{ end }}" -X "main.algoliaAppID={{ .Env.ALGOLIA_APP_ID }}" -X "main.algoliaAPIKey={{ .Env.ALGOLIA_API_KEY }}"' goarch: - '386' - amd64 diff --git a/algolia.go b/algolia.go new file mode 100644 index 000000000..25fd860af --- /dev/null +++ b/algolia.go @@ -0,0 +1,7 @@ +package main + +// Algolia application ID which should be used for searching Terraform registry modules +var algoliaAppID = "" + +// Algolia API key which should be used for searching Terraform registry modules +var algoliaAPIKey = "" diff --git a/go.mod b/go.mod index 0d2f0fccf..279bb1951 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.16 require ( github.com/agext/levenshtein v1.2.2 // indirect + github.com/algolia/algoliasearch-client-go/v3 v3.26.0 github.com/apparentlymart/go-textseg v1.0.0 github.com/creachadair/jrpc2 v0.41.1 github.com/google/go-cmp v0.5.8 @@ -14,7 +15,7 @@ require ( github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/hc-install v0.4.0 - github.com/hashicorp/hcl-lang v0.0.0-20220801150536-118ac453e267 + github.com/hashicorp/hcl-lang v0.0.0-20220808135720-b66451713db4 github.com/hashicorp/hcl/v2 v2.13.0 github.com/hashicorp/terraform-exec v0.17.2 github.com/hashicorp/terraform-json v0.14.0 diff --git a/go.sum b/go.sum index 73d85c2e2..217abe0eb 100644 --- a/go.sum +++ b/go.sum @@ -86,6 +86,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/algolia/algoliasearch-client-go/v3 v3.26.0 h1:shlL6HS5p2Nx/+rj5mzbXRj6bUzUSy1jv+CEfyOtlH4= +github.com/algolia/algoliasearch-client-go/v3 v3.26.0/go.mod h1:i7tLoP7TYDmHX3Q7vkIOL4syVse/k5VJ+k0i8WqFiJk= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antonmedv/expr v1.9.0 h1:j4HI3NHEdgDnN9p6oI6Ndr0G5QryMY0FNxT4ONrFDGU= @@ -323,8 +325,9 @@ github.com/hashicorp/hc-install v0.4.0 h1:cZkRFr1WVa0Ty6x5fTvL1TuO1flul231rWkGH9 github.com/hashicorp/hc-install v0.4.0/go.mod h1:5d155H8EC5ewegao9A4PUTMNPZaq+TbOzkJJZ4vrXeI= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/hcl-lang v0.0.0-20220801150536-118ac453e267 h1:8Vn+GbhtkSH0rMv4SYF/WJO1j5z1z0vvpvwHPZB3Fxs= github.com/hashicorp/hcl-lang v0.0.0-20220801150536-118ac453e267/go.mod h1:zGFgYSTTY5D5F+diri+HFKo2JGLqJb5icJl4CSEGPHk= +github.com/hashicorp/hcl-lang v0.0.0-20220808135720-b66451713db4 h1:NltaEn+1rMj2T0kmmTm8u+6abPntW6PPoOlq4h2Kyvk= +github.com/hashicorp/hcl-lang v0.0.0-20220808135720-b66451713db4/go.mod h1:zGFgYSTTY5D5F+diri+HFKo2JGLqJb5icJl4CSEGPHk= github.com/hashicorp/hcl/v2 v2.13.0 h1:0Apadu1w6M11dyGFxWnmhhcMjkbAiKCv7G1r/2QgCNc= github.com/hashicorp/hcl/v2 v2.13.0/go.mod h1:e4z5nxYlWNPdDSNYX+ph14EvWYMFm3eP0zIUqPc2jr0= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= diff --git a/internal/algolia/algolia.go b/internal/algolia/algolia.go new file mode 100644 index 000000000..fff4b913a --- /dev/null +++ b/internal/algolia/algolia.go @@ -0,0 +1,22 @@ +package algolia + +import "context" + +var credentialsKey struct{} + +type Credentials struct { + AppID string + APIKey string +} + +func WithCredentials(ctx context.Context, appID, apiKey string) context.Context { + return context.WithValue(ctx, credentialsKey, Credentials{ + AppID: appID, + APIKey: apiKey, + }) +} + +func CredentialsFromContext(ctx context.Context) (Credentials, bool) { + credentials, ok := ctx.Value(credentialsKey).(Credentials) + return credentials, ok +} diff --git a/internal/cmd/serve_command.go b/internal/cmd/serve_command.go index 8bf87ec6a..7563778b3 100644 --- a/internal/cmd/serve_command.go +++ b/internal/cmd/serve_command.go @@ -11,6 +11,7 @@ import ( "strings" "syscall" + "github.com/hashicorp/terraform-ls/internal/algolia" lsctx "github.com/hashicorp/terraform-ls/internal/context" "github.com/hashicorp/terraform-ls/internal/langserver" "github.com/hashicorp/terraform-ls/internal/langserver/handlers" @@ -23,6 +24,9 @@ type ServeCommand struct { Ui cli.Ui Version string + AlgoliaAppID string + AlgoliaAPIKey string + // flags port int logFilePath string @@ -97,6 +101,9 @@ func (c *ServeCommand) Run(args []string) int { logger.Printf("Starting terraform-ls %s", c.Version) ctx = lsctx.WithLanguageServerVersion(ctx, c.Version) + if c.AlgoliaAppID != "" && c.AlgoliaAPIKey != "" { + ctx = algolia.WithCredentials(ctx, c.AlgoliaAppID, c.AlgoliaAPIKey) + } srv := langserver.NewLangServer(ctx, handlers.NewSession) srv.SetLogger(logger) diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 64ddf7b49..0bc30d7ab 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -3,8 +3,17 @@ // registered via AppendCompletionHooks in completion_hooks.go. package hooks -import "github.com/hashicorp/terraform-ls/internal/state" +import ( + "log" + + "github.com/algolia/algoliasearch-client-go/v3/algolia/search" + "github.com/hashicorp/terraform-ls/internal/registry" + "github.com/hashicorp/terraform-ls/internal/state" +) type Hooks struct { - ModStore *state.ModuleStore + ModStore *state.ModuleStore + RegistryClient registry.Client + AlgoliaClient *search.Client + Logger *log.Logger } diff --git a/internal/hooks/module_source_local.go b/internal/hooks/module_source_local.go index 89c108231..172106d64 100644 --- a/internal/hooks/module_source_local.go +++ b/internal/hooks/module_source_local.go @@ -2,17 +2,53 @@ package hooks import ( "context" + "fmt" + "os" + "path/filepath" + "strings" "github.com/hashicorp/hcl-lang/decoder" + "github.com/hashicorp/terraform-ls/internal/terraform/datadir" "github.com/zclconf/go-cty/cty" ) func (h *Hooks) LocalModuleSources(ctx context.Context, value cty.Value) ([]decoder.Candidate, error) { candidates := make([]decoder.Candidate, 0) - // Obtain indexed modules via h.modStore.List() - // TODO filter modules inside .terraform - // TODO build candidates + modules, err := h.ModStore.List() + path, ok := decoder.PathFromContext(ctx) + if err != nil || !ok { + return candidates, err + } + + for _, mod := range modules { + dirName := fmt.Sprintf("%c%s%c", os.PathSeparator, datadir.DataDirName, os.PathSeparator) + if strings.Contains(mod.Path, dirName) { + // Skip installed module copies in cache directories + continue + } + if mod.Path == path.Path { + // Exclude the module we're providing completion in + // to avoid cyclic references + continue + } + + relPath, err := filepath.Rel(path.Path, mod.Path) + if err != nil { + continue + } + if !strings.HasPrefix(relPath, "..") { + // filepath.Rel will return the cleaned relative path, but Terraform + // expects local module sources to start with ./ + relPath = "./" + relPath + } + relPath = filepath.ToSlash(relPath) + c := decoder.ExpressionCompletionCandidate(decoder.ExpressionCandidate{ + Value: cty.StringVal(relPath), + Detail: "local", + }) + candidates = append(candidates, c) + } return candidates, nil } diff --git a/internal/hooks/module_source_local_test.go b/internal/hooks/module_source_local_test.go new file mode 100644 index 000000000..6453f28aa --- /dev/null +++ b/internal/hooks/module_source_local_test.go @@ -0,0 +1,93 @@ +package hooks + +import ( + "context" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl-lang/decoder" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/terraform-ls/internal/state" + "github.com/zclconf/go-cty/cty" +) + +func TestHooks_LocalModuleSources(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + ctx = decoder.WithPath(ctx, lang.Path{ + Path: tmpDir, + LanguageID: "terraform", + }) + s, err := state.NewStateStore() + if err != nil { + t.Fatal(err) + } + + h := &Hooks{ + ModStore: s.Modules, + } + + modules := []string{ + tmpDir, + filepath.Join(tmpDir, "alpha"), + filepath.Join(tmpDir, "beta"), + filepath.Join(tmpDir, "..", "gamma"), + filepath.Join(".terraform", "modules", "web_server_sg"), + filepath.Join(tmpDir, "any.terraformany"), + filepath.Join(tmpDir, "any.terraform"), + filepath.Join(tmpDir, ".terraformany"), + } + + for _, mod := range modules { + err := s.Modules.Add(mod) + if err != nil { + t.Fatal(err) + } + } + + expectedCandidates := []decoder.Candidate{ + { + Label: "\"./.terraformany\"", + Detail: "local", + Kind: lang.StringCandidateKind, + RawInsertText: "\"./.terraformany\"", + }, + { + Label: "\"./alpha\"", + Detail: "local", + Kind: lang.StringCandidateKind, + RawInsertText: "\"./alpha\"", + }, + { + Label: "\"./any.terraform\"", + Detail: "local", + Kind: lang.StringCandidateKind, + RawInsertText: "\"./any.terraform\"", + }, + { + Label: "\"./any.terraformany\"", + Detail: "local", + Kind: lang.StringCandidateKind, + RawInsertText: "\"./any.terraformany\"", + }, + { + Label: "\"./beta\"", + Detail: "local", + Kind: lang.StringCandidateKind, + RawInsertText: "\"./beta\"", + }, + { + Label: "\"../gamma\"", + Detail: "local", + Kind: lang.StringCandidateKind, + RawInsertText: "\"../gamma\"", + }, + } + + candidates, _ := h.LocalModuleSources(ctx, cty.StringVal("")) + if diff := cmp.Diff(expectedCandidates, candidates); diff != "" { + t.Fatalf("mismatched candidates: %s", diff) + } +} diff --git a/internal/hooks/module_source_registry.go b/internal/hooks/module_source_registry.go new file mode 100644 index 000000000..c89291425 --- /dev/null +++ b/internal/hooks/module_source_registry.go @@ -0,0 +1,89 @@ +package hooks + +import ( + "context" + "strings" + + "github.com/algolia/algoliasearch-client-go/v3/algolia/opt" + "github.com/hashicorp/hcl-lang/decoder" + "github.com/hashicorp/hcl-lang/lang" + "github.com/zclconf/go-cty/cty" +) + +type RegistryModule struct { + FullName string `json:"full-name"` + Description string `json:"description"` +} + +const algoliaModuleIndex = "tf-registry:prod:modules" + +func (h *Hooks) fetchModulesFromAlgolia(ctx context.Context, term string) ([]RegistryModule, error) { + modules := make([]RegistryModule, 0) + + index := h.AlgoliaClient.InitIndex(algoliaModuleIndex) + params := []interface{}{ + ctx, // transport.Request will magically extract the context from here + opt.AttributesToRetrieve("full-name", "description"), + opt.HitsPerPage(10), + } + + res, err := index.Search(term, params...) + if err != nil { + return modules, err + } + + err = res.UnmarshalHits(&modules) + if err != nil { + return modules, err + + } + + return modules, nil +} + +func (h *Hooks) RegistryModuleSources(ctx context.Context, value cty.Value) ([]decoder.Candidate, error) { + candidates := make([]decoder.Candidate, 0) + prefix := value.AsString() + + if isModuleSourceLocal(prefix) { + // We're dealing with a local module source here, no need to search the registry + return candidates, nil + } + + if h.AlgoliaClient == nil { + return candidates, nil + } + + modules, err := h.fetchModulesFromAlgolia(ctx, prefix) + if err != nil { + h.Logger.Printf("Error fetching modules from Algolia: %#v", err) + return candidates, err + } + + for _, mod := range modules { + c := decoder.ExpressionCompletionCandidate(decoder.ExpressionCandidate{ + Value: cty.StringVal(mod.FullName), + Detail: "registry", + Description: lang.PlainText(mod.Description), + }) + candidates = append(candidates, c) + } + + return candidates, nil +} + +var moduleSourceLocalPrefixes = []string{ + "./", + "../", + ".\\", + "..\\", +} + +func isModuleSourceLocal(raw string) bool { + for _, prefix := range moduleSourceLocalPrefixes { + if strings.HasPrefix(raw, prefix) { + return true + } + } + return false +} diff --git a/internal/hooks/module_source_registry_test.go b/internal/hooks/module_source_registry_test.go new file mode 100644 index 000000000..438898edc --- /dev/null +++ b/internal/hooks/module_source_registry_test.go @@ -0,0 +1,222 @@ +package hooks + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "io/ioutil" + "log" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/algolia/algoliasearch-client-go/v3/algolia/search" + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl-lang/decoder" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/terraform-ls/internal/state" + "github.com/zclconf/go-cty/cty" +) + +const responseAWS = `{ + "hits": [ + { + "full-name": "terraform-aws-modules/vpc/aws", + "description": "Terraform module which creates VPC resources on AWS", + "objectID": "modules:23" + }, + { + "full-name": "terraform-aws-modules/eks/aws", + "description": "Terraform module to create an Elastic Kubernetes (EKS) cluster and associated resources", + "objectID": "modules:1143" + } + ], + "nbHits": 10200, + "page": 0, + "nbPages": 100, + "hitsPerPage": 2, + "exhaustiveNbHits": true, + "exhaustiveTypo": true, + "query": "aws", + "params": "attributesToRetrieve=%5B%22full-name%22%2C%22description%22%5D&hitsPerPage=2&query=aws", + "renderingContent": {}, + "processingTimeMS": 1, + "processingTimingsMS": {} +}` + +const responseEmpty = `{ + "hits": [], + "nbHits": 0, + "page": 0, + "nbPages": 0, + "hitsPerPage": 2, + "exhaustiveNbHits": true, + "exhaustiveTypo": true, + "query": "foo", + "params": "attributesToRetrieve=%5B%22full-name%22%2C%22description%22%5D&hitsPerPage=2&query=foo", + "renderingContent": {}, + "processingTimeMS": 1 +}` + +const responseErr = `{ + "message": "Invalid Application-ID or API key", + "status": 403 +}` + +type testRequester struct { + client *http.Client +} + +func (r *testRequester) Request(req *http.Request) (*http.Response, error) { + return r.client.Do(req) +} + +func TestHooks_RegistryModuleSources(t *testing.T) { + ctx := context.Background() + + s, err := state.NewStateStore() + if err != nil { + t.Fatal(err) + } + + searchClient := buildSearchClientMock(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.RequestURI == "/1/indexes/tf-registry%3Aprod%3Amodules/query" { + b, _ := io.ReadAll(r.Body) + + if strings.Contains(string(b), "query=aws") { + w.Write([]byte(responseAWS)) + return + } else if strings.Contains(string(b), "query=err") { + http.Error(w, responseErr, http.StatusForbidden) + return + } + + w.Write([]byte(responseEmpty)) + return + } + http.Error(w, fmt.Sprintf("unexpected request: %q", r.RequestURI), 400) + })) + + h := &Hooks{ + ModStore: s.Modules, + AlgoliaClient: searchClient, + Logger: log.New(ioutil.Discard, "", 0), + } + + tests := []struct { + name string + value cty.Value + want []decoder.Candidate + wantErr bool + }{ + { + "simple search", + cty.StringVal("aws"), + []decoder.Candidate{ + { + Label: `"terraform-aws-modules/vpc/aws"`, + Detail: "registry", + Kind: lang.StringCandidateKind, + Description: lang.PlainText("Terraform module which creates VPC resources on AWS"), + RawInsertText: `"terraform-aws-modules/vpc/aws"`, + }, + { + Label: `"terraform-aws-modules/eks/aws"`, + Detail: "registry", + Kind: lang.StringCandidateKind, + Description: lang.PlainText("Terraform module to create an Elastic Kubernetes (EKS) cluster and associated resources"), + RawInsertText: `"terraform-aws-modules/eks/aws"`, + }, + }, + false, + }, + { + "empty result", + cty.StringVal("foo"), + []decoder.Candidate{}, + false, + }, + { + "auth error", + cty.StringVal("err"), + []decoder.Candidate{}, + true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + candidates, err := h.RegistryModuleSources(ctx, tt.value) + + if (err != nil) != tt.wantErr { + t.Errorf("Hooks.RegistryModuleSources() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if diff := cmp.Diff(tt.want, candidates); diff != "" { + t.Fatalf("mismatched candidates: %s", diff) + } + }) + } +} + +func TestHooks_RegistryModuleSourcesCtxCancel(t *testing.T) { + ctx := context.Background() + ctx, cancelFunc := context.WithTimeout(ctx, 50*time.Millisecond) + t.Cleanup(cancelFunc) + + s, err := state.NewStateStore() + if err != nil { + t.Fatal(err) + } + + searchClient := buildSearchClientMock(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(500 * time.Millisecond) + http.Error(w, fmt.Sprintf("unexpected request: %q", r.RequestURI), 400) + })) + + h := &Hooks{ + ModStore: s.Modules, + AlgoliaClient: searchClient, + Logger: log.New(ioutil.Discard, "", 0), + } + + _, err = h.RegistryModuleSources(ctx, cty.StringVal("aws")) + e, ok := err.(net.Error) + if !ok { + t.Fatalf("expected error, got %#v", err) + } + + if !strings.Contains(e.Error(), "context deadline exceeded") { + t.Fatalf("expected error with: %q, given: %q", "context deadline exceeded", e.Error()) + } +} + +func buildSearchClientMock(t *testing.T, handler http.HandlerFunc) *search.Client { + searchServer := httptest.NewTLSServer(handler) + t.Cleanup(searchServer.Close) + + // Algolia requires hosts to be without a protocol and always assumes https + u, err := url.Parse(searchServer.URL) + if err != nil { + t.Fatal(err) + } + searchClient := search.NewClientWithConfig(search.Configuration{ + Hosts: []string{u.Host}, + // We need to disable certificate checking here, because of the self signed cert + Requester: &testRequester{ + client: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + }, + }, + }) + + return searchClient +} diff --git a/internal/hooks/module_version.go b/internal/hooks/module_version.go new file mode 100644 index 000000000..91962012c --- /dev/null +++ b/internal/hooks/module_version.go @@ -0,0 +1,88 @@ +package hooks + +import ( + "context" + "errors" + "fmt" + + "github.com/hashicorp/hcl-lang/decoder" + "github.com/hashicorp/hcl/v2" + tfaddr "github.com/hashicorp/terraform-registry-address" + tfmod "github.com/hashicorp/terraform-schema/module" + "github.com/zclconf/go-cty/cty" +) + +func getModuleSourceAddr(moduleCalls map[string]tfmod.DeclaredModuleCall, pos hcl.Pos, filename string) (tfmod.ModuleSourceAddr, bool) { + for _, mc := range moduleCalls { + if mc.RangePtr == nil { + // This can only happen if the file is JSON + // In this case we're not providing completion anyway + continue + } + if mc.RangePtr.ContainsPos(pos) && mc.RangePtr.Filename == filename { + return mc.SourceAddr, true + } + } + + return nil, false +} + +func (h *Hooks) RegistryModuleVersions(ctx context.Context, value cty.Value) ([]decoder.Candidate, error) { + candidates := make([]decoder.Candidate, 0) + + path, ok := decoder.PathFromContext(ctx) + if !ok { + return candidates, errors.New("missing context: path") + } + pos, ok := decoder.PosFromContext(ctx) + if !ok { + return candidates, errors.New("missing context: pos") + } + filename, ok := decoder.FilenameFromContext(ctx) + if !ok { + return candidates, errors.New("missing context: filename") + } + maxCandidates, ok := decoder.MaxCandidatesFromContext(ctx) + if !ok { + return candidates, errors.New("missing context: maxCandidates") + } + + module, err := h.ModStore.ModuleByPath(path.Path) + if err != nil { + return candidates, err + } + + sourceAddr, ok := getModuleSourceAddr(module.Meta.ModuleCalls, pos, filename) + if !ok { + return candidates, nil + } + registryAddr, ok := sourceAddr.(tfaddr.Module) + if !ok { + // Trying to complete version on local or external module + return candidates, nil + } + + versions, err := h.RegistryClient.GetModuleVersions(ctx, registryAddr) + if err != nil { + return candidates, err + } + + for i, v := range versions { + if uint(i) >= maxCandidates { + return candidates, nil + } + + c := decoder.ExpressionCompletionCandidate(decoder.ExpressionCandidate{ + Value: cty.StringVal(v.String()), + }) + // We rely on the fact that hcl-lang limits number of candidates + // to 100, so padding with <=3 zeros provides naive but good enough + // way to reliably "lexicographically" sort the versions as there's + // no better way to do it in LSP. + c.SortText = fmt.Sprintf("%3d", i) + + candidates = append(candidates, c) + } + + return candidates, nil +} diff --git a/internal/hooks/module_version_test.go b/internal/hooks/module_version_test.go new file mode 100644 index 000000000..93ab204be --- /dev/null +++ b/internal/hooks/module_version_test.go @@ -0,0 +1,124 @@ +package hooks + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl-lang/decoder" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform-ls/internal/registry" + "github.com/hashicorp/terraform-ls/internal/state" + tfaddr "github.com/hashicorp/terraform-registry-address" + tfmod "github.com/hashicorp/terraform-schema/module" + "github.com/zclconf/go-cty/cty" +) + +var moduleVersionsMockResponse = `{ + "modules": [ + { + "source": "terraform-aws-modules/vpc/aws", + "versions": [ + { + "version": "0.0.1" + }, + { + "version": "2.0.24" + }, + { + "version": "1.33.7" + } + ] + } + ] + }` + +func TestHooks_RegistryModuleVersions(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + ctx = decoder.WithPath(ctx, lang.Path{ + Path: tmpDir, + LanguageID: "terraform", + }) + ctx = decoder.WithPos(ctx, hcl.Pos{ + Line: 2, + Column: 5, + Byte: 5, + }) + ctx = decoder.WithFilename(ctx, "main.tf") + ctx = decoder.WithMaxCandidates(ctx, 3) + s, err := state.NewStateStore() + if err != nil { + t.Fatal(err) + } + + regClient := registry.NewClient() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.RequestURI == "/v1/modules/terraform-aws-modules/vpc/aws/versions" { + w.Write([]byte(moduleVersionsMockResponse)) + return + } + http.Error(w, fmt.Sprintf("unexpected request: %q", r.RequestURI), 400) + })) + regClient.BaseURL = srv.URL + t.Cleanup(srv.Close) + + h := &Hooks{ + ModStore: s.Modules, + RegistryClient: regClient, + } + + err = s.Modules.Add(tmpDir) + if err != nil { + t.Fatal(err) + } + metadata := &tfmod.Meta{ + Path: tmpDir, + ModuleCalls: map[string]tfmod.DeclaredModuleCall{ + "vpc": { + LocalName: "vpc", + SourceAddr: tfaddr.MustParseModuleSource("registry.terraform.io/terraform-aws-modules/vpc/aws"), + RangePtr: &hcl.Range{ + Filename: "main.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 1}, + End: hcl.Pos{Line: 4, Column: 2, Byte: 20}, + }, + }, + }, + } + err = s.Modules.UpdateMetadata(tmpDir, metadata, nil) + if err != nil { + t.Fatal(err) + } + + expectedCandidates := []decoder.Candidate{ + { + Label: `"2.0.24"`, + Kind: lang.StringCandidateKind, + RawInsertText: `"2.0.24"`, + SortText: " 0", + }, + { + Label: `"1.33.7"`, + Kind: lang.StringCandidateKind, + RawInsertText: `"1.33.7"`, + SortText: " 1", + }, + { + Label: `"0.0.1"`, + Kind: lang.StringCandidateKind, + RawInsertText: `"0.0.1"`, + SortText: " 2", + }, + } + + candidates, _ := h.RegistryModuleVersions(ctx, cty.StringVal("")) + if diff := cmp.Diff(expectedCandidates, candidates); diff != "" { + t.Fatalf("mismatched candidates: %s", diff) + } +} diff --git a/internal/langserver/handlers/completion_hooks.go b/internal/langserver/handlers/completion_hooks.go index 4cff05107..96f2110a4 100644 --- a/internal/langserver/handlers/completion_hooks.go +++ b/internal/langserver/handlers/completion_hooks.go @@ -1,15 +1,25 @@ package handlers import ( + "github.com/algolia/algoliasearch-client-go/v3/algolia/search" "github.com/hashicorp/hcl-lang/decoder" + "github.com/hashicorp/terraform-ls/internal/algolia" "github.com/hashicorp/terraform-ls/internal/hooks" ) -func (s *service) AppendCompletionHooks(ctx decoder.DecoderContext) { +func (s *service) AppendCompletionHooks(decoderContext decoder.DecoderContext) { h := hooks.Hooks{ - ModStore: s.modStore, + ModStore: s.modStore, + RegistryClient: s.registryClient, + Logger: s.logger, } - ctx.CompletionHooks["CompleteLocalModuleSources"] = h.LocalModuleSources + credentials, ok := algolia.CredentialsFromContext(s.srvCtx) + if ok { + h.AlgoliaClient = search.NewClient(credentials.AppID, credentials.APIKey) + } + decoderContext.CompletionHooks["CompleteLocalModuleSources"] = h.LocalModuleSources + decoderContext.CompletionHooks["CompleteRegistryModuleSources"] = h.RegistryModuleSources + decoderContext.CompletionHooks["CompleteRegistryModuleVersions"] = h.RegistryModuleVersions } diff --git a/internal/lsp/completion.go b/internal/lsp/completion.go index c01214516..8eee68340 100644 --- a/internal/lsp/completion.go +++ b/internal/lsp/completion.go @@ -71,6 +71,7 @@ func toCompletionItem(candidate lang.Candidate, caps lsp.CompletionClientCapabil TextEdit: textEdit(candidate.TextEdit, snippetSupport), Command: cmd, AdditionalTextEdits: TextEdits(candidate.AdditionalTextEdits, snippetSupport), + SortText: candidate.SortText, } if candidate.ResolveHook != nil { diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 3ffe47b62..94682d3a7 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -76,6 +76,21 @@ func (c Client) GetModuleData(ctx context.Context, addr tfaddr.Module, cons vers } func (c Client) GetMatchingModuleVersion(ctx context.Context, addr tfaddr.Module, con version.Constraints) (*version.Version, error) { + foundVersions, err := c.GetModuleVersions(ctx, addr) + if err != nil { + return nil, err + } + + for _, fv := range foundVersions { + if con.Check(fv) { + return fv, nil + } + } + + return nil, fmt.Errorf("no suitable version found for %q %q", addr, con) +} + +func (c Client) GetModuleVersions(ctx context.Context, addr tfaddr.Module) (version.Collection, error) { url := fmt.Sprintf("%s/v1/modules/%s/%s/%s/versions", c.BaseURL, addr.Package.Namespace, addr.Package.Name, @@ -121,11 +136,5 @@ func (c Client) GetMatchingModuleVersion(ctx context.Context, addr tfaddr.Module sort.Sort(sort.Reverse(foundVersions)) - for _, fv := range foundVersions { - if con.Check(fv) { - return fv, nil - } - } - - return nil, fmt.Errorf("no suitable version found for %q %q", addr, con) + return foundVersions, nil } diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 620d6801b..76c6bb6e1 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -166,7 +166,7 @@ func TestCancellationThroughContext(t *testing.T) { client := NewClient() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(100 * time.Millisecond) + time.Sleep(500 * time.Millisecond) if r.RequestURI == "/v1/modules/puppetlabs/deployment/ec/versions" { w.Write([]byte(moduleVersionsMockResponse)) return diff --git a/internal/state/module.go b/internal/state/module.go index fb48d1d50..ffba34f41 100644 --- a/internal/state/module.go +++ b/internal/state/module.go @@ -385,6 +385,7 @@ func (s *ModuleStore) ModuleCalls(modPath string) (tfmod.ModuleCalls, error) { SourceAddr: mc.SourceAddr, Version: mc.Version, InputNames: mc.InputNames, + RangePtr: mc.RangePtr, } } diff --git a/main.go b/main.go index b911400d8..3a99ebdb7 100644 --- a/main.go +++ b/main.go @@ -29,8 +29,10 @@ func main() { c.Commands = map[string]cli.CommandFactory{ "serve": func() (cli.Command, error) { return &cmd.ServeCommand{ - Ui: ui, - Version: VersionString(), + Ui: ui, + Version: VersionString(), + AlgoliaAppID: algoliaAppID, + AlgoliaAPIKey: algoliaAPIKey, }, nil }, "inspect-module": func() (cli.Command, error) {