Skip to content

Commit

Permalink
Implement completion for registry module sources
Browse files Browse the repository at this point in the history
  • Loading branch information
dbanck committed Aug 9, 2022
1 parent 11ba385 commit ff56bd0
Show file tree
Hide file tree
Showing 6 changed files with 290 additions and 3 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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.0
github.com/google/go-cmp v0.5.8
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 2 additions & 0 deletions internal/hooks/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
package hooks

import (
"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
RegistryClient registry.Client
AlgoliaClient *search.Client
}
86 changes: 86 additions & 0 deletions internal/hooks/module_source_registry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
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"`
}

func (h *Hooks) fetchModulesFromAlgolia(ctx context.Context, term string) ([]RegistryModule, error) {
modules := make([]RegistryModule, 0)

index := h.AlgoliaClient.InitIndex("tf-registry:prod:modules")
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 {
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
}
188 changes: 188 additions & 0 deletions internal/hooks/module_source_registry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package hooks

import (
"context"
"crypto/tls"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"

"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/registry"
"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)
}

regServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("unexpected request: %q", r.RequestURI), 400)
}))
t.Cleanup(regServer.Close)
regClient := registry.NewClient()
regClient.BaseURL = regServer.URL

searchServer := httptest.NewUnstartedServer(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)
}))
searchServer.StartTLS()
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)
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
r := &testRequester{
client: &http.Client{Transport: tr},
}
searchClient := search.NewClientWithConfig(search.Configuration{
Hosts: []string{u.Host},
Requester: r,
})

h := &Hooks{
ModStore: s.Modules,
RegistryClient: regClient,
AlgoliaClient: searchClient,
}

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)
}
})
}
}
14 changes: 11 additions & 3 deletions internal/langserver/handlers/completion_hooks.go
Original file line number Diff line number Diff line change
@@ -1,17 +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/hooks"
)

func (s *service) AppendCompletionHooks(ctx decoder.DecoderContext) {
var AlgoliaAppID = ""
var AlgoliaAPIKey = ""

func (s *service) AppendCompletionHooks(decoderContext decoder.DecoderContext) {
h := hooks.Hooks{
ModStore: s.modStore,
RegistryClient: s.registryClient,
}

ctx.CompletionHooks["CompleteLocalModuleSources"] = h.LocalModuleSources
ctx.CompletionHooks["CompleteRegistryModuleVersions"] = h.RegistryModuleVersions
if AlgoliaAppID != "" && AlgoliaAPIKey != "" {
h.AlgoliaClient = search.NewClient(AlgoliaAppID, AlgoliaAPIKey)
}

decoderContext.CompletionHooks["CompleteLocalModuleSources"] = h.LocalModuleSources
decoderContext.CompletionHooks["CompleteRegistryModuleSources"] = h.RegistryModuleSources
decoderContext.CompletionHooks["CompleteRegistryModuleVersions"] = h.RegistryModuleVersions
}

0 comments on commit ff56bd0

Please sign in to comment.