diff --git a/internal/langserver/handlers/command/module_providers.go b/internal/langserver/handlers/command/module_providers.go new file mode 100644 index 000000000..2318153f0 --- /dev/null +++ b/internal/langserver/handlers/command/module_providers.go @@ -0,0 +1,85 @@ +package command + +import ( + "context" + "fmt" + + "github.com/creachadair/jrpc2/code" + lsctx "github.com/hashicorp/terraform-ls/internal/context" + "github.com/hashicorp/terraform-ls/internal/langserver/cmd" + op "github.com/hashicorp/terraform-ls/internal/terraform/module/operation" + "github.com/hashicorp/terraform-ls/internal/uri" + tfaddr "github.com/hashicorp/terraform-registry-address" +) + +const moduleProvidersVersion = 0 + +type moduleProvidersResponse struct { + FormatVersion int `json:"v"` + ProviderRequirements map[string]providerRequirement `json:"provider_requirements"` + InstalledProviders map[string]string `json:"installed_providers"` +} + +type providerRequirement struct { + DisplayName string `json:"display_name"` + VersionConstraint string `json:"version_constraint,omitempty"` + DocsLink string `json:"docs_link,omitempty"` +} + +func ModuleProvidersHandler(ctx context.Context, args cmd.CommandArgs) (interface{}, error) { + response := moduleProvidersResponse{ + FormatVersion: moduleProvidersVersion, + ProviderRequirements: make(map[string]providerRequirement), + InstalledProviders: make(map[string]string), + } + + 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 + } + + mod, _ := mm.ModuleByPath(modPath) + if mod == nil { + return response, nil + } + + if mod.MetaState == op.OpStateUnknown || mod.MetaErr != nil { + return response, nil + } + + for provider, version := range mod.Meta.ProviderRequirements { + response.ProviderRequirements[provider.String()] = providerRequirement{ + DisplayName: provider.ForDisplay(), + VersionConstraint: version.String(), + DocsLink: getProviderDocumentationLink(provider), + } + } + + for provider, version := range mod.InstalledProviders { + response.InstalledProviders[provider.String()] = version.String() + } + + return response, nil +} + +func getProviderDocumentationLink(provider tfaddr.Provider) string { + if provider.IsLegacy() || provider.IsBuiltIn() || provider.Hostname != "registry.terraform.io" { + return "" + } + + return fmt.Sprintf(`https://registry.terraform.io/providers/%s/latest`, provider.ForDisplay()) +} diff --git a/internal/langserver/handlers/execute_command.go b/internal/langserver/handlers/execute_command.go index 0d543ec94..cb363907f 100644 --- a/internal/langserver/handlers/execute_command.go +++ b/internal/langserver/handlers/execute_command.go @@ -17,6 +17,7 @@ var handlers = cmd.Handlers{ cmd.Name("terraform.init"): command.TerraformInitHandler, cmd.Name("terraform.validate"): command.TerraformValidateHandler, cmd.Name("module.calls"): command.ModuleCallsHandler, + cmd.Name("module.providers"): command.ModuleProvidersHandler, } func (lh *logHandler) WorkspaceExecuteCommand(ctx context.Context, params lsp.ExecuteCommandParams) (interface{}, error) { diff --git a/internal/langserver/handlers/execute_command_module_providers_test.go b/internal/langserver/handlers/execute_command_module_providers_test.go new file mode 100644 index 000000000..eaaee8e6e --- /dev/null +++ b/internal/langserver/handlers/execute_command_module_providers_test.go @@ -0,0 +1,162 @@ +package handlers + +import ( + "fmt" + "testing" + + "github.com/creachadair/jrpc2/code" + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-ls/internal/langserver" + "github.com/hashicorp/terraform-ls/internal/langserver/cmd" + "github.com/hashicorp/terraform-ls/internal/state" + "github.com/hashicorp/terraform-ls/internal/terraform/exec" + "github.com/hashicorp/terraform-ls/internal/uri" + tfaddr "github.com/hashicorp/terraform-registry-address" + tfmod "github.com/hashicorp/terraform-schema/module" + "github.com/stretchr/testify/mock" +) + +func TestLangServer_workspaceExecuteCommand_moduleProviders_argumentError(t *testing.T) { + rootDir := t.TempDir() + rootUri := uri.FromPath(rootDir) + + ls := langserver.NewLangServerMock(t, NewMockSession(&MockSessionInput{ + TerraformCalls: &exec.TerraformMockCalls{ + PerWorkDir: map[string][]*mock.Call{ + rootDir: validTfMockCalls(), + }, + }, + })) + stop := ls.Start(t) + defer stop() + + ls.Call(t, &langserver.CallRequest{ + Method: "initialize", + ReqParams: fmt.Sprintf(`{ + "capabilities": {}, + "rootUri": %q, + "processId": 12345 + }`, rootUri)}) + ls.Notify(t, &langserver.CallRequest{ + Method: "initialized", + ReqParams: "{}", + }) + ls.Call(t, &langserver.CallRequest{ + Method: "textDocument/didOpen", + ReqParams: fmt.Sprintf(`{ + "textDocument": { + "version": 0, + "languageId": "terraform", + "text": "provider \"github\" {}", + "uri": %q + } + }`, fmt.Sprintf("%s/main.tf", rootUri))}) + + ls.CallAndExpectError(t, &langserver.CallRequest{ + Method: "workspace/executeCommand", + ReqParams: fmt.Sprintf(`{ + "command": %q + }`, cmd.Name("module.providers"))}, code.InvalidParams.Err()) +} + +func TestLangServer_workspaceExecuteCommand_moduleProviders_basic(t *testing.T) { + modDir := t.TempDir() + modUri := uri.FromPath(modDir) + + s, err := state.NewStateStore() + if err != nil { + t.Fatal(err) + } + + err = s.Modules.Add(modDir) + if err != nil { + t.Fatal(err) + } + + metadata := &tfmod.Meta{ + Path: modDir, + CoreRequirements: testConstraint(t, "~> 0.15"), + ProviderRequirements: map[tfaddr.Provider]version.Constraints{ + tfaddr.NewDefaultProvider("aws"): testConstraint(t, "1.2.3"), + tfaddr.NewDefaultProvider("google"): testConstraint(t, ">= 2.0.0"), + }, + ProviderReferences: map[tfmod.ProviderRef]tfaddr.Provider{ + {LocalName: "aws"}: tfaddr.NewDefaultProvider("aws"), + {LocalName: "google"}: tfaddr.NewDefaultProvider("google"), + }, + } + + err = s.Modules.UpdateMetadata(modDir, metadata, nil) + if err != nil { + t.Fatal(err) + } + + pVersions := map[tfaddr.Provider]*version.Version{ + tfaddr.NewDefaultProvider("aws"): version.Must(version.NewVersion("1.2.3")), + tfaddr.NewDefaultProvider("google"): version.Must(version.NewVersion("2.5.5")), + } + err = s.Modules.UpdateInstalledProviders(modDir, pVersions) + if err != nil { + t.Fatal(err) + } + + ls := langserver.NewLangServerMock(t, NewMockSession(&MockSessionInput{ + TerraformCalls: &exec.TerraformMockCalls{ + PerWorkDir: map[string][]*mock.Call{ + modDir: validTfMockCalls(), + }, + }, + StateStore: s, + })) + stop := ls.Start(t) + defer stop() + + ls.Call(t, &langserver.CallRequest{ + Method: "initialize", + ReqParams: fmt.Sprintf(`{ + "capabilities": {}, + "rootUri": %q, + "processId": 12345 + }`, modUri)}) + ls.Notify(t, &langserver.CallRequest{ + Method: "initialized", + ReqParams: "{}", + }) + + ls.CallAndExpectResponse(t, &langserver.CallRequest{ + Method: "workspace/executeCommand", + ReqParams: fmt.Sprintf(`{ + "command": %q, + "arguments": ["uri=%s"] + }`, cmd.Name("module.providers"), modUri)}, `{ + "jsonrpc": "2.0", + "id": 2, + "result": { + "v": 0, + "provider_requirements": { + "registry.terraform.io/hashicorp/aws": { + "display_name": "hashicorp/aws", + "version_constraint":"1.2.3", + "docs_link": "https://registry.terraform.io/providers/hashicorp/aws/latest" + }, + "registry.terraform.io/hashicorp/google": { + "display_name": "hashicorp/google", + "version_constraint": "\u003e= 2.0.0", + "docs_link": "https://registry.terraform.io/providers/hashicorp/google/latest" + } + }, + "installed_providers":{ + "registry.terraform.io/hashicorp/aws": "1.2.3", + "registry.terraform.io/hashicorp/google": "2.5.5" + } + } + }`) +} + +func testConstraint(t *testing.T, v string) version.Constraints { + constraints, err := version.NewConstraint(v) + if err != nil { + t.Fatal(err) + } + return constraints +} diff --git a/internal/langserver/handlers/service.go b/internal/langserver/handlers/service.go index 71bdbb12e..33eaa5211 100644 --- a/internal/langserver/handlers/service.go +++ b/internal/langserver/handlers/service.go @@ -51,6 +51,7 @@ type service struct { tfExecOpts *exec.ExecutorOpts telemetry telemetry.Sender decoder *decoder.Decoder + stateStore *state.StateStore additionalHandlers map[string]rpch.Func } @@ -434,29 +435,33 @@ func (svc *service) configureSessionDependencies(ctx context.Context, cfgOpts *s svc.sessCtx = exec.WithExecutorOpts(svc.sessCtx, execOpts) svc.sessCtx = exec.WithExecutorFactory(svc.sessCtx, svc.tfExecFactory) - store, err := state.NewStateStore() - if err != nil { - return err + if svc.stateStore == nil { + store, err := state.NewStateStore() + if err != nil { + return err + } + svc.stateStore = store } - store.SetLogger(svc.logger) - store.Modules.ChangeHooks = state.ModuleChangeHooks{ - sendModuleTelemetry(svc.sessCtx, store, svc.telemetry), + + svc.stateStore.SetLogger(svc.logger) + svc.stateStore.Modules.ChangeHooks = state.ModuleChangeHooks{ + sendModuleTelemetry(svc.sessCtx, svc.stateStore, svc.telemetry), } - svc.modStore = store.Modules - svc.schemaStore = store.ProviderSchemas + svc.modStore = svc.stateStore.Modules + svc.schemaStore = svc.stateStore.ProviderSchemas svc.decoder = idecoder.NewDecoder(ctx, &idecoder.PathReader{ ModuleReader: svc.modStore, SchemaReader: svc.schemaStore, }) - err = schemas.PreloadSchemasToStore(store.ProviderSchemas) + err := schemas.PreloadSchemasToStore(svc.stateStore.ProviderSchemas) if err != nil { return err } - svc.modMgr = svc.newModuleManager(svc.sessCtx, svc.fs, store.Modules, store.ProviderSchemas) + svc.modMgr = svc.newModuleManager(svc.sessCtx, svc.fs, svc.stateStore.Modules, svc.stateStore.ProviderSchemas) svc.modMgr.SetLogger(svc.logger) svc.walker = svc.newWalker(svc.fs, svc.modMgr) diff --git a/internal/langserver/handlers/session_mock_test.go b/internal/langserver/handlers/session_mock_test.go index 4d717ade0..a84c05c76 100644 --- a/internal/langserver/handlers/session_mock_test.go +++ b/internal/langserver/handlers/session_mock_test.go @@ -11,6 +11,7 @@ import ( "github.com/creachadair/jrpc2/handler" "github.com/hashicorp/terraform-ls/internal/filesystem" "github.com/hashicorp/terraform-ls/internal/langserver/session" + "github.com/hashicorp/terraform-ls/internal/state" "github.com/hashicorp/terraform-ls/internal/terraform/discovery" "github.com/hashicorp/terraform-ls/internal/terraform/exec" "github.com/hashicorp/terraform-ls/internal/terraform/module" @@ -20,6 +21,7 @@ type MockSessionInput struct { Filesystem filesystem.Filesystem TerraformCalls *exec.TerraformMockCalls AdditionalHandlers map[string]handler.Func + StateStore *state.StateStore } type mockSession struct { @@ -44,10 +46,12 @@ func (ms *mockSession) new(srvCtx context.Context) session.Session { var fs filesystem.Filesystem fs = filesystem.NewFilesystem() var handlers map[string]handler.Func + var stateStore *state.StateStore if ms.mockInput != nil { if ms.mockInput.Filesystem != nil { fs = ms.mockInput.Filesystem } + stateStore = ms.mockInput.StateStore handlers = ms.mockInput.AdditionalHandlers } @@ -72,6 +76,7 @@ func (ms *mockSession) new(srvCtx context.Context) session.Session { tfDiscoFunc: d.LookPath, tfExecFactory: exec.NewMockExecutor(tfCalls), additionalHandlers: handlers, + stateStore: stateStore, } return svc