diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d692865..99d5838e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ env: jobs: build: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 steps: - name: Checkout diff --git a/go.mod b/go.mod index 3331cae7..5b5e0374 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/fsnotify/fsnotify v1.5.1 github.com/google/go-cmp v0.5.6 github.com/google/uuid v1.2.0 // indirect + github.com/hashicorp/go-getter v1.5.8 github.com/hashicorp/go-memdb v1.3.2 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-version v1.3.0 @@ -16,7 +17,7 @@ require ( github.com/hashicorp/hcl/v2 v2.10.1 github.com/hashicorp/terraform-exec v0.14.0 github.com/hashicorp/terraform-json v0.12.0 - github.com/hashicorp/terraform-registry-address v0.0.0-20210412075316-9b2996cce896 + github.com/hashicorp/terraform-registry-address v0.0.0-20210816115301-cb2034eba045 github.com/hashicorp/terraform-schema v0.0.0-20210823185306-e7a9c4e84cd1 github.com/kylelemons/godebug v1.1.0 // indirect github.com/mh-cbon/go-fmt-fail v0.0.0-20160815164508-67765b3fbcb5 diff --git a/go.sum b/go.sum index 431fd966..5a7b6e9e 100644 --- a/go.sum +++ b/go.sum @@ -159,8 +159,9 @@ github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtng github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-getter v1.5.3 h1:NF5+zOlQegim+w/EUhSLh6QhXHmZMEeHLQzllkQ3ROU= github.com/hashicorp/go-getter v1.5.3/go.mod h1:BrrV/1clo8cCYu6mxvboYg+KutTiFnXjMEgDD8+i7ZI= +github.com/hashicorp/go-getter v1.5.8 h1:qx5CZXxXz5YFpALPkbf/F1iZZoRE+f6T1i/AWw/Zkic= +github.com/hashicorp/go-getter v1.5.8/go.mod h1:BrrV/1clo8cCYu6mxvboYg+KutTiFnXjMEgDD8+i7ZI= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.0 h1:8exGP7ego3OmkfksihtSouGMZ+hQrhxx+FVELeXpVPE= github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= @@ -202,8 +203,9 @@ github.com/hashicorp/terraform-exec v0.14.0 h1:UQoUcxKTZZXhyyK68Cwn4mApT4mnFPmEX github.com/hashicorp/terraform-exec v0.14.0/go.mod h1:qrAASDq28KZiMPDnQ02sFS9udcqEkRly002EA2izXTA= github.com/hashicorp/terraform-json v0.12.0 h1:8czPgEEWWPROStjkWPUnTQDXmpmZPlkQAwYYLETaTvw= github.com/hashicorp/terraform-json v0.12.0/go.mod h1:pmbq9o4EuL43db5+0ogX10Yofv1nozM+wskr/bGFJpI= -github.com/hashicorp/terraform-registry-address v0.0.0-20210412075316-9b2996cce896 h1:1FGtlkJw87UsTMg5s8jrekrHmUPUJaMcu6ELiVhQrNw= github.com/hashicorp/terraform-registry-address v0.0.0-20210412075316-9b2996cce896/go.mod h1:bzBPnUIkI0RxauU8Dqo+2KrZZ28Cf48s8V6IHt3p4co= +github.com/hashicorp/terraform-registry-address v0.0.0-20210816115301-cb2034eba045 h1:R/I8ofvXuPcTNoc//N4ruvaHGZcShI/VuU2iXo875Lo= +github.com/hashicorp/terraform-registry-address v0.0.0-20210816115301-cb2034eba045/go.mod h1:anRyJbe12BZscpFgaeGu9gH12qfdBP094LYFtuAFzd4= github.com/hashicorp/terraform-schema v0.0.0-20210823185306-e7a9c4e84cd1 h1:K6wkKTi4+aSYXDFGbGWgd3sP+gWTTM9VYOVeCXjJJm8= github.com/hashicorp/terraform-schema v0.0.0-20210823185306-e7a9c4e84cd1/go.mod h1:wG+IttAk2LqgHE76fD0wt2kucaKzV7pzm7OTqCJJC3M= github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734 h1:HKLsbzeOsfXmKNpr3GiT18XAblV0BjCbzL8KQAMZGa0= diff --git a/internal/langserver/handlers/command/module_calls.go b/internal/langserver/handlers/command/module_calls.go new file mode 100644 index 00000000..9d7be602 --- /dev/null +++ b/internal/langserver/handlers/command/module_calls.go @@ -0,0 +1,139 @@ +package command + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/creachadair/jrpc2/code" + lsctx "github.com/hashicorp/terraform-ls/internal/context" + "github.com/hashicorp/terraform-ls/internal/langserver/cmd" + "github.com/hashicorp/terraform-ls/internal/terraform/datadir" + "github.com/hashicorp/terraform-ls/internal/uri" +) + +const moduleCallsVersion = 0 + +type moduleCallsResponse struct { + FormatVersion int `json:"v"` + ModuleCalls []moduleCall `json:"module_calls"` +} + +type moduleCall struct { + Name string `json:"name"` + SourceAddr string `json:"source_addr"` + Version string `json:"version,omitempty"` + SourceType datadir.ModuleType `json:"source_type,omitempty"` + DocsLink string `json:"docs_link,omitempty"` + DependentModules []moduleCall `json:"dependent_modules"` +} + +func ModuleCallsHandler(ctx context.Context, args cmd.CommandArgs) (interface{}, error) { + response := moduleCallsResponse{ + FormatVersion: moduleCallsVersion, + ModuleCalls: make([]moduleCall, 0), + } + + modUri, ok := args.GetString("uri") + if !ok || modUri == "" { + return response, fmt.Errorf("%w: expected module uri argument to be set", code.InvalidParams.Err()) + } + + if !uri.IsURIValid(modUri) { + return response, fmt.Errorf("URI %q is not valid", modUri) + } + + modPath, err := uri.PathFromURI(modUri) + if err != nil { + return response, err + } + + mm, err := lsctx.ModuleFinder(ctx) + if err != nil { + return response, err + } + + found, _ := mm.ModuleByPath(modPath) + if found == nil { + return response, nil + } + + if found.ModManifest == nil { + return response, nil + } + + response.ModuleCalls = parseModuleRecords(found.ModManifest.Records) + + return response, nil +} + +func parseModuleRecords(records []datadir.ModuleRecord) []moduleCall { + // sort all records by key so that dependent modules are found + // after primary modules + sort.SliceStable(records, func(i, j int) bool { + return records[i].Key < records[j].Key + }) + + modules := make(map[string]moduleCall) + for _, manifest := range records { + if manifest.IsRoot() { + // this is the current directory, which is technically a module + // skipping as it's not relevant in the activity bar (yet?) + continue + } + + moduleName := manifest.Key + subModuleName := "" + + // determine if this module is nested in another module + // in the currecnt workspace by finding a period in the moduleName + // is it better to look at SourceAddr and compare? + if strings.Contains(manifest.Key, ".") { + v := strings.Split(manifest.Key, ".") + moduleName = v[0] + subModuleName = v[1] + } + + // build what we know + moduleInfo := moduleCall{ + Name: moduleName, + SourceAddr: manifest.SourceAddr, + DocsLink: getModuleDocumentationLink(manifest), + Version: manifest.VersionStr, + SourceType: manifest.GetModuleType(), + DependentModules: make([]moduleCall, 0), + } + + m, present := modules[moduleName] + if present { + // this module is located inside another so append + moduleInfo.Name = subModuleName + m.DependentModules = append(m.DependentModules, moduleInfo) + modules[moduleName] = m + } else { + // this is the first we've seen module + modules[moduleName] = moduleInfo + } + } + + // don't need the map anymore, return a list of modules found + list := make([]moduleCall, 0) + for _, mo := range modules { + list = append(list, mo) + } + + sort.SliceStable(list, func(i, j int) bool { + return list[i].Name < list[j].Name + }) + + return list +} + +func getModuleDocumentationLink(record datadir.ModuleRecord) string { + if record.GetModuleType() != datadir.TFREGISTRY { + return "" + } + + return fmt.Sprintf(`https://registry.terraform.io/modules/%s/%s`, record.SourceAddr, record.VersionStr) +} diff --git a/internal/langserver/handlers/command/module_calls_test.go b/internal/langserver/handlers/command/module_calls_test.go new file mode 100644 index 00000000..05ced561 --- /dev/null +++ b/internal/langserver/handlers/command/module_calls_test.go @@ -0,0 +1,89 @@ +package command + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-ls/internal/terraform/datadir" +) + +func Test_parseModuleRecords(t *testing.T) { + tests := []struct { + name string + records []datadir.ModuleRecord + want []moduleCall + }{ + { + name: "detects terraform module types", + records: []datadir.ModuleRecord{ + { + Key: "ec2_instances", + SourceAddr: "terraform-aws-modules/ec2-instance/aws", + VersionStr: "2.12.0", + Dir: ".terraform\\modules\\ec2_instances", + }, + { + Key: "web_server_sg", + SourceAddr: "github.com/terraform-aws-modules/terraform-aws-security-group", + VersionStr: "", + Dir: ".terraform\\modules\\web_server_sg", + }, + { + Key: "eks", + SourceAddr: "terraform-aws-modules/eks/aws", + VersionStr: "17.20.0", + Dir: ".terraform\\modules\\eks", + }, + { + Key: "eks.fargate", + SourceAddr: "./modules/fargate", + VersionStr: "", + Dir: ".terraform\\modules\\eks\\modules\\fargate", + }, + }, + want: []moduleCall{ + { + Name: "ec2_instances", + SourceAddr: "terraform-aws-modules/ec2-instance/aws", + Version: "2.12.0", + SourceType: "tfregistry", + DocsLink: "https://registry.terraform.io/modules/terraform-aws-modules/ec2-instance/aws/2.12.0", + DependentModules: []moduleCall{}, + }, + { + Name: "eks", + SourceAddr: "terraform-aws-modules/eks/aws", + Version: "17.20.0", + SourceType: "tfregistry", + DocsLink: "https://registry.terraform.io/modules/terraform-aws-modules/eks/aws/17.20.0", + DependentModules: []moduleCall{ + { + Name: "fargate", + SourceAddr: "./modules/fargate", + Version: "", + SourceType: "local", + DocsLink: "", + DependentModules: []moduleCall{}, + }, + }, + }, + { + Name: "web_server_sg", + SourceAddr: "github.com/terraform-aws-modules/terraform-aws-security-group", + Version: "", + SourceType: "github", + DocsLink: "", + DependentModules: []moduleCall{}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseModuleRecords(tt.records) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Fatalf("module mismatch: %s", diff) + } + }) + } +} diff --git a/internal/langserver/handlers/execute_command.go b/internal/langserver/handlers/execute_command.go index 3c33f063..0d543ec9 100644 --- a/internal/langserver/handlers/execute_command.go +++ b/internal/langserver/handlers/execute_command.go @@ -16,6 +16,7 @@ var handlers = cmd.Handlers{ cmd.Name("module.callers"): command.ModuleCallersHandler, cmd.Name("terraform.init"): command.TerraformInitHandler, cmd.Name("terraform.validate"): command.TerraformValidateHandler, + cmd.Name("module.calls"): command.ModuleCallsHandler, } func (lh *logHandler) WorkspaceExecuteCommand(ctx context.Context, params lsp.ExecuteCommandParams) (interface{}, error) { diff --git a/internal/terraform/datadir/module_types.go b/internal/terraform/datadir/module_types.go new file mode 100644 index 00000000..fac82bd4 --- /dev/null +++ b/internal/terraform/datadir/module_types.go @@ -0,0 +1,49 @@ +package datadir + +import ( + "github.com/hashicorp/go-getter" + tfregistry "github.com/hashicorp/terraform-registry-address" +) + +type ModuleType string + +const ( + UNKNOWN ModuleType = "unknown" + TFREGISTRY ModuleType = "tfregistry" + LOCAL ModuleType = "local" + GITHUB ModuleType = "github" + GIT ModuleType = "git" +) + +// GetModuleType parses source addresses to determine what kind of source the Terraform module comes +// from. It currently supports detecting Terraform Registry modules, GitHub modules, Git modules, and +// local file paths +func (r *ModuleRecord) GetModuleType() ModuleType { + // TODO: It is technically incorrect to use the package hashicorp/terraform-registry-address + // here as it is written to parse Terraform provider addresses and may not work correctly on + // Terraform module addresses. The proper approach is to create a new parsing library that is + // dedicated to parsing these kinds of addresses correctly, by re-using the logic defined in + // the authorative source: hashicorp/terraform/internal/addrs/module_source.go. + // However this works enough for now to identify module types for display in vscode-terraform. + // Example: terraform-aws-modules/ec2-instance/aws + if _, err := tfregistry.ParseRawProviderSourceString(r.SourceAddr); err == nil { + return TFREGISTRY + } + + // Example: github.com/terraform-aws-modules/terraform-aws-security-group + if _, ok, _ := new(getter.GitHubDetector).Detect(r.SourceAddr, ""); ok { + return GITHUB + } + + // Example: git::https://example.com/vpc.git + if _, ok, _ := new(getter.GitDetector).Detect(r.SourceAddr, ""); ok { + return GIT + } + + // Local, non relative, file paths + if _, ok, _ := new(getter.FileDetector).Detect(r.SourceAddr, ""); ok { + return LOCAL + } + + return UNKNOWN +}