From 59866384f5a66b03afac82b8709b3a39863cedf0 Mon Sep 17 00:00:00 2001 From: Charlie Egan Date: Wed, 15 Jan 2025 16:24:39 +0000 Subject: [PATCH] config: Allow loading of .regal.yaml file too (#1339) * config: Allow loading of .regal.yaml file too Fixes https://github.com/StyraInc/regal/issues/1288 Signed-off-by: Charlie Egan * config: Choose most specific file Signed-off-by: Charlie Egan * Markdown line length lint * / -> or --------- Signed-off-by: Charlie Egan --- README.md | 15 +++-- cmd/languageserver.go | 2 +- docs/remote-features.md | 2 +- docs/rules/imports/use-rego-v1.md | 2 +- internal/lsp/server.go | 3 + pkg/config/config.go | 93 ++++++++++++++++++++------- pkg/config/config_test.go | 102 ++++++++++++++++++++++++------ 7 files changed, 166 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index d857f0f8..58721163 100644 --- a/README.md +++ b/README.md @@ -342,7 +342,7 @@ levels are available: Additionally, some rules may have configuration options of their own. See the documentation page for a rule to learn more about it. -**.regal/config.yaml** +**.regal/config.yaml** or **.regal.yaml** ```yaml rules: style: @@ -400,12 +400,15 @@ project: rego-version: 0 ``` -Regal will automatically search for a configuration file (`.regal/config.yaml`) in the current directory, and if not -found, traverse the parent directories either until either one is found, or the top of the directory hierarchy is -reached. If no configuration file is found, Regal will use the default configuration. +Regal will automatically search for a configuration file (`.regal/config.yaml` +or `.regal.yaml`) in the current directory, and if not found, traverse the +parent directories either until either one is found, or the top of the directory +hierarchy is reached. If no configuration file is found, Regal will use the +default configuration. -A custom configuration may be also be provided using the `--config-file`/`-c` option for `regal lint`, which when -provided will be used to override the default configuration. +A custom configuration may be also be provided using the `--config-file`/`-c` +option for `regal lint`, which when provided will be used to override the +default configuration. ## Ignoring Rules diff --git a/cmd/languageserver.go b/cmd/languageserver.go index d580b40c..e60e1ba8 100644 --- a/cmd/languageserver.go +++ b/cmd/languageserver.go @@ -38,7 +38,7 @@ func init() { } else { fmt.Fprintf( os.Stderr, - "Regal Language Server (path: %s, version: %s)", + "Regal Language Server (path: %s, version: %s)\n", absPath, cmp.Or(version.Version, "Unknown"), ) diff --git a/docs/remote-features.md b/docs/remote-features.md index b8d11d64..69faaf8d 100644 --- a/docs/remote-features.md +++ b/docs/remote-features.md @@ -23,5 +23,5 @@ GitHub API rate limits when using Regal. This functionality can be disabled in two ways: -* Using `.regal/config.yaml`: set `features.remote.check-version` to `false`. +* Using `.regal/config.yaml` / `.regal.yaml`: set `features.remote.check-version` to `false`. * Using an environment variable: set `REGAL_DISABLE_CHECK_VERSION` to `true`. diff --git a/docs/rules/imports/use-rego-v1.md b/docs/rules/imports/use-rego-v1.md index ca621c95..6f8d0b05 100644 --- a/docs/rules/imports/use-rego-v1.md +++ b/docs/rules/imports/use-rego-v1.md @@ -71,7 +71,7 @@ been disabled due to missing capabilities, kindly reminding you of them, but wit In the example below we're using the capabilities setting to target OPA v0.55.0 (where `import rego.v1` is not available): -**.regal/config.yaml** +**.regal/config.yaml** or **.regal.yaml** ```yaml capabilities: from: diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 2084c3f7..f13c8ae7 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -2526,7 +2526,10 @@ func (l *LanguageServer) handleInitialize( configFile, err := config.FindConfig(workspaceRootPath) if err == nil { + l.logf(log.LevelMessage, "using config file: %s", configFile.Name()) l.configWatcher.Watch(configFile.Name()) + } else { + l.logf(log.LevelMessage, "no config file found in workspace: %s", err) } if _, err = l.loadWorkspaceContents(ctx, false); err != nil { diff --git a/pkg/config/config.go b/pkg/config/config.go index 7b08074b..50eef99c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -118,20 +118,66 @@ type Builtin struct { } const ( - regalDirName = ".regal" - configFileName = "config.yaml" + regalDirName = ".regal" + configFileName = "config.yaml" + standaloneConfigFileName = ".regal.yaml" ) -// FindRegalDirectory searches for a .regal directory first in the directory of path, and if not found, -// in the parent directory, and if not found, in the parent's parent directory, and so on. -func FindRegalDirectory(path string) (*os.File, error) { +// FindConfig attempts to find either the .regal directory or .regal.yaml +// config file, and returns the appropriate file or an error. +func FindConfig(path string) (*os.File, error) { + regalDir, regalDirError := FindRegalDirectory(path) + regalConfigFile, regalConfigFileError := FindRegalConfigFile(path) + + var regalDirParent, regalConfigFileParent string + if regalDirError == nil && regalConfigFileError == nil { + regalDirParent = filepath.Dir(regalDir.Name()) + regalConfigFileParent = filepath.Dir(regalConfigFile.Name()) + + if regalDirParent == regalConfigFileParent { + return nil, errors.New("conflicting config files: both .regal directory and .regal.yaml found") + } + } + + if regalDirError != nil && regalConfigFileError != nil { + return nil, errors.New("could not find Regal config") + } + + // if the config file parent is not "", then it was found, and if it's + // longer then it's more specific to the search path in question, and so we + // return that here in such cases. + if len(regalConfigFileParent) > len(regalDirParent) { + return regalConfigFile, nil + } + + // if there is a .regal directory, when a config file is expected to be + // found inside. + if regalDirError == nil { + expectedConfigFilePath := filepath.Join(regalDir.Name(), rio.PathSeparator, configFileName) + + _, err := os.Stat(expectedConfigFilePath) + if err != nil && os.IsNotExist(err) { + return nil, errors.New("config file was not found in .regal directory") + } else if err != nil { + return nil, fmt.Errorf("failed to stat .regal config file: %w", err) + } + + return os.Open(expectedConfigFilePath) //nolint:wrapcheck + } + + // regalConfigFileError is nil at this point, so we can return the file + return regalConfigFile, nil +} + +// findUpwards searches for a file or directory matching the given name, +// starting from the provided path and moving upwards. +func findUpwards(path, name string, expectDir bool) (*os.File, error) { finfo, err := os.Stat(path) if err != nil { return nil, fmt.Errorf("failed to stat path %v: %w", path, err) } dir := path - if !finfo.IsDir() { dir = filepath.Dir(path) } @@ -142,33 +188,31 @@ func FindRegalDirectory(path string) (*os.File, error) { for { var searchPath string if volume == "" { - searchPath = filepath.Join(rio.PathSeparator, dir, regalDirName) + searchPath = filepath.Join(rio.PathSeparator, dir, name) } else { - searchPath = filepath.Join(dir, regalDirName) + searchPath = filepath.Join(dir, name) } - regalDir, err := os.Open(searchPath) + file, err := os.Open(searchPath) if err == nil { - rdInfo, err := regalDir.Stat() - if err == nil && rdInfo.IsDir() { - return regalDir, nil + fileInfo, err := file.Stat() + if err == nil && fileInfo.IsDir() == expectDir { + return file, nil } } - if searchPath == volume+rio.PathSeparator+regalDirName { + if searchPath == volume+rio.PathSeparator+name { // Stop traversing at the root path return nil, fmt.Errorf("can't traverse past root directory %w", err) } // Move up one level in the directory tree parts := strings.Split(dir, rio.PathSeparator) - if len(parts) < 2 { return nil, errors.New("stopping as dir is root directory") } parts = parts[:len(parts)-1] - if parts[0] == volume { parts[0] = volume + rio.PathSeparator } @@ -177,6 +221,16 @@ func FindRegalDirectory(path string) (*os.File, error) { } } +// FindRegalDirectory searches for a .regal directory upwards from the provided path. +func FindRegalDirectory(path string) (*os.File, error) { + return findUpwards(path, regalDirName, true) +} + +// FindRegalConfigFile searches for a .regal.yaml config file upwards from the provided path. +func FindRegalConfigFile(path string) (*os.File, error) { + return findUpwards(path, standaloneConfigFileName, false) +} + // FindBundleRootDirectories finds all bundle root directories from the provided path, // which **must** be an absolute path. Bundle root directories may be found either by: // @@ -285,15 +339,6 @@ func rootsFromRegalDirectory(regalDir *os.File) ([]string, error) { return foundBundleRoots, nil } -func FindConfig(path string) (*os.File, error) { - regalDir, err := FindRegalDirectory(path) - if err != nil { - return nil, fmt.Errorf("could not find .regal directory: %w", err) - } - - return os.Open(filepath.Join(regalDir.Name(), rio.PathSeparator, configFileName)) //nolint:wrapcheck -} - func FromMap(confMap map[string]any) (Config, error) { var conf Config diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 0d4dc27a..0596d68e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "slices" + "strings" "testing" "gopkg.in/yaml.v3" @@ -54,32 +55,93 @@ func TestFindRegalDirectory(t *testing.T) { func TestFindConfig(t *testing.T) { t.Parallel() - fs := map[string]string{ - "/foo/bar/baz/p.rego": "", - "/foo/bar/.regal/config.yaml": "", + testCases := map[string]struct { + FS map[string]string + Error string + ExpectedName string + }{ + "no config file": { + FS: map[string]string{ + "/foo/bar/baz/p.rego": "", + "/foo/bar/bax.json": "", + }, + Error: "could not find Regal config", + }, + ".regal/config.yaml": { + FS: map[string]string{ + "/foo/bar/baz/p.rego": "", + "/foo/bar/.regal/config.yaml": "", + }, + ExpectedName: "/foo/bar/.regal/config.yaml", + }, + ".regal/ dir missing config file": { + FS: map[string]string{ + "/foo/bar/baz/p.rego": "", + "/foo/bar/.regal/.keep": "", // .keep file to ensure the dir is present + }, + Error: "config file was not found in .regal directory", + }, + ".regal.yaml": { + FS: map[string]string{ + "/foo/bar/baz/p.rego": "", + "/foo/bar/.regal.yaml": "", + }, + ExpectedName: "/foo/bar/.regal.yaml", + }, + ".regal.yaml and .regal/config.yaml": { + FS: map[string]string{ + "/foo/bar/baz/p.rego": "", + "/foo/bar/.regal.yaml": "", + "/foo/bar/.regal/config.yaml": "", + }, + Error: "conflicting config files: both .regal directory and .regal.yaml found", + }, + ".regal.yaml with .regal/config.yaml at higher directory": { + FS: map[string]string{ + "/foo/bar/baz/p.rego": "", + "/foo/bar/.regal.yaml": "", + "/.regal/config.yaml": "", + }, + ExpectedName: "/foo/bar/.regal.yaml", + }, + ".regal/config.yaml with .regal.yaml at higher directory": { + FS: map[string]string{ + "/foo/bar/baz/p.rego": "", + "/foo/bar/.regal/config.yaml": "", + "/.regal.yaml": "", + }, + ExpectedName: "/foo/bar/.regal/config.yaml", + }, } - test.WithTempFS(fs, func(root string) { - path := filepath.Join(root, "/foo/bar/baz") + for testName, testData := range testCases { + t.Run(testName, func(t *testing.T) { + t.Parallel() - if _, err := FindConfig(path); err != nil { - t.Error(err) - } - }) + test.WithTempFS(testData.FS, func(root string) { + path := filepath.Join(root, "/foo/bar/baz") - fs = map[string]string{ - "/foo/bar/baz/p.rego": "", - "/foo/bar/bax.json": "", - } + configFile, err := FindConfig(path) + if testData.Error != "" { + if err == nil { + t.Fatalf("expected error %s, got nil", testData.Error) + } - test.WithTempFS(fs, func(root string) { - path := filepath.Join(root, "/foo/bar/baz") + if !strings.Contains(err.Error(), testData.Error) { + t.Fatalf("expected error %q, got %q", testData.Error, err.Error()) + } + } else if err != nil { + t.Fatalf("expected no error, got %s", err) + } - _, err := FindConfig(path) - if err == nil { - t.Errorf("expected no config file to be found") - } - }) + if testData.ExpectedName != "" { + if got, exp := strings.TrimPrefix(configFile.Name(), root), testData.ExpectedName; got != exp { + t.Fatalf("expected config file %q, got %q", exp, got) + } + } + }) + }) + } } func TestFindBundleRootDirectories(t *testing.T) {