diff --git a/commands/completion_command.go b/commands/completion_command.go index 3c6de8855..3955cd2c3 100644 --- a/commands/completion_command.go +++ b/commands/completion_command.go @@ -9,13 +9,10 @@ import ( "strconv" "strings" - lsctx "github.com/hashicorp/terraform-ls/internal/context" "github.com/hashicorp/terraform-ls/internal/filesystem" + ihcl "github.com/hashicorp/terraform-ls/internal/hcl" ilsp "github.com/hashicorp/terraform-ls/internal/lsp" - "github.com/hashicorp/terraform-ls/internal/terraform/discovery" - "github.com/hashicorp/terraform-ls/internal/terraform/exec" - "github.com/hashicorp/terraform-ls/internal/terraform/schema" - "github.com/hashicorp/terraform-ls/langserver/handlers" + "github.com/hashicorp/terraform-ls/internal/terraform/rootmodule" "github.com/hashicorp/terraform-ls/logging" "github.com/mitchellh/cli" lsp "github.com/sourcegraph/go-lsp" @@ -56,7 +53,8 @@ func (c *CompletionCommand) Run(args []string) int { return 1 } - lspUri := ilsp.FileHandlerFromPath(path).DocumentURI() + fh := ilsp.FileHandlerFromPath(path) + parts := strings.Split(c.atPos, ":") if len(parts) != 2 { c.Ui.Error(fmt.Sprintf("Error parsing at-pos argument: %q (expected line:col format)", c.atPos)) @@ -79,62 +77,48 @@ func (c *CompletionCommand) Run(args []string) int { fs := filesystem.NewFilesystem() fs.SetLogger(logger) fs.Open(ilsp.FileFromDocumentItem(lsp.TextDocumentItem{ - URI: lspUri, + URI: fh.DocumentURI(), Text: string(content), Version: 0, })) - d := &discovery.Discovery{} - tfPath, err := d.LookPath() + file, err := fs.GetFile(fh) if err != nil { c.Ui.Error(err.Error()) return 1 } - ss := schema.NewStorage() - ss.SetLogger(logger) - ss.SetSynchronous() - - ctx := context.Background() - - dir := ilsp.FileHandler(lspUri).Dir() - - tf := exec.NewExecutor(ctx, tfPath) - tf.SetWorkdir(dir) - version, err := tf.Version() + hclFile := ihcl.NewFile(file) + fPos, err := ilsp.FilePositionFromDocumentPosition(lsp.TextDocumentPositionParams{ + TextDocument: lsp.TextDocumentIdentifier{ + URI: fh.DocumentURI(), + }, + Position: lspPos, + }, file) if err != nil { c.Ui.Error(err.Error()) return 1 } - err = ss.ObtainSchemasForWorkspace(tf, dir) + w, err := rootmodule.NewRootModule(context.Background(), fh.Dir()) if err != nil { c.Ui.Error(err.Error()) return 1 } + p := w.Parser() - ctx = lsctx.WithFilesystem(fs, ctx) - ctx = lsctx.WithTerraformVersion(version, ctx) - ctx = lsctx.WithTerraformExecutor(tf, ctx) - ctx = lsctx.WithTerraformSchemaReader(ss, ctx) - ctx = lsctx.WithClientCapabilities(&lsp.ClientCapabilities{}, ctx) - - h := handlers.LogHandler(logger) - items, err := h.TextDocumentComplete(ctx, lsp.CompletionParams{ - TextDocumentPositionParams: lsp.TextDocumentPositionParams{ - TextDocument: lsp.TextDocumentIdentifier{ - URI: lspUri, - }, - Position: lspPos, - }, - }) + pos := fPos.Position() + + candidates, err := p.CompletionCandidatesAtPos(hclFile, pos) if err != nil { c.Ui.Error(err.Error()) return 1 } - c.Ui.Output(fmt.Sprintf("%#v", items)) + cc := &lsp.ClientCapabilities{} + items := ilsp.CompletionList(candidates, pos, cc.TextDocument) + c.Ui.Output(fmt.Sprintf("%#v", items)) return 0 } diff --git a/commands/serve_command.go b/commands/serve_command.go index 455d31ee7..6948552fd 100644 --- a/commands/serve_command.go +++ b/commands/serve_command.go @@ -92,14 +92,9 @@ func (c *ServeCommand) Run(args []string) int { logger.Printf("Terraform execution timeout set to %s", d) } - srv := langserver.NewLangServer(ctx, handlers.NewSession) - srv.SetLogger(logger) - if c.tfExecPath != "" { path := c.tfExecPath - logger.Printf("Setting Terraform exec path to %q", path) - // just some sanity checking here, no need to get too specific otherwise will be complex cross-OS if !filepath.IsAbs(path) { c.Ui.Error(fmt.Sprintf("Expected absolute path for Terraform binary, got %q", path)) @@ -115,11 +110,13 @@ func (c *ServeCommand) Run(args []string) int { return 1 } - srv.SetDiscoveryFunc(func() (string, error) { - return path, nil - }) + ctx = lsctx.WithTerraformExecPath(path, ctx) + logger.Printf("Terraform exec path set to %q", path) } + srv := langserver.NewLangServer(ctx, handlers.NewSession) + srv.SetLogger(logger) + if c.port != 0 { err := srv.StartTCP(fmt.Sprintf("localhost:%d", c.port)) if err != nil { diff --git a/go.mod b/go.mod index dac5e0c56..8c3ec398d 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/creachadair/jrpc2 v0.8.1 github.com/fsnotify/fsnotify v1.4.9 github.com/google/go-cmp v0.4.0 + github.com/hashicorp/go-multierror v1.1.0 // indirect github.com/hashicorp/go-version v1.2.0 github.com/hashicorp/hcl/v2 v2.5.2-0.20200528183353-fa7c453538de github.com/hashicorp/terraform-json v0.5.0 diff --git a/go.sum b/go.sum index 8378a0304..08b850b4d 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/U github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= github.com/hashicorp/go-version v1.2.0 h1:3vNe/fWF5CBgRIguda1meWhsZHy3m8gCJ5wx+dIzX/E= github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hcl/v2 v2.5.2-0.20200528183353-fa7c453538de h1:bCeWhTigOmP9am0cJA+5kaTtA2RFDmnWIRtBIxo+Ydg= diff --git a/internal/context/context.go b/internal/context/context.go index bb8572a07..19bcd00f5 100644 --- a/internal/context/context.go +++ b/internal/context/context.go @@ -5,8 +5,8 @@ import ( "time" "github.com/hashicorp/terraform-ls/internal/filesystem" - "github.com/hashicorp/terraform-ls/internal/terraform/exec" - "github.com/hashicorp/terraform-ls/internal/terraform/schema" + "github.com/hashicorp/terraform-ls/internal/terraform/rootmodule" + "github.com/hashicorp/terraform-ls/internal/watcher" "github.com/sourcegraph/go-lsp" ) @@ -20,15 +20,15 @@ func (k *contextKey) String() string { var ( ctxFs = &contextKey{"filesystem"} - ctxTerraformExec = &contextKey{"terraform executor"} ctxClientCapsSetter = &contextKey{"client capabilities setter"} ctxClientCaps = &contextKey{"client capabilities"} - ctxTfSchemaWriter = &contextKey{"schema writer"} - ctxTfSchemaReader = &contextKey{"schema reader"} - ctxTfVersion = &contextKey{"terraform version"} - ctxTfVersionSetter = &contextKey{"terraform version setter"} + ctxTfExecPath = &contextKey{"terraform executable path"} ctxTfExecLogPath = &contextKey{"terraform executor log path"} ctxTfExecTimeout = &contextKey{"terraform execution timeout"} + ctxWatcher = &contextKey{"watcher"} + ctxRootModuleMngr = &contextKey{"root module manager"} + ctxParserFinder = &contextKey{"parser finder"} + ctxTfExecFinder = &contextKey{"terraform exec finder"} ) func missingContextErr(ctxKey *contextKey) *MissingContextErr { @@ -48,19 +48,6 @@ func Filesystem(ctx context.Context) (filesystem.Filesystem, error) { return fs, nil } -func WithTerraformExecutor(tf *exec.Executor, ctx context.Context) context.Context { - return context.WithValue(ctx, ctxTerraformExec, tf) -} - -func TerraformExecutor(ctx context.Context) (*exec.Executor, error) { - tf, ok := ctx.Value(ctxTerraformExec).(*exec.Executor) - if !ok { - return nil, missingContextErr(ctxTerraformExec) - } - - return tf, nil -} - func WithClientCapabilitiesSetter(caps *lsp.ClientCapabilities, ctx context.Context) context.Context { return context.WithValue(ctx, ctxClientCapsSetter, caps) } @@ -88,73 +75,77 @@ func ClientCapabilities(ctx context.Context) (lsp.ClientCapabilities, error) { return *caps, nil } -func WithTerraformSchemaWriter(s schema.Writer, ctx context.Context) context.Context { - return context.WithValue(ctx, ctxTfSchemaWriter, s) +func WithTerraformExecLogPath(path string, ctx context.Context) context.Context { + return context.WithValue(ctx, ctxTfExecLogPath, path) } -func TerraformSchemaWriter(ctx context.Context) (schema.Writer, error) { - ss, ok := ctx.Value(ctxTfSchemaWriter).(schema.Writer) - if !ok { - return nil, missingContextErr(ctxTfSchemaWriter) - } +func TerraformExecLogPath(ctx context.Context) (string, bool) { + path, ok := ctx.Value(ctxTfExecLogPath).(string) + return path, ok +} - return ss, nil +func WithTerraformExecTimeout(timeout time.Duration, ctx context.Context) context.Context { + return context.WithValue(ctx, ctxTfExecTimeout, timeout) } -func WithTerraformSchemaReader(s schema.Reader, ctx context.Context) context.Context { - return context.WithValue(ctx, ctxTfSchemaReader, s) +func TerraformExecTimeout(ctx context.Context) (time.Duration, bool) { + path, ok := ctx.Value(ctxTfExecTimeout).(time.Duration) + return path, ok +} + +func WithWatcher(w watcher.Watcher, ctx context.Context) context.Context { + return context.WithValue(ctx, ctxWatcher, w) } -func TerraformSchemaReader(ctx context.Context) (schema.Reader, error) { - ss, ok := ctx.Value(ctxTfSchemaReader).(schema.Reader) +func Watcher(ctx context.Context) (watcher.Watcher, error) { + w, ok := ctx.Value(ctxWatcher).(watcher.Watcher) if !ok { - return nil, missingContextErr(ctxTfSchemaReader) + return nil, missingContextErr(ctxWatcher) } - - return ss, nil + return w, nil } -func WithTerraformVersion(v string, ctx context.Context) context.Context { - return context.WithValue(ctx, ctxTfVersion, v) +func WithRootModuleManager(wm rootmodule.RootModuleManager, ctx context.Context) context.Context { + return context.WithValue(ctx, ctxRootModuleMngr, wm) } -func TerraformVersion(ctx context.Context) (string, error) { - tfv, ok := ctx.Value(ctxTfVersion).(string) +func RootModuleManager(ctx context.Context) (rootmodule.RootModuleManager, error) { + wm, ok := ctx.Value(ctxRootModuleMngr).(rootmodule.RootModuleManager) if !ok { - return "", missingContextErr(ctxTfVersion) + return nil, missingContextErr(ctxRootModuleMngr) } - - return tfv, nil + return wm, nil } -func WithTerraformVersionSetter(v *string, ctx context.Context) context.Context { - return context.WithValue(ctx, ctxTfVersionSetter, v) +func WithParserFinder(pf rootmodule.ParserFinder, ctx context.Context) context.Context { + return context.WithValue(ctx, ctxParserFinder, pf) } -func SetTerraformVersion(ctx context.Context, v string) error { - tfv, ok := ctx.Value(ctxTfVersionSetter).(*string) +func ParserFinder(ctx context.Context) (rootmodule.ParserFinder, error) { + pf, ok := ctx.Value(ctxParserFinder).(rootmodule.ParserFinder) if !ok { - return missingContextErr(ctxTfVersionSetter) + return nil, missingContextErr(ctxParserFinder) } - *tfv = v - - return nil + return pf, nil } -func WithTerraformExecLogPath(path string, ctx context.Context) context.Context { - return context.WithValue(ctx, ctxTfExecLogPath, path) +func WithTerraformExecFinder(tef rootmodule.TerraformExecFinder, ctx context.Context) context.Context { + return context.WithValue(ctx, ctxTfExecFinder, tef) } -func TerraformExecLogPath(ctx context.Context) (string, bool) { - path, ok := ctx.Value(ctxTfExecLogPath).(string) - return path, ok +func TerraformExecutorFinder(ctx context.Context) (rootmodule.TerraformExecFinder, error) { + pf, ok := ctx.Value(ctxTfExecFinder).(rootmodule.TerraformExecFinder) + if !ok { + return nil, missingContextErr(ctxTfExecFinder) + } + return pf, nil } -func WithTerraformExecTimeout(timeout time.Duration, ctx context.Context) context.Context { - return context.WithValue(ctx, ctxTfExecTimeout, timeout) +func WithTerraformExecPath(path string, ctx context.Context) context.Context { + return context.WithValue(ctx, ctxTfExecPath, path) } -func TerraformExecTimeout(ctx context.Context) (time.Duration, bool) { - path, ok := ctx.Value(ctxTfExecTimeout).(time.Duration) +func TerraformExecPath(ctx context.Context) (string, bool) { + path, ok := ctx.Value(ctxTfExecPath).(string) return path, ok } diff --git a/internal/lsp/file.go b/internal/lsp/file.go index e5357e8ac..748f50816 100644 --- a/internal/lsp/file.go +++ b/internal/lsp/file.go @@ -14,7 +14,7 @@ type File interface { } type file struct { - fh FileHandler + fh *fileHandler ls source.Lines text []byte version int @@ -57,7 +57,7 @@ func (f *file) Version() int { func FileFromDocumentItem(doc lsp.TextDocumentItem) *file { return &file{ - fh: FileHandler(doc.URI), + fh: FileHandlerFromDocumentURI(doc.URI), text: []byte(doc.Text), version: doc.Version, } diff --git a/internal/lsp/file_change_test.go b/internal/lsp/file_change_test.go index 8e3221a4a..504205898 100644 --- a/internal/lsp/file_change_test.go +++ b/internal/lsp/file_change_test.go @@ -1,10 +1,11 @@ package lsp import ( - "github.com/hashicorp/hcl/v2" - "github.com/sourcegraph/go-lsp" "reflect" "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/sourcegraph/go-lsp" ) func TestLspRangeToHCL(t *testing.T) { @@ -76,7 +77,7 @@ func TestLspRangeToHCL(t *testing.T) { t.Logf("[DEBUG] Testing %q", v.Name) result, err := lspRangeToHCL(v.Range, &file{ - fh: "file:///test.tf", + fh: FileHandlerFromDocumentURI(lsp.DocumentURI("file:///test.tf")), text: []byte(v.Content), }) if err != nil { diff --git a/internal/lsp/file_handler.go b/internal/lsp/file_handler.go index 8f6c2fa3a..042a985e2 100644 --- a/internal/lsp/file_handler.go +++ b/internal/lsp/file_handler.go @@ -3,14 +3,39 @@ package lsp import ( "net/url" "path/filepath" + "strings" "github.com/hashicorp/terraform-ls/internal/filesystem" "github.com/sourcegraph/go-lsp" ) -type FileHandler string +func FileHandlerFromDocumentURI(docUri lsp.DocumentURI) *fileHandler { + return &fileHandler{uri: string(docUri)} +} + +func FileHandlerFromDirURI(dirUri lsp.DocumentURI) *fileHandler { + // Dir URIs are usually without trailing separator already + // but we do sanity check anyway, so we deal with the same URI + // regardless of language client differences + uri := strings.TrimSuffix(string(dirUri), "/") + return &fileHandler{uri: uri, isDir: true} +} + +type FileHandler interface { + Valid() bool + Dir() string + IsDir() bool + Filename() string + DocumentURI() lsp.DocumentURI + URI() string +} + +type fileHandler struct { + uri string + isDir bool +} -func (fh FileHandler) Valid() bool { +func (fh *fileHandler) Valid() bool { _, err := fh.parsePath() if err != nil { return false @@ -19,8 +44,12 @@ func (fh FileHandler) Valid() bool { return true } -func (fh FileHandler) parsePath() (string, error) { - u, err := url.ParseRequestURI(string(fh)) +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 } @@ -28,30 +57,35 @@ func (fh FileHandler) parsePath() (string, error) { return url.PathUnescape(u.Path) } -func (fh FileHandler) Dir() string { - return filepath.Dir(fh.FullPath()) +func (fh *fileHandler) Dir() string { + if fh.isDir { + return fh.FullPath() + } + + path := filepath.Dir(fh.FullPath()) + return path } -func (fh FileHandler) Filename() string { +func (fh *fileHandler) Filename() string { return filepath.Base(fh.FullPath()) } -func (fh FileHandler) DocumentURI() lsp.DocumentURI { - return lsp.DocumentURI(fh) +func (fh *fileHandler) DocumentURI() lsp.DocumentURI { + return lsp.DocumentURI(fh.uri) } -func (fh FileHandler) URI() string { - return string(fh) +func (fh *fileHandler) URI() string { + return string(fh.uri) } type versionedFileHandler struct { - FileHandler + fileHandler v int } func VersionedFileHandler(doc lsp.VersionedTextDocumentIdentifier) *versionedFileHandler { return &versionedFileHandler{ - FileHandler: FileHandler(doc.URI), + fileHandler: fileHandler{uri: string(doc.URI)}, v: doc.Version, } } @@ -60,6 +94,13 @@ func (fh *versionedFileHandler) Version() int { return fh.v } -func FileHandlerFromPath(path string) FileHandler { - return FileHandler(filesystem.URIFromPath(path)) +func FileHandlerFromPath(path string) *fileHandler { + return &fileHandler{uri: filesystem.URIFromPath(path)} +} + +func FileHandlerFromDirPath(dirPath string) *fileHandler { + // Dir URIs are usually without trailing separator in LSP + dirPath = filepath.Clean(dirPath) + + return &fileHandler{uri: filesystem.URIFromPath(dirPath), isDir: true} } diff --git a/internal/lsp/file_handler_test.go b/internal/lsp/file_handler_test.go index 1f9ee8ccd..d0a45bcd9 100644 --- a/internal/lsp/file_handler_test.go +++ b/internal/lsp/file_handler_test.go @@ -2,6 +2,8 @@ package lsp import ( "testing" + + "github.com/sourcegraph/go-lsp" ) var ( @@ -11,7 +13,7 @@ var ( func TestFileHandler_invalid(t *testing.T) { path := "invalidpath" - fh := FileHandler(path) + fh := FileHandlerFromDocumentURI(lsp.DocumentURI(path)) if fh.Valid() { t.Fatalf("Expected %q to be invalid", path) } diff --git a/internal/lsp/file_handler_unix.go b/internal/lsp/file_handler_unix.go index 4501d6d3c..c67c1c74e 100644 --- a/internal/lsp/file_handler_unix.go +++ b/internal/lsp/file_handler_unix.go @@ -6,7 +6,7 @@ import ( "path/filepath" ) -func (fh FileHandler) FullPath() string { +func (fh *fileHandler) FullPath() string { p, err := fh.parsePath() if err != nil { panic("invalid uri") diff --git a/internal/lsp/file_handler_unix_test.go b/internal/lsp/file_handler_unix_test.go index d62e0577e..ad96bd280 100644 --- a/internal/lsp/file_handler_unix_test.go +++ b/internal/lsp/file_handler_unix_test.go @@ -4,10 +4,12 @@ package lsp import ( "testing" + + "github.com/sourcegraph/go-lsp" ) func TestFileHandler_valid_unix(t *testing.T) { - fh := FileHandler(validUnixPath) + fh := FileHandlerFromDocumentURI(lsp.DocumentURI(validUnixPath)) if !fh.Valid() { t.Fatalf("Expected %q to be valid", validUnixPath) } @@ -35,3 +37,16 @@ func TestFileHandler_valid_unix(t *testing.T) { validUnixPath, fh.URI()) } } + +func TestFileHandler_valid_unixDir(t *testing.T) { + fh := FileHandlerFromDirURI(lsp.DocumentURI("/valid/path/to")) + if !fh.Valid() { + t.Fatalf("Expected %q to be valid", "/valid/path/to") + } + + expectedDir := "/valid/path/to" + if fh.Dir() != expectedDir { + t.Fatalf("Expected dir: %q, given: %q", + expectedDir, fh.Dir()) + } +} diff --git a/internal/lsp/file_handler_windows.go b/internal/lsp/file_handler_windows.go index 5f91e12d2..7c1ecf700 100644 --- a/internal/lsp/file_handler_windows.go +++ b/internal/lsp/file_handler_windows.go @@ -9,7 +9,7 @@ import ( // 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 { +func (fh *fileHandler) FullPath() string { p, err := fh.parsePath() if err != nil { panic("invalid uri") diff --git a/internal/lsp/file_handler_windows_test.go b/internal/lsp/file_handler_windows_test.go index 7089c4cb1..e34bd46f1 100644 --- a/internal/lsp/file_handler_windows_test.go +++ b/internal/lsp/file_handler_windows_test.go @@ -2,11 +2,13 @@ package lsp import ( "testing" + + "github.com/sourcegraph/go-lsp" ) func TestFileHandler_valid_windows(t *testing.T) { path := "file:///C:/Users/With%20Space/tf-test/file.tf" - fh := FileHandler(path) + fh := FileHandlerFromDocumentURI(lsp.DocumentURI(path)) if !fh.Valid() { t.Fatalf("Expected %q to be valid", path) } diff --git a/internal/lsp/position.go b/internal/lsp/position.go index 6e3827458..f69bdad90 100644 --- a/internal/lsp/position.go +++ b/internal/lsp/position.go @@ -11,7 +11,7 @@ import ( ) type filePosition struct { - fh FileHandler + fh *fileHandler pos hcl.Pos } @@ -42,7 +42,7 @@ func FilePositionFromDocumentPosition(params lsp.TextDocumentPositionParams, f F } return &filePosition{ - fh: FileHandler(params.TextDocument.URI), + fh: FileHandlerFromDocumentURI(params.TextDocument.URI), pos: pos, }, nil } diff --git a/internal/terraform/errors/errors.go b/internal/terraform/errors/errors.go index ec8af0449..25f4320b5 100644 --- a/internal/terraform/errors/errors.go +++ b/internal/terraform/errors/errors.go @@ -53,8 +53,8 @@ func (e *NotInitializedErr) Is(err error) bool { func (e *NotInitializedErr) Error() string { if e.Dir != "" { - return fmt.Sprintf("workspace %s not initialized", e.Dir) + return fmt.Sprintf("directory %s not initialized", e.Dir) } - return fmt.Sprintf("workspace not initialized") + return fmt.Sprintf("directory not initialized") } diff --git a/internal/terraform/exec/exec.go b/internal/terraform/exec/exec.go index c4c5081b2..82a87a2c8 100644 --- a/internal/terraform/exec/exec.go +++ b/internal/terraform/exec/exec.go @@ -31,6 +31,10 @@ var passthroughEnvVars = os.Environ() // ability to pass context for timeout/cancellation type cmdCtxFunc func(context.Context, string, ...string) *exec.Cmd +// ExecutorFactory can be used in external consumers of exec pkg +// to enable easy swapping with MockExecutor +type ExecutorFactory func(ctx context.Context, path string) *Executor + type Executor struct { ctx context.Context timeout time.Duration @@ -89,7 +93,7 @@ func (e *Executor) cmd(args ...string) (*command, error) { } ctx := e.ctx - var cancel context.CancelFunc + cancel := func() {} if e.timeout > 0 { ctx, cancel = context.WithTimeout(e.ctx, e.timeout) } @@ -184,6 +188,7 @@ func (e *Executor) runCmd(command *command) ([]byte, error) { func (e *Executor) run(args ...string) ([]byte, error) { cmd, err := e.cmd(args...) + e.logger.Printf("running with timeout %s", e.timeout) defer cmd.CancelFunc() if err != nil { return nil, err diff --git a/internal/terraform/exec/exec_mock.go b/internal/terraform/exec/exec_mock.go index 382974e8c..93025dd35 100644 --- a/internal/terraform/exec/exec_mock.go +++ b/internal/terraform/exec/exec_mock.go @@ -77,17 +77,19 @@ func (mc *MockQueue) NextMockItem() *MockItem { return mi } -func MockExecutor(md MockItemDispenser) *Executor { - if md == nil { - md = &MockCall{ - MockError: "no mocks provided", +func MockExecutor(md MockItemDispenser) ExecutorFactory { + return func(ctx context.Context, path string) *Executor { + if md == nil { + md = &MockCall{ + MockError: "no mocks provided", + } } - } - path, ctxFunc := mockCommandCtxFunc(md) - executor := NewExecutor(context.Background(), path) - executor.cmdCtxFunc = ctxFunc - return executor + path, ctxFunc := mockCommandCtxFunc(md) + executor := NewExecutor(context.Background(), path) + executor.cmdCtxFunc = ctxFunc + return executor + } } func mockCommandCtxFunc(md MockItemDispenser) (string, cmdCtxFunc) { diff --git a/internal/terraform/exec/exec_test.go b/internal/terraform/exec/exec_test.go index 1cd5490bd..5bcffd5c9 100644 --- a/internal/terraform/exec/exec_test.go +++ b/internal/terraform/exec/exec_test.go @@ -2,6 +2,7 @@ package exec import ( "bytes" + "context" "errors" "os" "testing" @@ -13,7 +14,7 @@ func TestExec_timeout(t *testing.T) { Args: []string{"version"}, SleepDuration: 100 * time.Millisecond, Stdout: "Terraform v0.12.0\n", - }) + })(context.Background(), "") e.SetWorkdir(os.TempDir()) e.timeout = 1 * time.Millisecond @@ -37,7 +38,7 @@ func TestExec_Version(t *testing.T) { Args: []string{"version"}, Stdout: "Terraform v0.12.0\n", ExitCode: 0, - }) + })(context.Background(), "") e.SetWorkdir(os.TempDir()) v, err := e.Version() if err != nil { @@ -54,7 +55,7 @@ func TestExec_Format(t *testing.T) { Args: []string{"fmt", "-"}, Stdout: string(expectedOutput), ExitCode: 0, - }) + })(context.Background(), "") e.SetWorkdir(os.TempDir()) out, err := e.Format([]byte("unformatted")) if err != nil { @@ -72,7 +73,7 @@ func TestExec_ProviderSchemas(t *testing.T) { Args: []string{"providers", "schema", "-json"}, Stdout: `{"format_version": "0.1"}`, ExitCode: 0, - }) + })(context.Background(), "") e.SetWorkdir(os.TempDir()) ps, err := e.ProviderSchemas() diff --git a/internal/terraform/lang/parser.go b/internal/terraform/lang/parser.go index fa15b04ce..e1caa85a2 100644 --- a/internal/terraform/lang/parser.go +++ b/internal/terraform/lang/parser.go @@ -36,7 +36,7 @@ type parser struct { schemaReader schema.Reader } -func ParserSupportsTerraform(v string) error { +func parserSupportsTerraform(v string) error { rawVer, err := version.NewVersion(v) if err != nil { return err @@ -69,7 +69,7 @@ func ParserSupportsTerraform(v string) error { // FindCompatibleParser finds a parser that is compatible with // given Terraform version, so that it parses config accuretly func FindCompatibleParser(v string) (Parser, error) { - err := ParserSupportsTerraform(v) + err := parserSupportsTerraform(v) if err != nil { return nil, err } diff --git a/internal/terraform/rootmodule/errors.go b/internal/terraform/rootmodule/errors.go new file mode 100644 index 000000000..a017710ee --- /dev/null +++ b/internal/terraform/rootmodule/errors.go @@ -0,0 +1,16 @@ +package rootmodule + +import ( + "fmt" +) + +type RootModuleNotFoundErr struct { + Dir string +} + +func (e *RootModuleNotFoundErr) Error() string { + if e.Dir != "" { + return fmt.Sprintf("root module not found for %s", e.Dir) + } + return "root module not found" +} diff --git a/internal/terraform/rootmodule/module_manifest.go b/internal/terraform/rootmodule/module_manifest.go new file mode 100644 index 000000000..17fcf8139 --- /dev/null +++ b/internal/terraform/rootmodule/module_manifest.go @@ -0,0 +1,121 @@ +package rootmodule + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "path/filepath" + "strings" + + version "github.com/hashicorp/go-version" +) + +func moduleManifestFilePath(dir string) string { + return filepath.Join( + dir, + ".terraform", + "modules", + "modules.json") +} + +// The following structs were copied from terraform's +// internal/modsdir/manifest.go + +// ModuleRecord represents some metadata about an installed module, as part +// of a ModuleManifest. +type ModuleRecord struct { + // Key is a unique identifier for this particular module, based on its + // position within the static module tree. + Key string `json:"Key"` + + // SourceAddr is the source address given for this module in configuration. + // This is used only to detect if the source was changed in configuration + // since the module was last installed, which means that the installer + // must re-install it. + SourceAddr string `json:"Source"` + + // Version is the exact version of the module, which results from parsing + // VersionStr. nil for un-versioned modules. + Version *version.Version `json:"-"` + + // VersionStr is the version specifier string. This is used only for + // serialization in snapshots and should not be accessed or updated + // by any other codepaths; use "Version" instead. + VersionStr string `json:"Version,omitempty"` + + // Dir is the path to the local directory where the module is installed. + Dir string `json:"Dir"` +} + +func (r *ModuleRecord) UnmarshalJSON(b []byte) error { + type rawRecord ModuleRecord + var record rawRecord + + err := json.Unmarshal(b, &record) + if err != nil { + return err + } + if record.VersionStr != "" { + record.Version, err = version.NewVersion(record.VersionStr) + if err != nil { + return fmt.Errorf("invalid version %q for %s: %s", record.VersionStr, record.Key, err) + } + } + + // Ensure Windows is using the proper modules path format after + // reading the modules manifest Dir records + record.Dir = filepath.FromSlash(record.Dir) + + // Terraform should be persisting clean paths already + // but it doesn't hurt to clean them for sanity + record.Dir = filepath.Clean(record.Dir) + + // TODO: Follow symlinks (requires proper test data) + + *r = (ModuleRecord)(record) + + return nil +} + +func (r *ModuleRecord) IsRoot() bool { + return r.Key == "" +} + +func (r *ModuleRecord) IsExternal() bool { + modCacheDir := filepath.Join(".terraform", "modules") + if strings.HasPrefix(r.Dir, modCacheDir) { + return true + } + + return false +} + +// moduleManifest is an internal struct used only to assist in our JSON +// serialization of manifest snapshots. It should not be used for any other +// purpose. +type moduleManifest struct { + rootDir string + Records []ModuleRecord `json:"Modules"` +} + +func ParseModuleManifestFromFile(path string) (*moduleManifest, error) { + b, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + + mm, err := parseModuleManifest(b) + mm.rootDir = rootModuleDirFromFilePath(path) + + return mm, nil +} + +func parseModuleManifest(b []byte) (*moduleManifest, error) { + mm := moduleManifest{} + err := json.Unmarshal(b, &mm) + if err != nil { + return nil, err + } + + return &mm, nil +} diff --git a/internal/terraform/rootmodule/module_manifest_test.go b/internal/terraform/rootmodule/module_manifest_test.go new file mode 100644 index 000000000..9c9c95c01 --- /dev/null +++ b/internal/terraform/rootmodule/module_manifest_test.go @@ -0,0 +1,27 @@ +package rootmodule + +const moduleManifestRecord_external = `{ + "Key": "web_server_sg", + "Source": "terraform-aws-modules/security-group/aws//modules/http-80", + "Version": "3.10.0", + "Dir": ".terraform/modules/web_server_sg/terraform-aws-security-group-3.10.0/modules/http-80" +}` + +const moduleManifestRecord_externalDirtyPath = `{ + "Key": "web_server_sg", + "Source": "terraform-aws-modules/security-group/aws//modules/http-80", + "Version": "3.10.0", + "Dir": ".terraform/modules/web_server_sg/terraform-aws-security-group-3.10.0/modules/something/../http-80" +}` + +const moduleManifestRecord_local = `{ + "Key": "local", + "Source": "./nested/path", + "Dir": "nested/path" +}` + +const moduleManifestRecord_root = `{ + "Key": "", + "Source": "", + "Dir": "." +}` diff --git a/internal/terraform/rootmodule/module_manifest_unix_test.go b/internal/terraform/rootmodule/module_manifest_unix_test.go new file mode 100644 index 000000000..d8fa4a1b1 --- /dev/null +++ b/internal/terraform/rootmodule/module_manifest_unix_test.go @@ -0,0 +1,99 @@ +// +build !windows + +package rootmodule + +import ( + "encoding/json" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/go-version" +) + +func TestRecord_UnmarshalJSON_basic(t *testing.T) { + var record ModuleRecord + err := json.Unmarshal([]byte(moduleManifestRecord_external), &record) + if err != nil { + t.Fatal(err) + } + + expectedVersion, err := version.NewVersion("3.10.0") + if err != nil { + t.Fatal(err) + } + expectedRecord := ModuleRecord{ + Key: "web_server_sg", + SourceAddr: "terraform-aws-modules/security-group/aws//modules/http-80", + VersionStr: "3.10.0", + Version: expectedVersion, + Dir: ".terraform/modules/web_server_sg/terraform-aws-security-group-3.10.0/modules/http-80", + } + if diff := cmp.Diff(expectedRecord, record); diff != "" { + t.Fatalf("version mismatch: %s", diff) + } +} + +func TestRecord_UnmarshalJSON_dirtyPath(t *testing.T) { + var record ModuleRecord + err := json.Unmarshal([]byte(moduleManifestRecord_externalDirtyPath), &record) + if err != nil { + t.Fatal(err) + } + + expectedDir := ".terraform/modules/web_server_sg/terraform-aws-security-group-3.10.0/modules/http-80" + if expectedDir != record.Dir { + t.Fatalf("expected dir: %s, given: %s", expectedDir, record.Dir) + } +} + +func TestRecord_UnmarshalJSON_isExternal(t *testing.T) { + var localRecord ModuleRecord + err := json.Unmarshal([]byte(moduleManifestRecord_local), &localRecord) + if err != nil { + t.Fatal(err) + } + + localExpected := false + localGiven := localRecord.IsExternal() + if localExpected != localGiven { + t.Fatalf("expected IsExternal(): %t, given: %t", localExpected, localGiven) + } + + var extRecord ModuleRecord + err = json.Unmarshal([]byte(moduleManifestRecord_external), &extRecord) + if err != nil { + t.Fatal(err) + } + + extExpected := true + extGiven := extRecord.IsExternal() + if extExpected != extGiven { + t.Fatalf("expected IsExternal(): %t, given: %t", extExpected, extGiven) + } +} + +func TestRecord_UnmarshalJSON_isRoot(t *testing.T) { + var rootRecord ModuleRecord + err := json.Unmarshal([]byte(moduleManifestRecord_root), &rootRecord) + if err != nil { + t.Fatal(err) + } + + rootExpected := true + rootGiven := rootRecord.IsRoot() + if rootExpected != rootGiven { + t.Fatalf("expected IsRoot(): %t, given: %t", rootExpected, rootGiven) + } + + var extRecord ModuleRecord + err = json.Unmarshal([]byte(moduleManifestRecord_external), &extRecord) + if err != nil { + t.Fatal(err) + } + + extExpected := false + extGiven := extRecord.IsRoot() + if extExpected != extGiven { + t.Fatalf("expected IsRoot(): %t, given: %t", extExpected, extGiven) + } +} diff --git a/internal/terraform/rootmodule/module_manifest_windows_test.go b/internal/terraform/rootmodule/module_manifest_windows_test.go new file mode 100644 index 000000000..94801a14c --- /dev/null +++ b/internal/terraform/rootmodule/module_manifest_windows_test.go @@ -0,0 +1,97 @@ +package rootmodule + +import ( + "encoding/json" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/go-version" +) + +func TestRecord_UnmarshalJSON_basic(t *testing.T) { + var record ModuleRecord + err := json.Unmarshal([]byte(moduleManifestRecord_external), &record) + if err != nil { + t.Fatal(err) + } + + expectedVersion, err := version.NewVersion("3.10.0") + if err != nil { + t.Fatal(err) + } + expectedRecord := ModuleRecord{ + Key: "web_server_sg", + SourceAddr: "terraform-aws-modules/security-group/aws//modules/http-80", + VersionStr: "3.10.0", + Version: expectedVersion, + Dir: `.terraform\modules\web_server_sg\terraform-aws-security-group-3.10.0\modules\http-80`, + } + if diff := cmp.Diff(expectedRecord, record); diff != "" { + t.Fatalf("version mismatch: %s", diff) + } +} + +func TestRecord_UnmarshalJSON_dirtyPath(t *testing.T) { + var record ModuleRecord + err := json.Unmarshal([]byte(moduleManifestRecord_externalDirtyPath), &record) + if err != nil { + t.Fatal(err) + } + + expectedDir := `.terraform\modules\web_server_sg\terraform-aws-security-group-3.10.0\modules\http-80` + if expectedDir != record.Dir { + t.Fatalf("expected dir: %s, given: %s", expectedDir, record.Dir) + } +} + +func TestRecord_UnmarshalJSON_isExternal(t *testing.T) { + var localRecord ModuleRecord + err := json.Unmarshal([]byte(moduleManifestRecord_local), &localRecord) + if err != nil { + t.Fatal(err) + } + + localExpected := false + localGiven := localRecord.IsExternal() + if localExpected != localGiven { + t.Fatalf("expected IsExternal(): %t, given: %t", localExpected, localGiven) + } + + var extRecord ModuleRecord + err = json.Unmarshal([]byte(moduleManifestRecord_external), &extRecord) + if err != nil { + t.Fatal(err) + } + + extExpected := true + extGiven := extRecord.IsExternal() + if extExpected != extGiven { + t.Fatalf("expected IsExternal(): %t, given: %t", extExpected, extGiven) + } +} + +func TestRecord_UnmarshalJSON_isRoot(t *testing.T) { + var rootRecord ModuleRecord + err := json.Unmarshal([]byte(moduleManifestRecord_root), &rootRecord) + if err != nil { + t.Fatal(err) + } + + rootExpected := true + rootGiven := rootRecord.IsRoot() + if rootExpected != rootGiven { + t.Fatalf("expected IsRoot(): %t, given: %t", rootExpected, rootGiven) + } + + var extRecord ModuleRecord + err = json.Unmarshal([]byte(moduleManifestRecord_external), &extRecord) + if err != nil { + t.Fatal(err) + } + + extExpected := false + extGiven := extRecord.IsRoot() + if extExpected != extGiven { + t.Fatalf("expected IsRoot(): %t, given: %t", extExpected, extGiven) + } +} diff --git a/internal/terraform/rootmodule/plugin_lock_file.go b/internal/terraform/rootmodule/plugin_lock_file.go new file mode 100644 index 000000000..f3306489c --- /dev/null +++ b/internal/terraform/rootmodule/plugin_lock_file.go @@ -0,0 +1,22 @@ +package rootmodule + +import ( + "path/filepath" + "runtime" +) + +func pluginLockFilePaths(dir string) []string { + return []string{ + // Terraform >= v0.13 + filepath.Join(dir, + ".terraform", + "plugins", + "selections.json"), + // Terraform <= v0.12 + filepath.Join(dir, + ".terraform", + "plugins", + runtime.GOOS+"_"+runtime.GOARCH, + "lock.json"), + } +} diff --git a/internal/terraform/rootmodule/root_module.go b/internal/terraform/rootmodule/root_module.go new file mode 100644 index 000000000..b65e0b89d --- /dev/null +++ b/internal/terraform/rootmodule/root_module.go @@ -0,0 +1,316 @@ +package rootmodule + +import ( + "context" + "fmt" + "io/ioutil" + "log" + "os" + "path/filepath" + "sync" + "time" + + "github.com/hashicorp/terraform-ls/internal/terraform/discovery" + tferr "github.com/hashicorp/terraform-ls/internal/terraform/errors" + "github.com/hashicorp/terraform-ls/internal/terraform/exec" + "github.com/hashicorp/terraform-ls/internal/terraform/lang" + "github.com/hashicorp/terraform-ls/internal/terraform/schema" +) + +type rootModule struct { + ctx context.Context + logger *log.Logger + pluginLockFile File + moduleManifestFile File + moduleManifest *moduleManifest + tfVersion string + + tfDiscoFunc discovery.DiscoveryFunc + tfNewExecutor exec.ExecutorFactory + tfExecPath string + tfExecTimeout time.Duration + tfExecLogPath string + newSchemaStorage schema.StorageFactory + ignorePluginCache bool + + tfExec *exec.Executor + parser lang.Parser + schemaWriter schema.Writer + pluginMu *sync.RWMutex + moduleMu *sync.RWMutex +} + +func newRootModule(ctx context.Context) *rootModule { + return &rootModule{ + ctx: ctx, + logger: defaultLogger, + pluginMu: &sync.RWMutex{}, + moduleMu: &sync.RWMutex{}, + } +} + +var defaultLogger = log.New(ioutil.Discard, "", 0) + +func NewRootModule(ctx context.Context, dir string) (RootModule, error) { + rm := newRootModule(ctx) + + d := &discovery.Discovery{} + rm.tfDiscoFunc = d.LookPath + + rm.tfNewExecutor = exec.NewExecutor + rm.newSchemaStorage = func() *schema.Storage { + ss := schema.NewStorage() + ss.SetSynchronous() + return ss + } + + return rm, rm.init(ctx, dir) +} + +func (rm *rootModule) SetLogger(logger *log.Logger) { + rm.logger = logger +} + +func (rm *rootModule) init(ctx context.Context, dir string) error { + tf, err := rm.initTfExecutor(dir) + if err != nil { + return err + } + + version, err := tf.Version() + if err != nil { + return err + } + + err = schema.SchemaSupportsTerraform(version) + if err != nil { + return err + } + + p, err := lang.FindCompatibleParser(version) + if err != nil { + return err + } + p.SetLogger(rm.logger) + + ss := rm.newSchemaStorage() + + ss.SetLogger(rm.logger) + + p.SetSchemaReader(ss) + + rm.parser = p + rm.schemaWriter = ss + rm.tfExec = tf + rm.tfVersion = version + + err = rm.initPluginCache(dir) + if err != nil { + return fmt.Errorf("plugin initialization failed: %w", err) + } + err = rm.initModuleCache(dir) + if err != nil { + return err + } + return nil +} + +func (rm *rootModule) initTfExecutor(dir string) (*exec.Executor, error) { + tfPath := rm.tfExecPath + if tfPath == "" { + var err error + tfPath, err = rm.tfDiscoFunc() + if err != nil { + return nil, err + } + } + + tf := rm.tfNewExecutor(rm.ctx, tfPath) + + tf.SetWorkdir(dir) + tf.SetLogger(rm.logger) + + if rm.tfExecLogPath != "" { + tf.SetExecLogPath(rm.tfExecLogPath) + } + + if rm.tfExecTimeout != 0 { + tf.SetTimeout(rm.tfExecTimeout) + } + + return tf, nil +} + +func (rm *rootModule) initPluginCache(dir string) error { + var lf File + if rm.ignorePluginCache { + lf = &file{ + path: pluginLockFilePaths(dir)[0], + } + } else { + var err error + lockPaths := pluginLockFilePaths(dir) + lf, err = findFile(lockPaths) + if err != nil { + if os.IsNotExist(err) { + return &tferr.NotInitializedErr{Dir: dir} + } + + return fmt.Errorf("unable to calculate hash: %w", err) + } + } + + return rm.UpdatePluginCache(lf) +} + +func findFile(paths []string) (File, error) { + var lf File + var err error + + for _, path := range paths { + lf, err = newFile(path) + if err == nil { + return lf, nil + } + if !os.IsNotExist(err) { + return nil, err + } + } + + return nil, err +} + +type file struct { + path string +} + +func (f *file) Path() string { + return f.path +} + +func newFile(path string) (File, error) { + fi, err := os.Stat(path) + if err != nil { + return nil, err + } + if fi.IsDir() { + return nil, fmt.Errorf("expected %s to be a file, not a dir", path) + } + + return &file{path: path}, nil +} + +func (rm *rootModule) initModuleCache(dir string) error { + lf, err := newFile(moduleManifestFilePath(dir)) + if err != nil { + if os.IsNotExist(err) { + rm.logger.Printf("module lock file not found: %s", err.Error()) + return nil + } + + return fmt.Errorf("unable to calculate hash: %w", err) + } + + return rm.UpdateModuleManifest(lf) +} + +func (rm *rootModule) UpdateModuleManifest(lockFile File) error { + rm.moduleMu.Lock() + rm.logger.Printf("updating module manifest based on %s ...", lockFile.Path()) + defer rm.moduleMu.Unlock() + + rm.moduleManifestFile = lockFile + + mm, err := ParseModuleManifestFromFile(lockFile.Path()) + if err != nil { + return err + } + + rm.moduleManifest = mm + rm.logger.Printf("updated module manifest - %d references parsed", len(mm.Records)) + return nil +} + +func (rm *rootModule) Parser() lang.Parser { + rm.pluginMu.RLock() + defer rm.pluginMu.RUnlock() + + return rm.parser +} + +func (rm *rootModule) ReferencesModulePath(path string) bool { + if rm.moduleManifest == nil { + return false + } + + for _, m := range rm.moduleManifest.Records { + if m.IsRoot() { + // skip root module, as that's tracked separately + continue + } + if m.IsExternal() { + // skip external modules as these shouldn't be modified from cache + continue + } + absPath := filepath.Join(rm.moduleManifest.rootDir, m.Dir) + rm.logger.Printf("checking if %q == %q", absPath, path) + if absPath == path { + return true + } + } + + return false +} + +func (rm *rootModule) TerraformExecutor() *exec.Executor { + return rm.tfExec +} + +func (rm *rootModule) UpdatePluginCache(lockFile File) error { + rm.pluginMu.Lock() + defer rm.pluginMu.Unlock() + + rm.pluginLockFile = lockFile + + return rm.schemaWriter.ObtainSchemasForModule( + rm.tfExec, rootModuleDirFromFilePath(lockFile.Path())) +} + +func (rm *rootModule) PathsToWatch() []string { + rm.pluginMu.RLock() + rm.moduleMu.RLock() + defer rm.moduleMu.RUnlock() + defer rm.pluginMu.RUnlock() + + files := make([]string, 0) + if rm.pluginLockFile != nil { + files = append(files, rm.pluginLockFile.Path()) + } + if rm.moduleManifestFile != nil { + files = append(files, rm.moduleManifestFile.Path()) + } + + return files +} + +func (rm *rootModule) IsKnownModuleManifestFile(path string) bool { + rm.pluginMu.RLock() + defer rm.pluginMu.RUnlock() + + if rm.pluginLockFile == nil { + return false + } + + return rm.pluginLockFile.Path() == path +} + +func (rm *rootModule) IsKnownPluginLockFile(path string) bool { + rm.moduleMu.RLock() + defer rm.moduleMu.RUnlock() + + if rm.moduleManifestFile == nil { + return false + } + + return rm.moduleManifestFile.Path() == path +} diff --git a/internal/terraform/rootmodule/root_module_manager.go b/internal/terraform/rootmodule/root_module_manager.go new file mode 100644 index 000000000..f59deb575 --- /dev/null +++ b/internal/terraform/rootmodule/root_module_manager.go @@ -0,0 +1,166 @@ +package rootmodule + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/hashicorp/terraform-ls/internal/terraform/discovery" + "github.com/hashicorp/terraform-ls/internal/terraform/exec" + "github.com/hashicorp/terraform-ls/internal/terraform/lang" + "github.com/hashicorp/terraform-ls/internal/terraform/schema" +) + +type rootModuleManager struct { + rms map[string]*rootModule + tfExecPath string + tfExecTimeout time.Duration + tfExecLogPath string + logger *log.Logger + + newRootModule RootModuleFactory +} + +func NewRootModuleManager(ctx context.Context) RootModuleManager { + return newRootModuleManager(ctx) +} + +func newRootModuleManager(ctx context.Context) *rootModuleManager { + rmm := &rootModuleManager{ + rms: make(map[string]*rootModule, 0), + logger: defaultLogger, + } + rmm.newRootModule = rmm.defaultRootModuleFactory + return rmm +} + +func (rmm *rootModuleManager) defaultRootModuleFactory(ctx context.Context, dir string) (*rootModule, error) { + w := newRootModule(ctx) + + w.SetLogger(rmm.logger) + + d := &discovery.Discovery{} + w.tfDiscoFunc = d.LookPath + w.tfNewExecutor = exec.NewExecutor + w.newSchemaStorage = schema.NewStorage + + w.tfExecPath = rmm.tfExecPath + w.tfExecTimeout = rmm.tfExecTimeout + w.tfExecLogPath = rmm.tfExecLogPath + + return w, w.init(ctx, dir) +} + +func (rmm *rootModuleManager) SetTerraformExecPath(path string) { + rmm.tfExecPath = path +} + +func (rmm *rootModuleManager) SetTerraformExecLogPath(logPath string) { + rmm.tfExecLogPath = logPath +} + +func (rmm *rootModuleManager) SetTerraformExecTimeout(timeout time.Duration) { + rmm.tfExecTimeout = timeout +} + +func (rmm *rootModuleManager) SetLogger(logger *log.Logger) { + rmm.logger = logger +} + +func (rmm *rootModuleManager) AddRootModule(dir string) error { + dir = filepath.Clean(dir) + + // TODO: Follow symlinks (requires proper test data) + + _, exists := rmm.rms[dir] + if exists { + return fmt.Errorf("rootModule %s was already added", dir) + } + + w, err := rmm.newRootModule(context.Background(), dir) + if err != nil { + return err + } + + rmm.rms[dir] = w + + return nil +} + +func (rmm *rootModuleManager) RootModuleByPath(path string) (RootModule, error) { + path = filepath.Clean(path) + + // TODO: Follow symlinks (requires proper test data) + + if rm, ok := rmm.rms[path]; ok { + rmm.logger.Printf("direct root module lookup succeeded: %s", path) + return rm, nil + } + + dir := rootModuleDirFromFilePath(path) + if rm, ok := rmm.rms[dir]; ok { + rmm.logger.Printf("dir-based root module lookup succeeded: %s", dir) + return rm, nil + } + + for _, rm := range rmm.rms { + rmm.logger.Printf("looking up %s in module references", dir) + if rm.ReferencesModulePath(dir) { + rmm.logger.Printf("module-ref-based root module lookup succeeded: %s", dir) + return rm, nil + } + } + + return nil, &RootModuleNotFoundErr{path} +} + +func (rmm *rootModuleManager) ParserForDir(path string) (lang.Parser, error) { + w, err := rmm.RootModuleByPath(path) + if err != nil { + return nil, err + } + + return w.Parser(), nil +} + +func (rmm *rootModuleManager) TerraformExecutorForDir(path string) (*exec.Executor, error) { + w, err := rmm.RootModuleByPath(path) + if err != nil { + return nil, err + } + + return w.TerraformExecutor(), nil +} + +// rootModuleDirFromPath strips known lock file paths and filenames +// to get the directory path of the relevant rootModule +func rootModuleDirFromFilePath(filePath string) string { + pluginLockFileSuffixes := pluginLockFilePaths(string(os.PathSeparator)) + for _, s := range pluginLockFileSuffixes { + if strings.HasSuffix(filePath, s) { + return strings.TrimSuffix(filePath, s) + } + } + + moduleManifestSuffix := moduleManifestFilePath(string(os.PathSeparator)) + if strings.HasSuffix(filePath, moduleManifestSuffix) { + return strings.TrimSuffix(filePath, moduleManifestSuffix) + } + + return filePath +} + +func (rmm *rootModuleManager) PathsToWatch() []string { + paths := make([]string, 0) + for _, rm := range rmm.rms { + ptw := rm.PathsToWatch() + if len(ptw) > 0 { + paths = append(paths, ptw...) + } + } + return paths +} diff --git a/internal/terraform/rootmodule/root_module_manager_mock.go b/internal/terraform/rootmodule/root_module_manager_mock.go new file mode 100644 index 000000000..d4587c72c --- /dev/null +++ b/internal/terraform/rootmodule/root_module_manager_mock.go @@ -0,0 +1,58 @@ +package rootmodule + +import ( + "context" + "fmt" + "log" + + tfjson "github.com/hashicorp/terraform-json" + "github.com/hashicorp/terraform-ls/internal/terraform/discovery" + "github.com/hashicorp/terraform-ls/internal/terraform/exec" + "github.com/hashicorp/terraform-ls/internal/terraform/schema" +) + +type RootModuleMock struct { + TerraformExecQueue exec.MockItemDispenser + ProviderSchemas *tfjson.ProviderSchemas +} + +type RootModuleMockFactory struct { + rmm map[string]*RootModuleMock + logger *log.Logger +} + +func (rmf *RootModuleMockFactory) New(ctx context.Context, dir string) (*rootModule, error) { + rm, ok := rmf.rmm[dir] + if !ok { + return nil, fmt.Errorf("unexpected root module requested: %s (%d available: %#v)", dir, len(rmf.rmm), rmf.rmm) + } + + w := newRootModule(ctx) + w.SetLogger(rmf.logger) + + md := &discovery.MockDiscovery{Path: "tf-mock"} + w.tfDiscoFunc = md.LookPath + + // For now, until we have better testing strategy to mimic real lock files + w.ignorePluginCache = true + + w.tfNewExecutor = exec.MockExecutor(rm.TerraformExecQueue) + + if rm.ProviderSchemas == nil { + w.newSchemaStorage = schema.MockStorage(rm.ProviderSchemas) + } else { + w.newSchemaStorage = schema.NewStorage + } + + return w, w.init(ctx, dir) +} + +func NewRootModuleManagerMock(m map[string]*RootModuleMock) RootModuleManagerFactory { + rm := newRootModuleManager(context.Background()) + rmf := &RootModuleMockFactory{rmm: m, logger: rm.logger} + rm.newRootModule = rmf.New + + return func(ctx context.Context) RootModuleManager { + return rm + } +} diff --git a/internal/terraform/rootmodule/root_module_manager_mock_test.go b/internal/terraform/rootmodule/root_module_manager_mock_test.go new file mode 100644 index 000000000..c93a22656 --- /dev/null +++ b/internal/terraform/rootmodule/root_module_manager_mock_test.go @@ -0,0 +1,54 @@ +package rootmodule + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/hashicorp/terraform-ls/internal/terraform/exec" +) + +func TestNewRootModuleManagerMock_noMocks(t *testing.T) { + f := NewRootModuleManagerMock(map[string]*RootModuleMock{}) + rmm := f(context.Background()) + err := rmm.AddRootModule("any-path") + if err == nil { + t.Fatal("expected unmocked path addition to fail") + } +} + +func TestNewRootModuleManagerMock_mocks(t *testing.T) { + tmpDir := filepath.Clean(os.TempDir()) + + f := NewRootModuleManagerMock(map[string]*RootModuleMock{ + tmpDir: { + TerraformExecQueue: &exec.MockQueue{ + Q: []*exec.MockItem{ + { + Args: []string{"version"}, + Stdout: "Terraform v0.12.0\n", + }, + { + Args: []string{"providers", "schema", "-json"}, + Stdout: "{\"format_version\":\"0.1\"}\n", + }, + }, + }, + }, + }) + rmm := f(context.Background()) + err := rmm.AddRootModule(tmpDir) + if err != nil { + t.Fatal(err) + } +} + +func TestMain(m *testing.M) { + if v := os.Getenv("TF_LS_MOCK"); v != "" { + os.Exit(exec.ExecuteMockData(v)) + return + } + + os.Exit(m.Run()) +} diff --git a/internal/terraform/rootmodule/root_module_manager_test.go b/internal/terraform/rootmodule/root_module_manager_test.go new file mode 100644 index 000000000..b4690ec37 --- /dev/null +++ b/internal/terraform/rootmodule/root_module_manager_test.go @@ -0,0 +1,120 @@ +package rootmodule + +import ( + "context" + "io/ioutil" + "log" + "os" + "path/filepath" + "testing" +) + +func TestRootModuleManager_RootModuleByPath(t *testing.T) { + rmm := testRootModuleManager(t) + + direct, unrelated, dirbased := testRootModule(t), testRootModule(t), testRootModule(t) + rmm.rms = map[string]*rootModule{ + "direct": direct, + "unrelated": unrelated, + "dirbased": dirbased, + } + + w1, err := rmm.RootModuleByPath("direct") + if err != nil { + t.Fatal(err) + } + if direct != w1 { + t.Fatalf("unexpected root module found: %p, expected: %p", w1, direct) + } + + w2, err := rmm.RootModuleByPath(filepath.Join("dirbased", ".terraform", "plugins", "selections.json")) + if err != nil { + t.Fatal(err) + } + if dirbased != w2 { + t.Fatalf("unexpected root module found: %p, expected: %p", w2, dirbased) + } +} + +func TestRootModuleManager_RootModuleByPath_moduleRefs(t *testing.T) { + rmm := testRootModuleManager(t) + direct, unrelated, modbased := testRootModule(t), testRootModule(t), testRootModule(t) + + mm, err := parseModuleManifest([]byte(`{ + "Modules": [ + { + "Key": "local.deep-inside", + "Source": "../../another-one", + "Dir": "another-one" + }, + { + "Key": "web_server_sg", + "Source": "terraform-aws-modules/security-group/aws//modules/http-80", + "Version": "3.10.0", + "Dir": ".terraform/modules/web_server_sg/terraform-aws-security-group-3.10.0/modules/http-80" + }, + { + "Key": "web_server_sg.sg", + "Source": "../../", + "Dir": ".terraform/modules/web_server_sg/terraform-aws-security-group-3.10.0" + }, + { + "Key": "", + "Source": "", + "Dir": "." + }, + { + "Key": "local", + "Source": "./nested/path", + "Dir": "nested/path" + } + ] +}`)) + if err != nil { + t.Fatal(err) + } + mm.rootDir = "newroot" + modbased.moduleManifest = mm + + rmm.rms = map[string]*rootModule{ + "direct": direct, + "unrelated": unrelated, + "modulebased": modbased, + } + + t.Run("dir-path", func(t *testing.T) { + w, err := rmm.RootModuleByPath(filepath.Join("newroot", "nested", "path")) + if err != nil { + t.Fatal(err) + } + if modbased != w { + t.Fatalf("unexpected root module found: %p, expected: %p", w, modbased) + } + }) + t.Run("file-path", func(t *testing.T) { + _, err := rmm.RootModuleByPath(filepath.Join("newroot", "nested", "path", "file.tf")) + if err == nil { + t.Fatal("expected file-based lookup to fail") + } + }) +} + +func testRootModuleManager(t *testing.T) *rootModuleManager { + rmm := newRootModuleManager(context.Background()) + rmm.logger = testLogger() + return rmm +} + +func testRootModule(t *testing.T) *rootModule { + w := newRootModule(context.Background()) + w.logger = testLogger() + return w +} + +func testLogger() *log.Logger { + if testing.Verbose() { + return log.New(os.Stdout, "", log.LstdFlags|log.Lshortfile) + } + + return log.New(ioutil.Discard, "", 0) +} diff --git a/internal/terraform/rootmodule/types.go b/internal/terraform/rootmodule/types.go new file mode 100644 index 000000000..31056cd90 --- /dev/null +++ b/internal/terraform/rootmodule/types.go @@ -0,0 +1,48 @@ +package rootmodule + +import ( + "context" + "log" + "time" + + "github.com/hashicorp/terraform-ls/internal/terraform/exec" + "github.com/hashicorp/terraform-ls/internal/terraform/lang" +) + +type File interface { + Path() string +} + +type ParserFinder interface { + ParserForDir(path string) (lang.Parser, error) +} + +type TerraformExecFinder interface { + TerraformExecutorForDir(path string) (*exec.Executor, error) +} + +type RootModuleManager interface { + SetLogger(logger *log.Logger) + SetTerraformExecPath(path string) + SetTerraformExecLogPath(logPath string) + SetTerraformExecTimeout(timeout time.Duration) + AddRootModule(dir string) error + PathsToWatch() []string + RootModuleByPath(path string) (RootModule, error) + ParserForDir(path string) (lang.Parser, error) + TerraformExecutorForDir(path string) (*exec.Executor, error) +} + +type RootModule interface { + IsKnownPluginLockFile(path string) bool + IsKnownModuleManifestFile(path string) bool + PathsToWatch() []string + UpdatePluginCache(lockFile File) error + UpdateModuleManifest(manifestFile File) error + Parser() lang.Parser + TerraformExecutor() *exec.Executor +} + +type RootModuleFactory func(context.Context, string) (*rootModule, error) + +type RootModuleManagerFactory func(context.Context) RootModuleManager diff --git a/internal/terraform/schema/schema_storage.go b/internal/terraform/schema/schema_storage.go index 2215cf196..666ffb8fe 100644 --- a/internal/terraform/schema/schema_storage.go +++ b/internal/terraform/schema/schema_storage.go @@ -24,9 +24,7 @@ type Reader interface { } type Writer interface { - ObtainSchemasForWorkspace(*exec.Executor, string) error - AddWorkspaceForWatching(string) error - StartWatching(*exec.Executor) error + ObtainSchemasForModule(*exec.Executor, string) error } type Resource struct { @@ -43,10 +41,10 @@ type DataSource struct { DescriptionKind tfjson.SchemaDescriptionKind } +type StorageFactory func() *Storage + type Storage struct { - ps *tfjson.ProviderSchemas - w watcher - watching bool + ps *tfjson.ProviderSchemas logger *log.Logger @@ -108,18 +106,18 @@ func (s *Storage) SetSynchronous() { s.sync = true } -// ObtainSchemasForWorkspace will (by default) asynchronously obtain schema via tf +// ObtainSchemasForModule will (by default) asynchronously obtain schema via tf // and store it for later consumption via Reader methods -func (s *Storage) ObtainSchemasForWorkspace(tf *exec.Executor, dir string) error { +func (s *Storage) ObtainSchemasForModule(tf *exec.Executor, dir string) error { if s.sync { - return s.obtainSchemasForWorkspace(tf, dir) + return s.obtainSchemasForModule(tf, dir) } // This routine is not cancellable in itself // but the time-consuming part is done by exec.Executor // which is cancellable via its own context go func() { - err := s.obtainSchemasForWorkspace(tf, dir) + err := s.obtainSchemasForModule(tf, dir) if err != nil { s.logger.Println("error obtaining schemas:", err) } @@ -128,7 +126,7 @@ func (s *Storage) ObtainSchemasForWorkspace(tf *exec.Executor, dir string) error return nil } -func (s *Storage) obtainSchemasForWorkspace(tf *exec.Executor, dir string) error { +func (s *Storage) obtainSchemasForModule(tf *exec.Executor, dir string) error { s.logger.Printf("Acquiring semaphore before retrieving schema for %q ...", dir) err := s.sem.Acquire(context.Background(), 1) if err != nil { @@ -279,67 +277,3 @@ func (s *Storage) DataSources() ([]DataSource, error) { return dataSources, nil } - -// watcher creates a new Watcher instance -// if one doesn't exist yet or returns an existing one -func (s *Storage) watcher() (watcher, error) { - if s.w != nil { - return s.w, nil - } - - w, err := NewWatcher() - if err != nil { - return nil, err - } - w.SetLogger(s.logger) - - s.w = w - return s.w, nil -} - -// StartWatching starts to watch for plugin changes in dirs that were added -// via AddWorkspaceForWatching until StopWatching() is called -func (s *Storage) StartWatching(tf *exec.Executor) error { - if s.watching { - s.logger.Println("watching already in progress") - return nil - } - w, err := s.watcher() - if err != nil { - return err - } - - go w.OnPluginChange(func(ww *watchedWorkspace) error { - s.obtainSchemasForWorkspace(tf, ww.dir) - return nil - }) - s.watching = true - - s.logger.Printf("Watching for plugin changes ...") - - return nil -} - -func (s *Storage) StopWatching() error { - if s.w == nil { - return nil - } - - err := s.w.Close() - if err == nil { - s.watching = false - } - - return err -} - -func (s *Storage) AddWorkspaceForWatching(dir string) error { - w, err := s.watcher() - if err != nil { - return err - } - - s.logger.Printf("Adding workspace for watching: %q", dir) - - return w.AddWorkspace(dir) -} diff --git a/internal/terraform/schema/schema_storage_mock.go b/internal/terraform/schema/schema_storage_mock.go index 0b8c86cfc..626e593cc 100644 --- a/internal/terraform/schema/schema_storage_mock.go +++ b/internal/terraform/schema/schema_storage_mock.go @@ -4,15 +4,16 @@ import ( tfjson "github.com/hashicorp/terraform-json" ) -func MockStorage(ps *tfjson.ProviderSchemas) *Storage { - s := NewStorage() - if ps == nil { - ps = &tfjson.ProviderSchemas{} +func MockStorage(ps *tfjson.ProviderSchemas) StorageFactory { + return func() *Storage { + s := NewStorage() + if ps == nil { + ps = &tfjson.ProviderSchemas{} + } + s.ps = ps + s.sync = true + return s } - s.ps = ps - s.sync = true - s.w = &MockWatcher{} - return s } type MockReader struct { @@ -27,7 +28,7 @@ type MockReader struct { } func (r *MockReader) storage() *Storage { - return MockStorage(r.ProviderSchemas) + return MockStorage(r.ProviderSchemas)() } func (r *MockReader) ProviderConfigSchema(name string) (*tfjson.Schema, error) { diff --git a/internal/terraform/schema/watcher.go b/internal/terraform/schema/watcher.go deleted file mode 100644 index b83189ae6..000000000 --- a/internal/terraform/schema/watcher.go +++ /dev/null @@ -1,181 +0,0 @@ -package schema - -import ( - "crypto/sha256" - "fmt" - "io" - "log" - "os" - "path/filepath" - "runtime" - - "github.com/fsnotify/fsnotify" - "github.com/hashicorp/terraform-ls/internal/terraform/errors" -) - -type watcher interface { - AddWorkspace(string) error - Close() error - Events() chan fsnotify.Event - Errors() chan error - OnPluginChange(func(*watchedWorkspace) error) - SetLogger(*log.Logger) -} - -func lockFilePaths(dir string) []string { - return []string{ - // Terraform >= v0.13 - filepath.Join(dir, - ".terraform", - "plugins", - "selections.json"), - // Terraform <= v0.12 - filepath.Join(dir, - ".terraform", - "plugins", - runtime.GOOS+"_"+runtime.GOARCH, - "lock.json"), - } -} - -// Watcher is a wrapper around native fsnotify.Watcher -// to make it swappable for MockWatcher via interface, -// provide higher-level ability to detect actual file changes -// (rather than just events that may not be changing any bytes) -// and hold knowledge about workspace structure -type Watcher struct { - w *fsnotify.Watcher - lockFiles map[string]*watchedWorkspace - logger *log.Logger -} - -type watchedWorkspace struct { - pluginsLockFileHash string - dir string -} - -func NewWatcher() (*Watcher, error) { - w, err := fsnotify.NewWatcher() - if err != nil { - return nil, err - } - return &Watcher{ - w: w, - lockFiles: make(map[string]*watchedWorkspace, 0), - logger: defaultLogger, - }, nil -} - -func (w *Watcher) SetLogger(logger *log.Logger) { - w.logger = logger -} - -func (w *Watcher) AddWorkspace(dir string) error { - lockPaths := lockFilePaths(dir) - w.logger.Printf("Adding %q for watching...", lockPaths) - - lockFile, err := findLockFile(lockPaths) - if err != nil { - if os.IsNotExist(err) { - return &errors.NotInitializedErr{ - Dir: dir, - } - } - return fmt.Errorf("unable to calculate hash: %w", err) - } - - w.lockFiles[lockFile.path] = &watchedWorkspace{ - pluginsLockFileHash: lockFile.hash, - dir: dir, - } - - return w.w.Add(lockFile.path) -} - -type lockFile struct { - path string - hash string -} - -func findLockFile(paths []string) (*lockFile, error) { - var b []byte - var err error - - for _, path := range paths { - b, err = fileHashSum(path) - if err == nil { - return &lockFile{ - path: path, - hash: string(b), - }, nil - } - - if !os.IsNotExist(err) { - return nil, err - } - } - - return nil, err -} - -func fileHashSum(path string) ([]byte, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - - h := sha256.New() - _, err = io.Copy(h, f) - if err != nil { - return nil, err - } - - return h.Sum(nil), nil -} - -func (w *Watcher) Close() error { - return w.w.Close() -} - -func (w *Watcher) Events() chan fsnotify.Event { - return w.w.Events -} - -func (w *Watcher) Errors() chan error { - return w.w.Errors -} - -func (w *Watcher) OnPluginChange(f func(*watchedWorkspace) error) { - for { - select { - case event, ok := <-w.Events(): - if !ok { - return - } - - if event.Op&fsnotify.Write == fsnotify.Write { - hash, err := fileHashSum(event.Name) - if err != nil { - w.logger.Println("unable to calculate hash:", err) - } - newHash := string(hash) - existingHash := w.lockFiles[event.Name].pluginsLockFileHash - - if newHash != existingHash { - w.lockFiles[event.Name].pluginsLockFileHash = newHash - - err = f(w.lockFiles[event.Name]) - if err != nil { - w.logger.Println("error when executing on change:", err) - } - } - } - case err, ok := <-w.Errors(): - if !ok { - return - } - w.logger.Println("watch error:", err) - } - } -} diff --git a/internal/terraform/schema/watcher_mock.go b/internal/terraform/schema/watcher_mock.go deleted file mode 100644 index 914d4a285..000000000 --- a/internal/terraform/schema/watcher_mock.go +++ /dev/null @@ -1,29 +0,0 @@ -package schema - -import ( - "log" - - "github.com/fsnotify/fsnotify" -) - -type MockWatcher struct{} - -func (w *MockWatcher) AddWorkspace(string) error { - return nil -} - -func (w *MockWatcher) Close() error { - return nil -} - -func (w *MockWatcher) Events() chan fsnotify.Event { - return nil -} - -func (w *MockWatcher) Errors() chan error { - return nil -} - -func (w *MockWatcher) OnPluginChange(func(*watchedWorkspace) error) {} - -func (w *MockWatcher) SetLogger(*log.Logger) {} diff --git a/internal/watcher/tracked_file.go b/internal/watcher/tracked_file.go new file mode 100644 index 000000000..861bf6a1a --- /dev/null +++ b/internal/watcher/tracked_file.go @@ -0,0 +1,54 @@ +package watcher + +import ( + "crypto/sha256" + "io" + "os" + "path/filepath" +) + +func trackedFileFromPath(path string) (TrackedFile, error) { + path, err := filepath.EvalSymlinks(path) + if err != nil { + return nil, err + } + + b, err := fileSha256Sum(path) + if err != nil { + return nil, err + } + + return &trackedFile{ + path: path, + sha256sum: string(b), + }, nil +} + +type trackedFile struct { + path string + sha256sum string +} + +func (tf *trackedFile) Path() string { + return tf.path +} + +func (tf *trackedFile) Sha256Sum() string { + return tf.sha256sum +} + +func fileSha256Sum(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + h := sha256.New() + _, err = io.Copy(h, f) + if err != nil { + return nil, err + } + + return h.Sum(nil), nil +} diff --git a/internal/watcher/types.go b/internal/watcher/types.go new file mode 100644 index 000000000..cb6009cab --- /dev/null +++ b/internal/watcher/types.go @@ -0,0 +1,21 @@ +package watcher + +import ( + "log" +) + +type TrackedFile interface { + Path() string + Sha256Sum() string +} + +type Watcher interface { + Start() error + Stop() error + SetLogger(logger *log.Logger) + AddPath(path string) error + AddPaths(paths []string) error + AddChangeHook(f ChangeHook) +} + +type ChangeHook func(file TrackedFile) error diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go new file mode 100644 index 000000000..b7d4f8361 --- /dev/null +++ b/internal/watcher/watcher.go @@ -0,0 +1,129 @@ +package watcher + +import ( + "io/ioutil" + "log" + + "github.com/fsnotify/fsnotify" +) + +// Watcher is a wrapper around native fsnotify.Watcher +// It provides the ability to detect actual file changes +// (rather than just events that may not be changing any bytes) +type watcher struct { + fw *fsnotify.Watcher + trackedFiles map[string]TrackedFile + changeHooks []ChangeHook + watching bool + logger *log.Logger +} + +type WatcherFactory func() (Watcher, error) + +func NewWatcher() (Watcher, error) { + fw, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + return &watcher{ + fw: fw, + logger: defaultLogger, + trackedFiles: make(map[string]TrackedFile, 0), + }, nil +} + +var defaultLogger = log.New(ioutil.Discard, "", 0) + +func (w *watcher) SetLogger(logger *log.Logger) { + w.logger = logger +} + +func (w *watcher) AddPaths(paths []string) error { + for _, p := range paths { + err := w.AddPath(p) + if err != nil { + return err + } + } + return nil +} + +func (w *watcher) AddPath(path string) error { + w.logger.Printf("adding %s for watching", path) + + tf, err := trackedFileFromPath(path) + if err != nil { + return err + } + w.trackedFiles[path] = tf + + return w.fw.Add(path) +} + +func (w *watcher) AddChangeHook(h ChangeHook) { + w.changeHooks = append(w.changeHooks, h) +} + +func (w *watcher) run() { + for { + select { + case event, ok := <-w.fw.Events: + if !ok { + return + } + + if event.Op&fsnotify.Write == fsnotify.Write { + w.logger.Printf("detected write into %s", event.Name) + oldTf := w.trackedFiles[event.Name] + newTf, err := trackedFileFromPath(event.Name) + if err != nil { + w.logger.Println("failed to track file, ignoring", err) + continue + } + w.trackedFiles[event.Name] = newTf + + if oldTf.Sha256Sum() != newTf.Sha256Sum() { + for _, h := range w.changeHooks { + err := h(newTf) + if err != nil { + w.logger.Println("change hook error:", err) + } + } + } + } + case err, ok := <-w.fw.Errors: + if !ok { + return + } + w.logger.Println("watch error:", err) + } + } +} + +// StartWatching starts to watch for changes that were added +// via AddPath(s) until Stop() is called +func (w *watcher) Start() error { + if w.watching { + w.logger.Println("watching already in progress") + return nil + } + + go w.run() + w.watching = true + w.logger.Printf("Watching for changes ...") + + return nil +} + +func (w *watcher) Stop() error { + if !w.watching { + return nil + } + + err := w.fw.Close() + if err == nil { + w.watching = false + } + + return err +} diff --git a/internal/watcher/watcher_mock.go b/internal/watcher/watcher_mock.go new file mode 100644 index 000000000..8eef0ddf6 --- /dev/null +++ b/internal/watcher/watcher_mock.go @@ -0,0 +1,34 @@ +package watcher + +import ( + "log" +) + +func MockWatcher() WatcherFactory { + return func() (Watcher, error) { + return &mockWatcher{}, nil + } +} + +type mockWatcher struct{} + +func (w *mockWatcher) AddChangeHook(h ChangeHook) { +} + +func (w *mockWatcher) AddPaths(paths []string) error { + return nil +} + +func (w *mockWatcher) AddPath(path string) error { + return nil +} + +func (w *mockWatcher) Start() error { + return nil +} + +func (w *mockWatcher) Stop() error { + return nil +} + +func (w *mockWatcher) SetLogger(*log.Logger) {} diff --git a/langserver/handlers/complete.go b/langserver/handlers/complete.go index f8303db71..f0e150c1d 100644 --- a/langserver/handlers/complete.go +++ b/langserver/handlers/complete.go @@ -7,7 +7,6 @@ import ( lsctx "github.com/hashicorp/terraform-ls/internal/context" ihcl "github.com/hashicorp/terraform-ls/internal/hcl" ilsp "github.com/hashicorp/terraform-ls/internal/lsp" - "github.com/hashicorp/terraform-ls/internal/terraform/lang" lsp "github.com/sourcegraph/go-lsp" ) @@ -24,22 +23,18 @@ func (h *logHandler) TextDocumentComplete(ctx context.Context, params lsp.Comple return list, err } - sr, err := lsctx.TerraformSchemaReader(ctx) - if err != nil { - return list, err - } - - tfVersion, err := lsctx.TerraformVersion(ctx) + pf, err := lsctx.ParserFinder(ctx) if err != nil { return list, err } h.logger.Printf("Finding block at position %#v", params.TextDocumentPositionParams) - file, err := fs.GetFile(ilsp.FileHandler(params.TextDocument.URI)) + file, err := fs.GetFile(ilsp.FileHandlerFromDocumentURI(params.TextDocument.URI)) if err != nil { return list, err } + hclFile := ihcl.NewFile(file) fPos, err := ilsp.FilePositionFromDocumentPosition(params.TextDocumentPositionParams, file) if err != nil { @@ -48,12 +43,10 @@ func (h *logHandler) TextDocumentComplete(ctx context.Context, params lsp.Comple pos := fPos.Position() - p, err := lang.FindCompatibleParser(tfVersion) + p, err := pf.ParserForDir(file.Dir()) if err != nil { return list, fmt.Errorf("finding compatible parser failed: %w", err) } - p.SetLogger(h.logger) - p.SetSchemaReader(sr) candidates, err := p.CompletionCandidatesAtPos(hclFile, pos) if err != nil { diff --git a/langserver/handlers/complete_test.go b/langserver/handlers/complete_test.go index 6c58887a1..26f87c53d 100644 --- a/langserver/handlers/complete_test.go +++ b/langserver/handlers/complete_test.go @@ -5,12 +5,13 @@ import ( "testing" "github.com/hashicorp/terraform-ls/internal/terraform/exec" + "github.com/hashicorp/terraform-ls/internal/terraform/rootmodule" "github.com/hashicorp/terraform-ls/langserver" "github.com/hashicorp/terraform-ls/langserver/session" ) func TestCompletion_withoutInitialization(t *testing.T) { - ls := langserver.NewLangServerMock(t, NewMock(nil)) + ls := langserver.NewLangServerMock(t, NewMockSession(nil)) stop := ls.Start(t) defer stop() @@ -24,19 +25,23 @@ func TestCompletion_withoutInitialization(t *testing.T) { "character": 0, "line": 1 } - }`, TempDirUri())}, session.SessionNotInitialized.Err()) + }`, TempDir().URI())}, session.SessionNotInitialized.Err()) } func TestCompletion_withValidData(t *testing.T) { - ls := langserver.NewLangServerMock(t, NewMock(&exec.MockQueue{ - Q: []*exec.MockItem{ - { - Args: []string{"version"}, - Stdout: "Terraform v0.12.0\n", - }, - { - Args: []string{"providers", "schema", "-json"}, - Stdout: testSchemaOutput, + ls := langserver.NewLangServerMock(t, NewMockSession(map[string]*rootmodule.RootModuleMock{ + TempDir().Dir(): { + TerraformExecQueue: &exec.MockQueue{ + Q: []*exec.MockItem{ + { + Args: []string{"version"}, + Stdout: "Terraform v0.12.0\n", + }, + { + Args: []string{"providers", "schema", "-json"}, + Stdout: testSchemaOutput, + }, + }, }, }, })) @@ -49,7 +54,7 @@ func TestCompletion_withValidData(t *testing.T) { "capabilities": {}, "rootUri": %q, "processId": 12345 - }`, TempDirUri())}) + }`, TempDir().URI())}) ls.Notify(t, &langserver.CallRequest{ Method: "initialized", ReqParams: "{}", @@ -63,7 +68,7 @@ func TestCompletion_withValidData(t *testing.T) { "text": "provider \"test\" {\n\n}\n", "uri": "%s/main.tf" } - }`, TempDirUri())}) + }`, TempDir().URI())}) ls.CallAndExpectResponse(t, &langserver.CallRequest{ Method: "textDocument/completion", @@ -75,7 +80,7 @@ func TestCompletion_withValidData(t *testing.T) { "character": 0, "line": 1 } - }`, TempDirUri())}, `{ + }`, TempDir().URI())}, `{ "jsonrpc": "2.0", "id": 3, "result": { diff --git a/langserver/handlers/did_close.go b/langserver/handlers/did_close.go index 28c4c67a6..799f73dc1 100644 --- a/langserver/handlers/did_close.go +++ b/langserver/handlers/did_close.go @@ -14,6 +14,6 @@ func TextDocumentDidClose(ctx context.Context, params lsp.DidCloseTextDocumentPa return err } - fh := ilsp.FileHandler(params.TextDocument.URI) + fh := ilsp.FileHandlerFromDocumentURI(params.TextDocument.URI) return fs.Close(fh) } diff --git a/langserver/handlers/did_open_test.go b/langserver/handlers/did_open_test.go index 0cb15e2e5..ba9a423eb 100644 --- a/langserver/handlers/did_open_test.go +++ b/langserver/handlers/did_open_test.go @@ -9,7 +9,7 @@ import ( ) func TestLangServer_didOpenWithoutInitialization(t *testing.T) { - ls := langserver.NewLangServerMock(t, NewMock(nil)) + ls := langserver.NewLangServerMock(t, NewMockSession(nil)) stop := ls.Start(t) defer stop() @@ -22,5 +22,5 @@ func TestLangServer_didOpenWithoutInitialization(t *testing.T) { "text": "provider \"github\" {\n\n}\n", "uri": "%s/main.tf" } - }`, TempDirUri())}, session.SessionNotInitialized.Err()) + }`, TempDir().URI())}, session.SessionNotInitialized.Err()) } diff --git a/langserver/handlers/formatting.go b/langserver/handlers/formatting.go index c9e9423c0..ca159deae 100644 --- a/langserver/handlers/formatting.go +++ b/langserver/handlers/formatting.go @@ -2,7 +2,6 @@ package handlers import ( "context" - "os" lsctx "github.com/hashicorp/terraform-ls/internal/context" "github.com/hashicorp/terraform-ls/internal/hcl" @@ -18,19 +17,24 @@ func (h *logHandler) TextDocumentFormatting(ctx context.Context, params lsp.Docu return edits, err } - tf, err := lsctx.TerraformExecutor(ctx) + tff, err := lsctx.TerraformExecutorFinder(ctx) if err != nil { return edits, err } - // Input is sent to stdin -> no need for a meaningful workdir - tf.SetWorkdir(os.TempDir()) - fh := ilsp.FileHandler(params.TextDocument.URI) + fh := ilsp.FileHandlerFromDocumentURI(params.TextDocument.URI) file, err := fs.GetFile(fh) if err != nil { return edits, err } + tf, err := tff.TerraformExecutorForDir(fh.Dir()) + if err != nil { + return edits, err + } + + // TODO: This should probably be FormatWithContext() + // so it's cancellable on request cancellation formatted, err := tf.Format(file.Text()) if err != nil { return edits, err diff --git a/langserver/handlers/formatting_test.go b/langserver/handlers/formatting_test.go index 540e2aa48..e6c8b4f77 100644 --- a/langserver/handlers/formatting_test.go +++ b/langserver/handlers/formatting_test.go @@ -5,12 +5,13 @@ import ( "testing" "github.com/hashicorp/terraform-ls/internal/terraform/exec" + "github.com/hashicorp/terraform-ls/internal/terraform/rootmodule" "github.com/hashicorp/terraform-ls/langserver" "github.com/hashicorp/terraform-ls/langserver/session" ) func TestLangServer_formattingWithoutInitialization(t *testing.T) { - ls := langserver.NewLangServerMock(t, NewMock(nil)) + ls := langserver.NewLangServerMock(t, NewMockSession(nil)) stop := ls.Start(t) defer stop() @@ -23,16 +24,18 @@ func TestLangServer_formattingWithoutInitialization(t *testing.T) { "text": "provider \"github\" {\n\n}\n", "uri": "%s/main.tf" } - }`, TempDirUri())}, session.SessionNotInitialized.Err()) + }`, TempDir().URI())}, session.SessionNotInitialized.Err()) } -func TestLangServer_formatting(t *testing.T) { +func TestLangServer_formatting_basic(t *testing.T) { queue := validTfMockCalls() queue.Q = append(queue.Q, &exec.MockItem{ Args: []string{"fmt", "-"}, Stdout: "provider \"test\" {\n\n}\n", }) - ls := langserver.NewLangServerMock(t, NewMock(queue)) + ls := langserver.NewLangServerMock(t, NewMockSession(map[string]*rootmodule.RootModuleMock{ + TempDir().Dir(): {TerraformExecQueue: queue}, + })) stop := ls.Start(t) defer stop() @@ -42,7 +45,7 @@ func TestLangServer_formatting(t *testing.T) { "capabilities": {}, "rootUri": %q, "processId": 12345 - }`, TempDirUri())}) + }`, TempDir().URI())}) ls.Notify(t, &langserver.CallRequest{ Method: "initialized", ReqParams: "{}", @@ -56,14 +59,14 @@ func TestLangServer_formatting(t *testing.T) { "text": "provider \"test\" {\n\n}\n", "uri": "%s/main.tf" } - }`, TempDirUri())}) + }`, TempDir().URI())}) ls.CallAndExpectResponse(t, &langserver.CallRequest{ Method: "textDocument/formatting", ReqParams: fmt.Sprintf(`{ "textDocument": { "uri": "%s/main.tf" } - }`, TempDirUri())}, `{ + }`, TempDir().URI())}, `{ "jsonrpc": "2.0", "id": 3, "result": [ diff --git a/langserver/handlers/handlers_test.go b/langserver/handlers/handlers_test.go index 77b7ea293..54d086ff9 100644 --- a/langserver/handlers/handlers_test.go +++ b/langserver/handlers/handlers_test.go @@ -7,11 +7,14 @@ import ( "github.com/hashicorp/terraform-ls/internal/lsp" "github.com/hashicorp/terraform-ls/internal/terraform/exec" + "github.com/hashicorp/terraform-ls/internal/terraform/rootmodule" "github.com/hashicorp/terraform-ls/langserver" ) func TestInitalizeAndShutdown(t *testing.T) { - ls := langserver.NewLangServerMock(t, NewMock(validTfMockCalls())) + ls := langserver.NewLangServerMock(t, NewMockSession(map[string]*rootmodule.RootModuleMock{ + TempDir().Dir(): {TerraformExecQueue: validTfMockCalls()}, + })) stop := ls.Start(t) defer stop() @@ -21,7 +24,7 @@ func TestInitalizeAndShutdown(t *testing.T) { "capabilities": {}, "rootUri": %q, "processId": 12345 - }`, TempDirUri())}, `{ + }`, TempDir().URI())}, `{ "jsonrpc": "2.0", "id": 1, "result": { @@ -45,7 +48,9 @@ func TestInitalizeAndShutdown(t *testing.T) { } func TestEOF(t *testing.T) { - ms := newMockSession(validTfMockCalls()) + ms := newMockSession(map[string]*rootmodule.RootModuleMock{ + TempDir().Dir(): {TerraformExecQueue: validTfMockCalls()}, + }) ls := langserver.NewLangServerMock(t, ms.new) stop := ls.Start(t) defer stop() @@ -56,7 +61,7 @@ func TestEOF(t *testing.T) { "capabilities": {}, "rootUri": %q, "processId": 12345 - }`, TempDirUri())}, `{ + }`, TempDir().URI())}, `{ "jsonrpc": "2.0", "id": 1, "result": { @@ -105,6 +110,7 @@ func TestMain(m *testing.M) { os.Exit(m.Run()) } -func TempDirUri() string { - return lsp.FileHandlerFromPath(os.TempDir()).URI() +func TempDir() lsp.FileHandler { + tmpDir := os.TempDir() + return lsp.FileHandlerFromDirPath(tmpDir) } diff --git a/langserver/handlers/initialize.go b/langserver/handlers/initialize.go index 14b458649..594646fcd 100644 --- a/langserver/handlers/initialize.go +++ b/langserver/handlers/initialize.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "os" lsctx "github.com/hashicorp/terraform-ls/internal/context" ilsp "github.com/hashicorp/terraform-ls/internal/lsp" @@ -28,14 +27,12 @@ func (lh *logHandler) Initialize(ctx context.Context, params lsp.InitializeParam }, } - fh := ilsp.FileHandler(params.RootURI) + fh := ilsp.FileHandlerFromDirURI(params.RootURI) if !fh.Valid() { return serverCaps, fmt.Errorf("URI %q is not valid", params.RootURI) } - rootURI := fh.FullPath() - - if rootURI == "" { + if !fh.IsDir() { return serverCaps, fmt.Errorf("Editing a single file is not yet supported." + " Please open a directory.") } @@ -45,71 +42,31 @@ func (lh *logHandler) Initialize(ctx context.Context, params lsp.InitializeParam return serverCaps, err } - ss, err := lsctx.TerraformSchemaWriter(ctx) - if err != nil { - return serverCaps, err - } - - tf, err := lsctx.TerraformExecutor(ctx) + wm, err := lsctx.RootModuleManager(ctx) if err != nil { return serverCaps, err } - // Workdir is not important for version - // and schema obtaining is explicit anyway - tf.SetWorkdir(os.TempDir()) - - tfVersion, err := tf.Version() + ww, err := lsctx.Watcher(ctx) if err != nil { return serverCaps, err } - err = supportsTerraform(tfVersion) + err = wm.AddRootModule(fh.Dir()) if err != nil { - if uvErr, ok := err.(*tferr.UnsupportedTerraformVersion); ok { - lh.logger.Printf("Unsupported terraform version: %s", uvErr) - // Which component exactly imposed the constrain may not be relevant - // to the user unless they are very familiar with internals of the LS - // so we avoid displaying it, but it will be logged for debugging purposes. - uvErr.Component = "" - - return serverCaps, fmt.Errorf("%w. "+ - "Please upgrade or make supported version available in $PATH"+ - " and reopen %s", uvErr, rootURI) + if errors.Is(err, &tferr.NotInitializedErr{}) { + return serverCaps, fmt.Errorf("Directory not initialized. "+ + "Please run `terraform init` in %s", fh.Dir()) } - - // We naively assume that Terraform version can't change at runtime - // and just fail initalization early and force user to reopen IDE - // with supported TF version. - // - // Longer-term we may want to pick up changes while LS is running. - // That would require asynchronous and continuous discovery though. - return serverCaps, err - } - - lh.logger.Printf("Found compatible Terraform version (%s) at %s", - tfVersion, tf.GetExecPath()) - - err = lsctx.SetTerraformVersion(ctx, tfVersion) - if err != nil { - return serverCaps, err - } - - err = ss.ObtainSchemasForWorkspace(tf, rootURI) - if err != nil { return serverCaps, err } - err = ss.AddWorkspaceForWatching(rootURI) + err = ww.AddPaths(wm.PathsToWatch()) if err != nil { - if errors.Is(err, &tferr.NotInitializedErr{}) { - return serverCaps, fmt.Errorf("Workspace not initialized. "+ - "Please run `terraform init` in %s", rootURI) - } return serverCaps, err } - err = ss.StartWatching(tf) + err = ww.Start() if err != nil { return serverCaps, err } diff --git a/langserver/handlers/initialize_test.go b/langserver/handlers/initialize_test.go index acfdc9977..46c6271c4 100644 --- a/langserver/handlers/initialize_test.go +++ b/langserver/handlers/initialize_test.go @@ -6,11 +6,14 @@ import ( "github.com/creachadair/jrpc2/code" "github.com/hashicorp/terraform-ls/internal/terraform/exec" + "github.com/hashicorp/terraform-ls/internal/terraform/rootmodule" "github.com/hashicorp/terraform-ls/langserver" ) func TestInitialize_twice(t *testing.T) { - ls := langserver.NewLangServerMock(t, NewMock(validTfMockCalls())) + ls := langserver.NewLangServerMock(t, NewMockSession(map[string]*rootmodule.RootModuleMock{ + TempDir().Dir(): {TerraformExecQueue: validTfMockCalls()}, + })) stop := ls.Start(t) defer stop() @@ -20,20 +23,24 @@ func TestInitialize_twice(t *testing.T) { "capabilities": {}, "rootUri": %q, "processId": 12345 - }`, TempDirUri())}) + }`, TempDir().URI())}) ls.CallAndExpectError(t, &langserver.CallRequest{ Method: "initialize", ReqParams: fmt.Sprintf(`{ "capabilities": {}, "rootUri": %q, "processId": 12345 - }`, TempDirUri())}, code.SystemError.Err()) + }`, TempDir().URI())}, code.SystemError.Err()) } func TestInitialize_withIncompatibleTerraformVersion(t *testing.T) { - ls := langserver.NewLangServerMock(t, NewMock(&exec.MockCall{ - Args: []string{"version"}, - Stdout: "Terraform v0.11.0\n", + ls := langserver.NewLangServerMock(t, NewMockSession(map[string]*rootmodule.RootModuleMock{ + TempDir().Dir(): { + TerraformExecQueue: &exec.MockCall{ + Args: []string{"version"}, + Stdout: "Terraform v0.11.0\n", + }, + }, })) stop := ls.Start(t) defer stop() @@ -44,11 +51,13 @@ func TestInitialize_withIncompatibleTerraformVersion(t *testing.T) { "capabilities": {}, "processId": 12345, "rootUri": %q - }`, TempDirUri())}, code.SystemError.Err()) + }`, TempDir().URI())}, code.SystemError.Err()) } func TestInitialize_withInvalidRootURI(t *testing.T) { - ls := langserver.NewLangServerMock(t, NewMock(validTfMockCalls())) + ls := langserver.NewLangServerMock(t, NewMockSession(map[string]*rootmodule.RootModuleMock{ + TempDir().Dir(): {TerraformExecQueue: validTfMockCalls()}, + })) stop := ls.Start(t) defer stop() diff --git a/langserver/handlers/service.go b/langserver/handlers/service.go index 00194e94f..cf0973a89 100644 --- a/langserver/handlers/service.go +++ b/langserver/handlers/service.go @@ -12,10 +12,8 @@ import ( rpch "github.com/creachadair/jrpc2/handler" lsctx "github.com/hashicorp/terraform-ls/internal/context" "github.com/hashicorp/terraform-ls/internal/filesystem" - "github.com/hashicorp/terraform-ls/internal/terraform/discovery" - "github.com/hashicorp/terraform-ls/internal/terraform/exec" - "github.com/hashicorp/terraform-ls/internal/terraform/lang" - "github.com/hashicorp/terraform-ls/internal/terraform/schema" + "github.com/hashicorp/terraform-ls/internal/terraform/rootmodule" + "github.com/hashicorp/terraform-ls/internal/watcher" "github.com/hashicorp/terraform-ls/langserver/session" "github.com/sourcegraph/go-lsp" ) @@ -28,24 +26,22 @@ type service struct { sessCtx context.Context stopSession context.CancelFunc - tfDiscoFunc discovery.DiscoveryFunc - ss *schema.Storage - executorFunc func(ctx context.Context, execPath string) *exec.Executor + ww watcher.Watcher + newRootModuleManager rootmodule.RootModuleManagerFactory + newWatcher watcher.WatcherFactory } var discardLogs = log.New(ioutil.Discard, "", 0) func NewSession(srvCtx context.Context) session.Session { sessCtx, stopSession := context.WithCancel(srvCtx) - d := &discovery.Discovery{} return &service{ - logger: discardLogs, - srvCtx: srvCtx, - sessCtx: sessCtx, - stopSession: stopSession, - executorFunc: exec.NewExecutor, - tfDiscoFunc: d.LookPath, - ss: schema.NewStorage(), + logger: discardLogs, + srvCtx: srvCtx, + sessCtx: sessCtx, + stopSession: stopSession, + newRootModuleManager: rootmodule.NewRootModuleManager, + newWatcher: watcher.NewWatcher, } } @@ -53,10 +49,6 @@ func (svc *service) SetLogger(logger *log.Logger) { svc.logger = logger } -func (svc *service) SetDiscoveryFunc(f discovery.DiscoveryFunc) { - svc.tfDiscoFunc = f -} - // Assigner builds out the jrpc2.Map according to the LSP protocol // and passes related dependencies to handlers via context func (svc *service) Assigner() (jrpc2.Assigner, error) { @@ -73,8 +65,51 @@ func (svc *service) Assigner() (jrpc2.Assigner, error) { fs.SetLogger(svc.logger) lh := LogHandler(svc.logger) cc := &lsp.ClientCapabilities{} - tfVersion := "0.0.0" - svc.ss.SetLogger(svc.logger) + + rmm := svc.newRootModuleManager(svc.sessCtx) + rmm.SetLogger(svc.logger) + + // The following is set via CLI flags, hence available in the server context + if path, ok := lsctx.TerraformExecPath(svc.srvCtx); ok { + rmm.SetTerraformExecPath(path) + } + if path, ok := lsctx.TerraformExecLogPath(svc.srvCtx); ok { + rmm.SetTerraformExecLogPath(path) + } + if timeout, ok := lsctx.TerraformExecTimeout(svc.srvCtx); ok { + rmm.SetTerraformExecTimeout(timeout) + } + + ww, err := svc.newWatcher() + if err != nil { + return nil, err + } + svc.ww = ww + svc.ww.SetLogger(svc.logger) + svc.ww.AddChangeHook(func(file watcher.TrackedFile) error { + w, err := rmm.RootModuleByPath(file.Path()) + if err != nil { + return err + } + if w.IsKnownPluginLockFile(file.Path()) { + svc.logger.Printf("detected plugin cache change, updating ...") + return w.UpdatePluginCache(file) + } + + return nil + }) + svc.ww.AddChangeHook(func(file watcher.TrackedFile) error { + rm, err := rmm.RootModuleByPath(file.Path()) + if err != nil { + return err + } + if rm.IsKnownModuleManifestFile(file.Path()) { + svc.logger.Printf("detected module manifest change, updating ...") + return rm.UpdateModuleManifest(file) + } + + return nil + }) m := map[string]rpch.Func{ "initialize": func(ctx context.Context, req *jrpc2.Request) (interface{}, error) { @@ -84,32 +119,8 @@ func (svc *service) Assigner() (jrpc2.Assigner, error) { } ctx = lsctx.WithFilesystem(fs, ctx) ctx = lsctx.WithClientCapabilitiesSetter(cc, ctx) - - tfPath, err := svc.tfDiscoFunc() - if err != nil { - return nil, err - } - - // We intentionally pass session context here to make executor cancellable - // on session shutdown, rather than response delivery or request cancellation - // as some operations may run in isolated goroutines - tf := svc.executorFunc(svc.sessCtx, tfPath) - - // Log path is set via CLI flag, hence in the server context - if path, ok := lsctx.TerraformExecLogPath(svc.srvCtx); ok { - tf.SetExecLogPath(path) - } - - // Timeout is set via CLI flag, hence in the server context - if timeout, ok := lsctx.TerraformExecTimeout(svc.srvCtx); ok { - tf.SetTimeout(timeout) - } - - tf.SetLogger(svc.logger) - - ctx = lsctx.WithTerraformExecutor(tf, ctx) - ctx = lsctx.WithTerraformVersionSetter(&tfVersion, ctx) - ctx = lsctx.WithTerraformSchemaWriter(svc.ss, ctx) + ctx = lsctx.WithWatcher(ww, ctx) + ctx = lsctx.WithRootModuleManager(rmm, ctx) return handle(ctx, req, lh.Initialize) }, @@ -154,8 +165,7 @@ func (svc *service) Assigner() (jrpc2.Assigner, error) { ctx = lsctx.WithFilesystem(fs, ctx) // TODO: Read-only FS ctx = lsctx.WithClientCapabilities(cc, ctx) - ctx = lsctx.WithTerraformVersion(tfVersion, ctx) - ctx = lsctx.WithTerraformSchemaReader(svc.ss, ctx) + ctx = lsctx.WithParserFinder(rmm, ctx) return handle(ctx, req, lh.TextDocumentComplete) }, @@ -166,20 +176,7 @@ func (svc *service) Assigner() (jrpc2.Assigner, error) { } ctx = lsctx.WithFilesystem(fs, ctx) - - tfPath, err := svc.tfDiscoFunc() - if err != nil { - return nil, err - } - - tf := svc.executorFunc(ctx, tfPath) - // Log path is set via CLI flag, hence the server context - if path, ok := lsctx.TerraformExecLogPath(svc.srvCtx); ok { - tf.SetExecLogPath(path) - } - tf.SetLogger(svc.logger) - - ctx = lsctx.WithTerraformExecutor(tf, ctx) + ctx = lsctx.WithTerraformExecFinder(rmm, ctx) return handle(ctx, req, lh.TextDocumentFormatting) }, @@ -219,10 +216,10 @@ func (svc *service) Finish(status jrpc2.ServerStatus) { svc.logger.Printf("session stopped unexpectedly (err: %v)", status.Err) } - svc.logger.Println("Stopping schema watcher for session ...") - err := svc.ss.StopWatching() + svc.logger.Println("Stopping watcher for session ...") + err := svc.ww.Stop() if err != nil { - svc.logger.Println("Unable to stop schema watcher for session:", err) + svc.logger.Println("Unable to stop watcher for session:", err) } svc.stopSession() @@ -251,17 +248,3 @@ func handle(ctx context.Context, req *jrpc2.Request, fn interface{}) (interface{ } return result, err } - -func supportsTerraform(tfVersion string) error { - err := schema.SchemaSupportsTerraform(tfVersion) - if err != nil { - return err - } - - err = lang.ParserSupportsTerraform(tfVersion) - if err != nil { - return err - } - - return nil -} diff --git a/langserver/handlers/service_mock.go b/langserver/handlers/service_mock.go index f51a5d700..093e0ceaf 100644 --- a/langserver/handlers/service_mock.go +++ b/langserver/handlers/service_mock.go @@ -3,14 +3,13 @@ package handlers import ( "context" - "github.com/hashicorp/terraform-ls/internal/terraform/discovery" - "github.com/hashicorp/terraform-ls/internal/terraform/exec" - "github.com/hashicorp/terraform-ls/internal/terraform/schema" + "github.com/hashicorp/terraform-ls/internal/terraform/rootmodule" + "github.com/hashicorp/terraform-ls/internal/watcher" "github.com/hashicorp/terraform-ls/langserver/session" ) type mockSession struct { - mid exec.MockItemDispenser + mockRMs map[string]*rootmodule.RootModuleMock stopFunc func() stopFuncCalled bool @@ -19,18 +18,14 @@ type mockSession struct { func (ms *mockSession) new(srvCtx context.Context) session.Session { sessCtx, stopSession := context.WithCancel(srvCtx) ms.stopFunc = stopSession - d := discovery.MockDiscovery{Path: "mock-tf"} svc := &service{ - logger: discardLogs, - srvCtx: srvCtx, - sessCtx: sessCtx, - stopSession: ms.stop, - executorFunc: func(context.Context, string) *exec.Executor { - return exec.MockExecutor(ms.mid) - }, - tfDiscoFunc: d.LookPath, - ss: schema.MockStorage(nil), + logger: discardLogs, + srvCtx: srvCtx, + sessCtx: sessCtx, + stopSession: ms.stop, + newRootModuleManager: rootmodule.NewRootModuleManagerMock(ms.mockRMs), + newWatcher: watcher.MockWatcher(), } return svc @@ -45,10 +40,10 @@ func (ms *mockSession) StopFuncCalled() bool { return ms.stopFuncCalled } -func newMockSession(mid exec.MockItemDispenser) *mockSession { - return &mockSession{mid: mid} +func newMockSession(mockRMs map[string]*rootmodule.RootModuleMock) *mockSession { + return &mockSession{mockRMs: mockRMs} } -func NewMock(mid exec.MockItemDispenser) session.SessionFactory { - return newMockSession(mid).new +func NewMockSession(mockRMs map[string]*rootmodule.RootModuleMock) session.SessionFactory { + return newMockSession(mockRMs).new } diff --git a/langserver/handlers/shutdown_test.go b/langserver/handlers/shutdown_test.go index db0ce14c3..4d3996121 100644 --- a/langserver/handlers/shutdown_test.go +++ b/langserver/handlers/shutdown_test.go @@ -5,11 +5,14 @@ import ( "testing" "github.com/creachadair/jrpc2/code" + "github.com/hashicorp/terraform-ls/internal/terraform/rootmodule" "github.com/hashicorp/terraform-ls/langserver" ) func TestShutdown_twice(t *testing.T) { - ls := langserver.NewLangServerMock(t, NewMock(validTfMockCalls())) + ls := langserver.NewLangServerMock(t, NewMockSession(map[string]*rootmodule.RootModuleMock{ + TempDir().Dir(): {TerraformExecQueue: validTfMockCalls()}, + })) stop := ls.Start(t) defer stop() @@ -19,7 +22,7 @@ func TestShutdown_twice(t *testing.T) { "capabilities": {}, "rootUri": %q, "processId": 12345 - }`, TempDirUri())}) + }`, TempDir().URI())}) ls.Call(t, &langserver.CallRequest{ Method: "shutdown", ReqParams: `{}`}) diff --git a/langserver/langserver.go b/langserver/langserver.go index 3dfadb46b..f8b30d91f 100644 --- a/langserver/langserver.go +++ b/langserver/langserver.go @@ -12,17 +12,14 @@ import ( "github.com/creachadair/jrpc2" "github.com/creachadair/jrpc2/channel" "github.com/creachadair/jrpc2/server" - - "github.com/hashicorp/terraform-ls/internal/terraform/discovery" "github.com/hashicorp/terraform-ls/langserver/session" ) type langServer struct { - srvCtx context.Context - logger *log.Logger - tfDiscoFunc discovery.DiscoveryFunc - srvOptions *jrpc2.ServerOptions - newSession session.SessionFactory + srvCtx context.Context + logger *log.Logger + srvOptions *jrpc2.ServerOptions + newSession session.SessionFactory } func NewLangServer(srvCtx context.Context, sf session.SessionFactory) *langServer { @@ -48,16 +45,9 @@ func (ls *langServer) SetLogger(logger *log.Logger) { ls.logger = logger } -func (ls *langServer) SetDiscoveryFunc(f discovery.DiscoveryFunc) { - ls.tfDiscoFunc = f -} - func (ls *langServer) newService() server.Service { svc := ls.newSession(ls.srvCtx) svc.SetLogger(ls.logger) - if ls.tfDiscoFunc != nil { - svc.SetDiscoveryFunc(ls.tfDiscoFunc) - } return svc } diff --git a/langserver/session/types.go b/langserver/session/types.go index 22eea3d0e..8e25ea1df 100644 --- a/langserver/session/types.go +++ b/langserver/session/types.go @@ -5,15 +5,12 @@ import ( "log" "github.com/creachadair/jrpc2" - - "github.com/hashicorp/terraform-ls/internal/terraform/discovery" ) type Session interface { Assigner() (jrpc2.Assigner, error) Finish(jrpc2.ServerStatus) SetLogger(*log.Logger) - SetDiscoveryFunc(discovery.DiscoveryFunc) } type SessionFactory func(context.Context) Session diff --git a/vendor/github.com/hashicorp/go-multierror/.travis.yml b/vendor/github.com/hashicorp/go-multierror/.travis.yml index 304a83595..24b80388f 100644 --- a/vendor/github.com/hashicorp/go-multierror/.travis.yml +++ b/vendor/github.com/hashicorp/go-multierror/.travis.yml @@ -9,4 +9,4 @@ branches: only: - master -script: make test testrace +script: env GO111MODULE=on make test testrace diff --git a/vendor/github.com/hashicorp/go-multierror/README.md b/vendor/github.com/hashicorp/go-multierror/README.md index ead5830f7..e92fa614c 100644 --- a/vendor/github.com/hashicorp/go-multierror/README.md +++ b/vendor/github.com/hashicorp/go-multierror/README.md @@ -14,9 +14,10 @@ be a list of errors. If the caller knows this, they can unwrap the list and access the errors. If the caller doesn't know, the error formats to a nice human-readable format. -`go-multierror` implements the -[errwrap](https://github.com/hashicorp/errwrap) interface so that it can -be used with that library, as well. +`go-multierror` is fully compatible with the Go standard library +[errors](https://golang.org/pkg/errors/) package, including the +functions `As`, `Is`, and `Unwrap`. This provides a standardized approach +for introspecting on error values. ## Installation and Docs @@ -81,6 +82,39 @@ if err := something(); err != nil { } ``` +You can also use the standard [`errors.Unwrap`](https://golang.org/pkg/errors/#Unwrap) +function. This will continue to unwrap into subsequent errors until none exist. + +**Extracting an error** + +The standard library [`errors.As`](https://golang.org/pkg/errors/#As) +function can be used directly with a multierror to extract a specific error: + +```go +// Assume err is a multierror value +err := somefunc() + +// We want to know if "err" has a "RichErrorType" in it and extract it. +var errRich RichErrorType +if errors.As(err, &errRich) { + // It has it, and now errRich is populated. +} +``` + +**Checking for an exact error value** + +Some errors are returned as exact errors such as the [`ErrNotExist`](https://golang.org/pkg/os/#pkg-variables) +error in the `os` package. You can check if this error is present by using +the standard [`errors.Is`](https://golang.org/pkg/errors/#Is) function. + +```go +// Assume err is a multierror value +err := somefunc() +if errors.Is(err, os.ErrNotExist) { + // err contains os.ErrNotExist +} +``` + **Returning a multierror only if there are errors** If you build a `multierror.Error`, you can use the `ErrorOrNil` function diff --git a/vendor/github.com/hashicorp/go-multierror/go.mod b/vendor/github.com/hashicorp/go-multierror/go.mod index 2534331d5..0afe8e6f9 100644 --- a/vendor/github.com/hashicorp/go-multierror/go.mod +++ b/vendor/github.com/hashicorp/go-multierror/go.mod @@ -1,3 +1,5 @@ module github.com/hashicorp/go-multierror +go 1.14 + require github.com/hashicorp/errwrap v1.0.0 diff --git a/vendor/github.com/hashicorp/go-multierror/go.sum b/vendor/github.com/hashicorp/go-multierror/go.sum index 85b1f8ff3..e8238e9ec 100644 --- a/vendor/github.com/hashicorp/go-multierror/go.sum +++ b/vendor/github.com/hashicorp/go-multierror/go.sum @@ -1,4 +1,2 @@ -github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce h1:prjrVgOk2Yg6w+PflHoszQNLTUh4kaByUcEWM/9uin4= -github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= diff --git a/vendor/github.com/hashicorp/go-multierror/group.go b/vendor/github.com/hashicorp/go-multierror/group.go new file mode 100644 index 000000000..9c29efb7f --- /dev/null +++ b/vendor/github.com/hashicorp/go-multierror/group.go @@ -0,0 +1,38 @@ +package multierror + +import "sync" + +// Group is a collection of goroutines which return errors that need to be +// coalesced. +type Group struct { + mutex sync.Mutex + err *Error + wg sync.WaitGroup +} + +// Go calls the given function in a new goroutine. +// +// If the function returns an error it is added to the group multierror which +// is returned by Wait. +func (g *Group) Go(f func() error) { + g.wg.Add(1) + + go func() { + defer g.wg.Done() + + if err := f(); err != nil { + g.mutex.Lock() + g.err = Append(g.err, err) + g.mutex.Unlock() + } + }() +} + +// Wait blocks until all function calls from the Go method have returned, then +// returns the multierror. +func (g *Group) Wait() *Error { + g.wg.Wait() + g.mutex.Lock() + defer g.mutex.Unlock() + return g.err +} diff --git a/vendor/github.com/hashicorp/go-multierror/multierror.go b/vendor/github.com/hashicorp/go-multierror/multierror.go index 89b1422d1..d05dd9269 100644 --- a/vendor/github.com/hashicorp/go-multierror/multierror.go +++ b/vendor/github.com/hashicorp/go-multierror/multierror.go @@ -1,6 +1,7 @@ package multierror import ( + "errors" "fmt" ) @@ -49,3 +50,69 @@ func (e *Error) GoString() string { func (e *Error) WrappedErrors() []error { return e.Errors } + +// Unwrap returns an error from Error (or nil if there are no errors). +// This error returned will further support Unwrap to get the next error, +// etc. The order will match the order of Errors in the multierror.Error +// at the time of calling. +// +// The resulting error supports errors.As/Is/Unwrap so you can continue +// to use the stdlib errors package to introspect further. +// +// This will perform a shallow copy of the errors slice. Any errors appended +// to this error after calling Unwrap will not be available until a new +// Unwrap is called on the multierror.Error. +func (e *Error) Unwrap() error { + // If we have no errors then we do nothing + if e == nil || len(e.Errors) == 0 { + return nil + } + + // If we have exactly one error, we can just return that directly. + if len(e.Errors) == 1 { + return e.Errors[0] + } + + // Shallow copy the slice + errs := make([]error, len(e.Errors)) + copy(errs, e.Errors) + return chain(errs) +} + +// chain implements the interfaces necessary for errors.Is/As/Unwrap to +// work in a deterministic way with multierror. A chain tracks a list of +// errors while accounting for the current represented error. This lets +// Is/As be meaningful. +// +// Unwrap returns the next error. In the cleanest form, Unwrap would return +// the wrapped error here but we can't do that if we want to properly +// get access to all the errors. Instead, users are recommended to use +// Is/As to get the correct error type out. +// +// Precondition: []error is non-empty (len > 0) +type chain []error + +// Error implements the error interface +func (e chain) Error() string { + return e[0].Error() +} + +// Unwrap implements errors.Unwrap by returning the next error in the +// chain or nil if there are no more errors. +func (e chain) Unwrap() error { + if len(e) == 1 { + return nil + } + + return e[1:] +} + +// As implements errors.As by attempting to map to the current value. +func (e chain) As(target interface{}) bool { + return errors.As(e[0], target) +} + +// Is implements errors.Is by comparing the current value directly. +func (e chain) Is(target error) bool { + return errors.Is(e[0], target) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index f6ae76321..85a98c404 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -30,7 +30,7 @@ github.com/google/go-cmp/cmp/internal/function github.com/google/go-cmp/cmp/internal/value # github.com/hashicorp/errwrap v1.0.0 github.com/hashicorp/errwrap -# github.com/hashicorp/go-multierror v1.0.0 +# github.com/hashicorp/go-multierror v1.1.0 github.com/hashicorp/go-multierror # github.com/hashicorp/go-version v1.2.0 github.com/hashicorp/go-version