diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 000000000..fb49acb5e --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,108 @@ +# Commands + +The server exposes the following executable commands via LSP to clients. +Typically these commands are not invokable by end-users automatically. +Instead this serves as a documentation for client maintainers, +and clients may expose these e.g. via command palette where appropriate. + +Every care is taken to avoid breaking changes, but these interfaces +should not be considered stable yet and may change. + +Either way clients should always follow LSP spec in the sense +that they check whether a command is actually supported or not +(via `ServerCapabilities.executeCommandProvider.commands`). + +## Command Prefix + +All commands use `terraform-ls.` prefix to avoid any conflicts +with commands registered by any other language servers user +may be using at the same time. + +Some clients may also choose to generate additional prefix +where e.g. the language server runs in multiple instances +and registering the same commands would lead to conflicts. + +This can be passed as part of `initializationOptions`, +as documented in [Settings](./SETTINGS.md#commandprefix). + +## Arguments + +All commands accept arguments as string arrays with `=` used +as a separator between key and value. i.e. + +```json +{ + "command": "command-name", + "arguments": [ "key=value" ] +} +``` + +## Supported Commands + +### `terraform.init` + +Runs [`terraform init`](https://www.terraform.io/docs/cli/commands/init.html) using available `terraform` installation from `$PATH`. + +**Arguments:** + + - `uri` - URI of the directory in which to run `terraform init` + +**Outputs:** + +Error is returned e.g. when `terraform` is not installed, or when execution fails, +but no output is returned if `init` successfully finishes. + +### `terraform.validate` + +Runs [`terraform validate`](https://www.terraform.io/docs/cli/commands/validate.html) using available `terraform` installation from `$PATH`. + +Any violations are published back the the client via [`textDocument/publishDiagnostics` notification](https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_publishDiagnostics). + +Diagnostics are not persisted and any document change will cause them to be lost. + +**Arguments:** + + - `uri` - URI of the directory in which to run `terraform validate` + +**Outputs:** + +Error is returned e.g. when `terraform` is not installed, or when execution fails, +but no output is returned if `validate` successfully finishes. + +### `module.callers` + +In Terraform module hierarchy "callers" are modules which _call_ another module +via `module "..." {` blocks. + +Language server will attempt to discover any module hierarchy within the workspace +and this command can be used to obtain the data about such hierarchy, which +can be used to hint the user e.g. where to run `init` or `validate` from. + +**Arguments:** + + - `uri` - URI of the directory of the module in question, e.g. `file:///path/to/network` + +**Outputs:** + + - `v` - describes version of the format; Will be used in the future to communicate format changes. + - `callers` - array of any modules found in the workspace which call the module in question + - `uri` - URI of the directory (absolute path) + - `rel_path` - path relative to the module in question, suitable to display in any UI elements + +```json +{ + "v": 0, + "callers": [ + { + "uri": "file:///path/to/dev", + "rel_path": "../dev" + }, + { + "uri": "file:///path/to/prod", + "rel_path": "../prod" + } + ] +} +``` + +### `rootmodules` (DEPRECATED, use `module.callers` instead) diff --git a/internal/langserver/handlers/command/module_callers.go b/internal/langserver/handlers/command/module_callers.go new file mode 100644 index 000000000..ceac5aa10 --- /dev/null +++ b/internal/langserver/handlers/command/module_callers.go @@ -0,0 +1,66 @@ +package command + +import ( + "context" + "fmt" + "path/filepath" + "sort" + + "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/uri" +) + +const moduleCallersVersion = 0 + +type moduleCallersResponse struct { + FormatVersion int `json:"v"` + Callers []moduleCaller `json:"callers"` +} + +type moduleCaller struct { + URI string `json:"uri"` + RelativePath string `json:"rel_path"` +} + +func ModuleCallersHandler(ctx context.Context, args cmd.CommandArgs) (interface{}, error) { + modUri, ok := args.GetString("uri") + if !ok || modUri == "" { + return nil, fmt.Errorf("%w: expected uri argument to be set", code.InvalidParams.Err()) + } + + modPath, err := uri.PathFromURI(modUri) + if err != nil { + return nil, err + } + + mf, err := lsctx.ModuleFinder(ctx) + if err != nil { + return nil, err + } + + modCallers, err := mf.CallersOfModule(modPath) + if err != nil { + return nil, err + } + + callers := make([]moduleCaller, 0) + for _, caller := range modCallers { + relPath, err := filepath.Rel(modPath, caller.Path) + if err != nil { + return nil, err + } + callers = append(callers, moduleCaller{ + URI: uri.FromPath(caller.Path), + RelativePath: relPath, + }) + } + sort.SliceStable(callers, func(i, j int) bool { + return callers[i].URI < callers[j].URI + }) + return moduleCallersResponse{ + FormatVersion: moduleCallersVersion, + Callers: callers, + }, nil +} diff --git a/internal/langserver/handlers/execute_command.go b/internal/langserver/handlers/execute_command.go index cc48f11e7..3c33f0633 100644 --- a/internal/langserver/handlers/execute_command.go +++ b/internal/langserver/handlers/execute_command.go @@ -13,6 +13,7 @@ import ( var handlers = cmd.Handlers{ cmd.Name("rootmodules"): command.ModulesHandler, + cmd.Name("module.callers"): command.ModuleCallersHandler, cmd.Name("terraform.init"): command.TerraformInitHandler, cmd.Name("terraform.validate"): command.TerraformValidateHandler, } diff --git a/internal/langserver/handlers/execute_command_module_callers_test.go b/internal/langserver/handlers/execute_command_module_callers_test.go new file mode 100644 index 000000000..04cabf7ad --- /dev/null +++ b/internal/langserver/handlers/execute_command_module_callers_test.go @@ -0,0 +1,168 @@ +package handlers + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/creachadair/jrpc2/code" + "github.com/hashicorp/terraform-ls/internal/langserver" + "github.com/hashicorp/terraform-ls/internal/langserver/cmd" + "github.com/hashicorp/terraform-ls/internal/terraform/exec" + "github.com/hashicorp/terraform-ls/internal/uri" + "github.com/stretchr/testify/mock" +) + +func TestLangServer_workspaceExecuteCommand_moduleCallers_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.callers"))}, code.InvalidParams.Err()) +} + +func TestLangServer_workspaceExecuteCommand_moduleCallers_basic(t *testing.T) { + rootDir := t.TempDir() + rootUri := uri.FromPath(rootDir) + baseDirUri := uri.FromPath(filepath.Join(rootDir, "base")) + + createModuleCalling(t, "../base", filepath.Join(rootDir, "dev")) + createModuleCalling(t, "../base", filepath.Join(rootDir, "staging")) + createModuleCalling(t, "../base", filepath.Join(rootDir, "prod")) + + 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", baseDirUri))}) + + devName := filepath.Join("..", "dev") + prodName := filepath.Join("..", "prod") + stagingName := filepath.Join("..", "staging") + + ls.CallAndExpectResponse(t, &langserver.CallRequest{ + Method: "workspace/executeCommand", + ReqParams: fmt.Sprintf(`{ + "command": %q, + "arguments": ["uri=%s"] + }`, cmd.Name("module.callers"), baseDirUri)}, fmt.Sprintf(`{ + "jsonrpc": "2.0", + "id": 3, + "result": { + "v": 0, + "callers": [ + { + "uri": "%s/dev", + "rel_path": %q + }, + { + "uri": "%s/prod", + "rel_path": %q + }, + { + "uri": "%s/staging", + "rel_path": %q + } + ] + } + }`, rootUri, devName, rootUri, prodName, rootUri, stagingName)) +} + +func createModuleCalling(t *testing.T, src, modPath string) { + modulesDir := filepath.Join(modPath, ".terraform", "modules") + err := os.MkdirAll(modulesDir, 0755) + if err != nil { + t.Fatal(err) + } + + configBytes := []byte(fmt.Sprintf(` +module "local" { + source = %q +} +`, src)) + err = os.WriteFile(filepath.Join(modPath, "module.tf"), configBytes, 0755) + if err != nil { + t.Fatal(err) + } + + manifestBytes := []byte(fmt.Sprintf(`{ + "Modules": [ + { + "Key": "", + "Source": "", + "Dir": "." + }, + { + "Key": "local", + "Source": %q, + "Dir": %q + } + ] +}`, src, src)) + err = os.WriteFile(filepath.Join(modulesDir, "modules.json"), manifestBytes, 0755) + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/langserver/handlers/service.go b/internal/langserver/handlers/service.go index 9f39ab337..bb4d97cc4 100644 --- a/internal/langserver/handlers/service.go +++ b/internal/langserver/handlers/service.go @@ -296,6 +296,7 @@ func (svc *service) Assigner() (jrpc2.Assigner, error) { ctx = lsctx.WithCommandPrefix(ctx, &commandPrefix) ctx = lsctx.WithModuleManager(ctx, svc.modMgr) + ctx = lsctx.WithModuleFinder(ctx, svc.modMgr) ctx = lsctx.WithModuleWalker(ctx, svc.walker) ctx = lsctx.WithWatcher(ctx, ww) ctx = lsctx.WithRootDirectory(ctx, &rootDir) diff --git a/internal/lsp/file_handler.go b/internal/lsp/file_handler.go index 573e1e03e..e57c89844 100644 --- a/internal/lsp/file_handler.go +++ b/internal/lsp/file_handler.go @@ -1,7 +1,6 @@ package lsp import ( - "net/url" "path/filepath" "strings" @@ -36,27 +35,13 @@ type fileHandler struct { } func (fh *fileHandler) Valid() bool { - _, err := fh.parsePath() - if err != nil { - return false - } - - return true + return uri.IsURIValid(fh.uri) } func (fh *fileHandler) IsDir() bool { return fh.isDir } -func (fh *fileHandler) parsePath() (string, error) { - u, err := url.ParseRequestURI(string(fh.uri)) - if err != nil { - return "", err - } - - return url.PathUnescape(u.Path) -} - func (fh *fileHandler) Dir() string { if fh.isDir { return fh.FullPath() @@ -70,6 +55,10 @@ func (fh *fileHandler) Filename() string { return filepath.Base(fh.FullPath()) } +func (fh *fileHandler) FullPath() string { + return uri.MustPathFromURI(fh.uri) +} + func (fh *fileHandler) DocumentURI() lsp.DocumentURI { return lsp.DocumentURI(fh.uri) } diff --git a/internal/lsp/file_handler_unix.go b/internal/lsp/file_handler_unix.go deleted file mode 100644 index c67c1c74e..000000000 --- a/internal/lsp/file_handler_unix.go +++ /dev/null @@ -1,16 +0,0 @@ -// +build !windows - -package lsp - -import ( - "path/filepath" -) - -func (fh *fileHandler) FullPath() string { - p, err := fh.parsePath() - if err != nil { - panic("invalid uri") - } - - return filepath.FromSlash(p) -} diff --git a/internal/lsp/file_handler_windows.go b/internal/lsp/file_handler_windows.go deleted file mode 100644 index 7c1ecf700..000000000 --- a/internal/lsp/file_handler_windows.go +++ /dev/null @@ -1,21 +0,0 @@ -package lsp - -import ( - "path/filepath" - "strings" -) - -// FullPath on Windows strips the leading '/' -// which occurs in Windows-style paths (e.g. file:///C:/) -// as url.URL methods don't account for that -// (see golang/go#6027). -func (fh *fileHandler) FullPath() string { - p, err := fh.parsePath() - if err != nil { - panic("invalid uri") - } - - p = strings.TrimPrefix(p, "/") - - return filepath.FromSlash(p) -} diff --git a/internal/terraform/module/module_manager.go b/internal/terraform/module/module_manager.go index 8b5e604ca..17b564002 100644 --- a/internal/terraform/module/module_manager.go +++ b/internal/terraform/module/module_manager.go @@ -136,6 +136,20 @@ func schemaForModule(mod *state.Module, schemaReader state.SchemaReader) (*schem return sm.SchemaForModule(meta) } +func (mm *moduleManager) CallersOfModule(modPath string) ([]Module, error) { + modules := make([]Module, 0) + callers, err := mm.moduleStore.CallersOfModule(modPath) + if err != nil { + return modules, err + } + + for _, mod := range callers { + modules = append(modules, mod) + } + + return modules, nil +} + // SchemaSourcesForModule is DEPRECATED and should NOT be used anymore // it is just maintained for backwards compatibility in the "rootmodules" // custom LSP command which itself will be DEPRECATED as external parties diff --git a/internal/terraform/module/types.go b/internal/terraform/module/types.go index e0752299d..9407df4cd 100644 --- a/internal/terraform/module/types.go +++ b/internal/terraform/module/types.go @@ -24,6 +24,7 @@ type ModuleFinder interface { SchemaForModule(path string) (*schema.BodySchema, error) SchemaSourcesForModule(path string) ([]SchemaSource, error) ListModules() ([]Module, error) + CallersOfModule(modPath string) ([]Module, error) } type ModuleLoader func(dir string) (Module, error) diff --git a/internal/uri/uri.go b/internal/uri/uri.go index 48d7f969b..44e245a7b 100644 --- a/internal/uri/uri.go +++ b/internal/uri/uri.go @@ -1,6 +1,7 @@ package uri import ( + "fmt" "net/url" "path/filepath" ) @@ -15,3 +16,29 @@ func FromPath(path string) string { } return u.String() } + +func IsURIValid(uri string) bool { + _, err := parseUri(uri) + if err != nil { + return false + } + + return true +} + +func mustParseUri(uri string) string { + u, err := parseUri(uri) + if err != nil { + panic(fmt.Sprintf("invalid URI: %s", uri)) + } + return u +} + +func parseUri(uri string) (string, error) { + u, err := url.ParseRequestURI(uri) + if err != nil { + return "", err + } + + return url.PathUnescape(u.Path) +} diff --git a/internal/uri/uri_unix.go b/internal/uri/uri_unix.go index 2d00cf899..9c753ddd6 100644 --- a/internal/uri/uri_unix.go +++ b/internal/uri/uri_unix.go @@ -2,7 +2,25 @@ package uri +import ( + "path/filepath" +) + // wrapPath is no-op for unix-style paths func wrapPath(path string) string { return path } + +func PathFromURI(uri string) (string, error) { + p, err := parseUri(uri) + if err != nil { + return "", err + } + + return filepath.FromSlash(p), nil +} + +func MustPathFromURI(uri string) string { + p := mustParseUri(uri) + return filepath.FromSlash(p) +} diff --git a/internal/uri/uri_unix_test.go b/internal/uri/uri_unix_test.go index fd425e2d2..655e33e6a 100644 --- a/internal/uri/uri_unix_test.go +++ b/internal/uri/uri_unix_test.go @@ -16,3 +16,33 @@ func TestURIFromPath(t *testing.T) { expectedURI, uri) } } + +func TestPathFromURI_valid_unixFile(t *testing.T) { + uri := "file:///valid/path/to/file.tf" + if !IsURIValid(uri) { + t.Fatalf("Expected %q to be valid", uri) + } + + expectedFullPath := "/valid/path/to/file.tf" + path, err := PathFromURI(uri) + if err != nil { + t.Fatal(err) + } + if path != expectedFullPath { + t.Fatalf("Expected full path: %q, given: %q", + expectedFullPath, path) + } +} + +func TestPathFromURI_valid_unixDir(t *testing.T) { + uri := "file:///valid/path/to" + expectedDir := "/valid/path/to" + path, err := PathFromURI(uri) + if err != nil { + t.Fatal(err) + } + if path != expectedDir { + t.Fatalf("Expected dir: %q, given: %q", + expectedDir, path) + } +} diff --git a/internal/uri/uri_windows.go b/internal/uri/uri_windows.go index 36b2d2a1c..db084e2a2 100644 --- a/internal/uri/uri_windows.go +++ b/internal/uri/uri_windows.go @@ -1,8 +1,36 @@ package uri +import ( + "path/filepath" + "strings" +) + // wrapPath prepends Windows-style paths (C:\path) // with an additional slash to account for an empty hostname // in a valid file-scheme URI per RFC 8089 func wrapPath(path string) string { return "/" + path } + +func PathFromURI(uri string) (string, error) { + p, err := parseUri(uri) + if err != nil { + return "", err + } + + p = strings.TrimPrefix(p, "/") + + return filepath.FromSlash(p), nil +} + +// MustPathFromURI on Windows strips the leading '/' +// which occurs in Windows-style paths (e.g. file:///C:/) +// as url.URL methods don't account for that +// (see golang/go#6027). +func MustPathFromURI(uri string) string { + p := mustParseUri(uri) + + p = strings.TrimPrefix(p, "/") + + return filepath.FromSlash(p) +} diff --git a/internal/uri/uri_windows_test.go b/internal/uri/uri_windows_test.go index 97d733cb9..76f069ba9 100644 --- a/internal/uri/uri_windows_test.go +++ b/internal/uri/uri_windows_test.go @@ -14,3 +14,37 @@ func TestFromPath(t *testing.T) { expectedURI, uri) } } + +func TestPathFromURI_valid_windowsFile(t *testing.T) { + uri := "file:///C:/Users/With%20Space/tf-test/file.tf" + if !IsURIValid(uri) { + t.Fatalf("Expected %q to be valid", uri) + } + + expectedPath := `C:\Users\With Space\tf-test\file.tf` + path, err := PathFromURI(uri) + if err != nil { + t.Fatal(err) + } + if path != expectedPath { + t.Fatalf("Expected full path: %q, given: %q", + expectedPath, path) + } +} + +func TestPathFromURI_valid_windowsDir(t *testing.T) { + uri := "file:///C:/Users/With%20Space/tf-test" + if !IsURIValid(uri) { + t.Fatalf("Expected %q to be valid", uri) + } + + expectedPath := `C:\Users\With Space\tf-test` + path, err := PathFromURI(uri) + if err != nil { + t.Fatal(err) + } + if path != expectedPath { + t.Fatalf("Expected full path: %q, given: %q", + expectedPath, path) + } +}