Skip to content

Commit

Permalink
Add new 'module.callers' command
Browse files Browse the repository at this point in the history
  • Loading branch information
radeksimko committed May 18, 2021
1 parent 43d0660 commit 5a19dd9
Show file tree
Hide file tree
Showing 7 changed files with 359 additions and 0 deletions.
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
14 changes: 14 additions & 0 deletions internal/terraform/module/module_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/terraform/module/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit 5a19dd9

Please sign in to comment.