Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new 'module.callers' command #508

Merged
merged 2 commits into from
May 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
@@ -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)
66 changes: 66 additions & 0 deletions internal/langserver/handlers/command/module_callers.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions internal/langserver/handlers/execute_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
168 changes: 168 additions & 0 deletions internal/langserver/handlers/execute_command_module_callers_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions internal/langserver/handlers/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,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)
Expand Down
Loading