diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index 8f3b04ac..f3c20a6c 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -68,6 +68,18 @@ Or if left empty This setting should be deprecated once the language server supports multiple workspaces, as this arises in VS code because a server instance is started per VS Code workspace. +## `ignoreDirectoryNames` (`[]string`) + +This allows excluding directories from being indexed upon initialization by passing a list of directory names. + +The following list of directories will always be ignored: + +- `.git` +- `.idea` +- `.vscode` +- `terraform.tfstate.d` +- `.terragrunt-cache` + ## `experimentalFeatures` (object) This object contains inner settings used to opt into experimental features not yet ready to be on by default. diff --git a/internal/langserver/handlers/initialize.go b/internal/langserver/handlers/initialize.go index 5af34ab0..74520d17 100644 --- a/internal/langserver/handlers/initialize.go +++ b/internal/langserver/handlers/initialize.go @@ -59,6 +59,7 @@ func (svc *service) Initialize(ctx context.Context, params lsp.InitializeParams) "options.rootModulePaths": false, "options.excludeModulePaths": false, "options.commandPrefix": false, + "options.ignoreDirectoryNames": false, "options.experimentalFeatures.validateOnSave": false, "options.terraformExecPath": false, "options.terraformExecTimeout": "", @@ -123,6 +124,7 @@ func (svc *service) Initialize(ctx context.Context, params lsp.InitializeParams) properties["options.rootModulePaths"] = len(out.Options.ModulePaths) > 0 properties["options.excludeModulePaths"] = len(out.Options.ExcludeModulePaths) > 0 properties["options.commandPrefix"] = len(out.Options.CommandPrefix) > 0 + properties["options.ignoreDirectoryNames"] = len(out.Options.IgnoreDirectoryNames) > 0 properties["options.experimentalFeatures.prefillRequiredFields"] = out.Options.ExperimentalFeatures.PrefillRequiredFields properties["options.experimentalFeatures.validateOnSave"] = out.Options.ExperimentalFeatures.ValidateOnSave properties["options.terraformExecPath"] = len(out.Options.TerraformExecPath) > 0 @@ -192,6 +194,7 @@ func (svc *service) Initialize(ctx context.Context, params lsp.InitializeParams) excludeModulePaths = append(excludeModulePaths, modPath) } + svc.walker.SetIgnoreDirectoryNames(cfgOpts.IgnoreDirectoryNames) svc.walker.SetExcludeModulePaths(excludeModulePaths) svc.walker.EnqueuePath(fh.Dir()) diff --git a/internal/langserver/handlers/initialize_test.go b/internal/langserver/handlers/initialize_test.go index 7c661c5e..6d4d0e20 100644 --- a/internal/langserver/handlers/initialize_test.go +++ b/internal/langserver/handlers/initialize_test.go @@ -2,6 +2,7 @@ package handlers import ( "fmt" + "path/filepath" "testing" "github.com/creachadair/jrpc2/code" @@ -119,3 +120,42 @@ func TestInitialize_multipleFolders(t *testing.T) { ] }`, rootDir.URI(), rootDir.URI())}) } + +func TestInitialize_ignoreDirectoryNames(t *testing.T) { + tmpDir := TempDir(t, "plugin", "ignore") + pluginDir := filepath.Join(tmpDir.Dir(), "plugin") + emptyDir := filepath.Join(tmpDir.Dir(), "ignore") + + InitPluginCache(t, pluginDir) + InitPluginCache(t, emptyDir) + + ls := langserver.NewLangServerMock(t, NewMockSession(&MockSessionInput{ + TerraformCalls: &exec.TerraformMockCalls{ + PerWorkDir: map[string][]*mock.Call{ + pluginDir: validTfMockCalls(), + emptyDir: { + // TODO! improve mock and remove entry for `emptyDir` here afterwards + { + Method: "GetExecPath", + Repeatability: 1, + ReturnArguments: []interface{}{ + "", + }, + }, + }, + }, + }})) + stop := ls.Start(t) + defer stop() + + ls.Call(t, &langserver.CallRequest{ + Method: "initialize", + ReqParams: fmt.Sprintf(`{ + "capabilities": {}, + "rootUri": %q, + "processId": 12345, + "initializationOptions": { + "ignoreDirectoryNames": [%q] + } + }`, tmpDir.URI(), "ignore")}) +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 5e6c03e8..9829ee89 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -4,7 +4,9 @@ import ( "fmt" "os" "path/filepath" + "strings" + "github.com/hashicorp/terraform-ls/internal/terraform/datadir" "github.com/mitchellh/mapstructure" ) @@ -15,9 +17,10 @@ type ExperimentalFeatures struct { type Options struct { // ModulePaths describes a list of absolute paths to modules to load - ModulePaths []string `mapstructure:"rootModulePaths"` - ExcludeModulePaths []string `mapstructure:"excludeModulePaths"` - CommandPrefix string `mapstructure:"commandPrefix"` + ModulePaths []string `mapstructure:"rootModulePaths"` + ExcludeModulePaths []string `mapstructure:"excludeModulePaths"` + CommandPrefix string `mapstructure:"commandPrefix"` + IgnoreDirectoryNames []string `mapstructure:"ignoreDirectoryNames"` // ExperimentalFeatures encapsulates experimental features users can opt into. ExperimentalFeatures ExperimentalFeatures `mapstructure:"experimentalFeatures"` @@ -46,6 +49,18 @@ func (o *Options) Validate() error { } } + if len(o.IgnoreDirectoryNames) > 0 { + for _, directory := range o.IgnoreDirectoryNames { + if directory == datadir.DataDirName { + return fmt.Errorf("cannot ignore directory %q", datadir.DataDirName) + } + + if strings.Contains(directory, string(filepath.Separator)) { + return fmt.Errorf("expected directory name, got a path: %q", directory) + } + } + } + return nil } diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index 6ae8b527..9c267ac8 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -1,9 +1,12 @@ package settings import ( + "fmt" + "path/filepath" "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-ls/internal/terraform/datadir" ) func TestDecodeOptions_nil(t *testing.T) { @@ -40,3 +43,40 @@ func TestDecodeOptions_success(t *testing.T) { t.Fatalf("options mismatch: %s", diff) } } + +func TestValidate_IgnoreDirectoryNames_error(t *testing.T) { + tables := []struct { + input string + result string + }{ + {datadir.DataDirName, `cannot ignore directory ".terraform"`}, + {filepath.Join("path", "path"), fmt.Sprintf(`expected directory name, got a path: %q`, filepath.Join("path", "path"))}, + } + + for _, table := range tables { + out, err := DecodeOptions(map[string]interface{}{ + "ignoreDirectoryNames": []string{table.input}, + }) + if err != nil { + t.Fatal(err) + } + + result := out.Options.Validate() + if result.Error() != table.result { + t.Fatalf("expected error: %s, got: %s", table.result, result) + } + } +} +func TestValidate_IgnoreDirectoryNames_success(t *testing.T) { + out, err := DecodeOptions(map[string]interface{}{ + "ignoreDirectoryNames": []string{"directory"}, + }) + if err != nil { + t.Fatal(err) + } + + result := out.Options.Validate() + if result != nil { + t.Fatalf("did not expect error: %s", result) + } +} diff --git a/internal/terraform/module/walker.go b/internal/terraform/module/walker.go index 38bbf4ff..7e892539 100644 --- a/internal/terraform/module/walker.go +++ b/internal/terraform/module/walker.go @@ -21,6 +21,8 @@ var ( // skipDirNames represent directory names which would never contain // plugin/module cache, so it's safe to skip them during the walk + // + // please keep the list in `SETTINGS.md` in sync skipDirNames = map[string]bool{ ".git": true, ".idea": true, @@ -48,7 +50,8 @@ type Walker struct { cancelFunc context.CancelFunc doneCh <-chan struct{} - excludeModulePaths map[string]bool + excludeModulePaths map[string]bool + ignoreDirectoryNames map[string]bool } // queueCap represents channel buffer size @@ -58,14 +61,15 @@ const queueCap = 50 func NewWalker(fs filesystem.Filesystem, modMgr ModuleManager) *Walker { return &Walker{ - fs: fs, - modMgr: modMgr, - logger: discardLogger, - walkingMu: &sync.RWMutex{}, - queue: newWalkerQueue(fs), - queueMu: &sync.Mutex{}, - pushChan: make(chan struct{}, queueCap), - doneCh: make(chan struct{}, 0), + fs: fs, + modMgr: modMgr, + logger: discardLogger, + walkingMu: &sync.RWMutex{}, + queue: newWalkerQueue(fs), + queueMu: &sync.Mutex{}, + pushChan: make(chan struct{}, queueCap), + doneCh: make(chan struct{}, 0), + ignoreDirectoryNames: skipDirNames, } } @@ -84,6 +88,12 @@ func (w *Walker) SetExcludeModulePaths(excludeModulePaths []string) { } } +func (w *Walker) SetIgnoreDirectoryNames(ignoreDirectoryNames []string) { + for _, path := range ignoreDirectoryNames { + w.ignoreDirectoryNames[path] = true + } +} + func (w *Walker) Stop() { if w.cancelFunc != nil { w.cancelFunc() @@ -199,6 +209,11 @@ func (w *Walker) IsWalking() bool { return w.walking } +func (w *Walker) isSkippableDir(dirName string) bool { + _, ok := w.ignoreDirectoryNames[dirName] + return ok +} + func (w *Walker) walk(ctx context.Context, rootPath string) error { // We ignore the passed FS and instead read straight from OS FS // because that would require reimplementing filepath.Walk and @@ -221,6 +236,11 @@ func (w *Walker) walk(ctx context.Context, rootPath string) error { return err } + if w.isSkippableDir(info.Name()) { + w.logger.Printf("skipping %s", path) + return filepath.SkipDir + } + if _, ok := w.excludeModulePaths[dir]; ok { return filepath.SkipDir } @@ -272,18 +292,8 @@ func (w *Walker) walk(ctx context.Context, rootPath string) error { return nil } - if isSkippableDir(info.Name()) { - w.logger.Printf("skipping %s", path) - return filepath.SkipDir - } - return nil }) w.logger.Printf("walking of %s finished", rootPath) return err } - -func isSkippableDir(dirName string) bool { - _, ok := skipDirNames[dirName] - return ok -}