Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Allow fixing directory structure not matching package paths #1035

Merged
merged 1 commit into from
Sep 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ linters:
- rowserrcheck
- wastedassign
# annoying
- gocyclo
- tagliatelle
- nestif
- gocognit
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,7 @@ The Regal language server currently supports the following LSP features:
- [x] [use-rego-v1](https://docs.styra.com/regal/rules/imports/use-rego-v1)
- [x] [use-assignment-operator](https://docs.styra.com/regal/rules/style/use-assignment-operator)
- [x] [no-whitespace-comment](https://docs.styra.com/regal/rules/style/no-whitespace-comment)
- [x] [directory-package-mismatch](https://docs.styra.com/regal/rules/idiomatic/directory-package-mismatch)
- [x] [Code lenses](https://docs.styra.com/regal/language-server#code-lenses-evaluation)
(click to evaluate any package or rule directly in the editor)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,19 @@ import rego.v1
import data.regal.config
import data.regal.result

# METADATA
# - METADATA
# description: |
# emit warning notice when package has more parts than the directory,
# as this should likely **not** fail
notices contains _notice(message, "warning") if {
count(_file_path_values) > 0
count(_file_path_values) < count(_pkg_path_values)

message := sprintf(
"package '%s' has more parts than provided directory path '%s'",
[concat(".", _pkg_path_values), concat("/", _file_path_values)],
)
}

# METADATA
# description: emit notice when single file is provided, but with no severity
notices contains _notice(message, "none") if {
count(_file_path_values) == 0
# # as this should likely **not** fail
# notices contains _notice(message, "warning") if {
# count(_file_path_values) > 0
# count(_file_path_values) < count(_pkg_path_values)

message := "provided file has no directory components in its path... try linting a directory"
}
# message := sprintf(
# "package '%s' has more parts than provided directory path '%s'",
# [concat(".", _pkg_path_values), concat("/", _file_path_values)],
# )
# }

report contains violation if {
# get the last n components from file path, where n == count(_pkg_path_values)
Expand All @@ -49,10 +41,10 @@ _pkg_path := [p.value |
i > 0
]

_pkg_path_values := without_test_suffix if {
cfg := config.for_rule("idiomatic", "directory-package-mismatch")
_pkg_path_values := _pkg_path if not config.for_rule("idiomatic", "directory-package-mismatch")["exclude-test-suffix"]

cfg["exclude-test-suffix"]
_pkg_path_values := without_test_suffix if {
config.for_rule("idiomatic", "directory-package-mismatch")["exclude-test-suffix"]

without_test_suffix := array.concat(
array.slice(_pkg_path, 0, count(_pkg_path) - 1),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,32 +36,6 @@ test_success_directory_structure_package_path_match_shorter_directory_path if {
r == set()
}

test_notice_severity_warning_when_directory_path_shorter_than_package_path if {
module := regal.parse_module("bar/baz/p.rego", "package foo.bar.baz")
r := rule.notices with input as module with config.for_rule as default_config

r == {{
"category": "idiomatic",
"description": "package 'foo.bar.baz' has more parts than provided directory path 'bar/baz'",
"level": "notice",
"severity": "warning",
"title": "directory-package-mismatch",
}}
}

test_notice_severity_none_when_no_path_likely_single_file_provided if {
module := regal.parse_module("p.rego", "package p")
r := rule.notices with input as module with config.for_rule as default_config

r == {{
"category": "idiomatic",
"description": "provided file has no directory components in its path... try linting a directory",
"level": "notice",
"severity": "none",
"title": "directory-package-mismatch",
}}
}

with_location(location) := {{
"category": "idiomatic",
"description": "Directory structure should mirror package",
Expand Down
6 changes: 6 additions & 0 deletions cmd/fix.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,13 @@ func fix(args []string, params *fixCommandParams) error {
log.Println("no user-provided config file found, will use the default config")
}

roots, err := config.GetPotentialRoots(args...)
if err != nil {
return fmt.Errorf("could not find potential roots: %w", err)
}

f := fixer.NewFixer()
f.RegisterRoots(roots...)
f.RegisterFixes(fixes.NewDefaultFixes()...)
f.RegisterMandatoryFixes(
&fixes.Fmt{
Expand Down
38 changes: 22 additions & 16 deletions e2e/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,7 @@ func TestFix(t *testing.T) {
stderr := bytes.Buffer{}
td := t.TempDir()

// note the package name, and how it will be used to move the file to a corresponding directory
unformattedContents := []byte(`package wow

import rego.v1
Expand All @@ -788,26 +789,22 @@ allow if {
true
}
`)
err := os.WriteFile(filepath.Join(td, "main.rego"), unformattedContents, 0o644)
if err != nil {
t.Fatalf("failed to write main.rego: %v", err)
}

unrelatedFileContents := []byte(`foobar`)
err = os.WriteFile(filepath.Join(td, "unrelated.txt"), unrelatedFileContents, 0o644)
if err != nil {
t.Fatalf("failed to write unrelated.txt: %v", err)
}

err = regal(&stdout, &stderr)("fix", td)
mustWriteToFile(t, filepath.Join(td, "main.rego"), string(unformattedContents))
mustWriteToFile(t, filepath.Join(td, "unrelated.txt"), string(unrelatedFileContents))

err := regal(&stdout, &stderr)("fix", td)

// 0 exit status is expected as all violations should have been fixed
expectExitCode(t, err, 0, &stdout, &stderr)

exp := fmt.Sprintf(`2 fixes applied:
%s/main.rego:
- no-whitespace-comment
exp := fmt.Sprintf(`3 fixes applied:
In project root: %s
main.rego -> wow/main.rego:
- use-rego-v1
- directory-package-mismatch
- no-whitespace-comment
`, td)

if act := stdout.String(); exp != act {
Expand All @@ -818,10 +815,10 @@ allow if {
t.Errorf("expected stderr %q, got %q", exp, act)
}

// check that the file was formatted
bs, err := os.ReadFile(filepath.Join(td, "main.rego"))
// check that the (now moved) file was formatted
bs, err := os.ReadFile(filepath.Join(td, "wow/main.rego"))
if err != nil {
t.Fatalf("failed to read main.rego: %v", err)
t.Fatalf("failed to read wow/main.rego: %v", err)
}

expectedContent := `package wow
Expand Down Expand Up @@ -905,3 +902,12 @@ func expectExitCode(t *testing.T, err error, exp int, stdout *bytes.Buffer, stde
exp, act, stdout.String(), stderr.String())
}
}

func mustWriteToFile(t *testing.T, path string, content string) {
t.Helper()

err := os.WriteFile(path, []byte(content), 0o644)
if err != nil {
t.Fatalf("failed to write to %s: %v", path, err)
}
}
50 changes: 40 additions & 10 deletions internal/io/io.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,6 @@ func MustLoadRegalBundleFS(fs files.FS) bundle.Bundle {
return regalBundle
}

// MustLoadRegalBundlePath loads bundle from path, exit on failure.
func MustLoadRegalBundlePath(path string) bundle.Bundle {
regalBundle, err := LoadRegalBundlePath(path)
if err != nil {
log.Fatal(err)
}

return regalBundle
}

// ToMap convert any value to map[string]any, or panics on failure.
func ToMap(a any) map[string]any {
r := make(map[string]any)
Expand Down Expand Up @@ -122,3 +112,43 @@ func FindInput(file string, workspacePath string) (string, io.Reader) {

return "", nil
}

func IsSkipWalkDirectory(info files.DirEntry) bool {
return info.IsDir() && (info.Name() == ".git" || info.Name() == ".idea" || info.Name() == "node_modules")
}

// WalkFiles walks the file system rooted at root, calling f for each file. This is
// a less ceremonious version of filepath.WalkDir where only file paths (not dirs)
// are passed to the callback, and where directories that should commonly be ignored
// (.git, node_modules, etc.) are skipped.
func WalkFiles(root string, f func(path string) error) error {
return filepath.WalkDir(root, func(path string, info os.DirEntry, _ error) error { // nolint:wrapcheck
if IsSkipWalkDirectory(info) {
return filepath.SkipDir
}

if info.IsDir() {
return nil
}

return f(path)
})
}

func FindManifestLocations(root string) ([]string, error) {
var foundBundleRoots []string

if err := WalkFiles(root, func(path string) error {
if filepath.Base(path) == ".manifest" {
if rel, err := filepath.Rel(root, path); err == nil {
foundBundleRoots = append(foundBundleRoots, filepath.Dir(rel))
}
}

return nil
}); err != nil {
return nil, fmt.Errorf("failed to walk workspace path: %w", err)
}

return foundBundleRoots, nil
}
32 changes: 32 additions & 0 deletions internal/io/io_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package io

import (
"slices"
"testing"

"github.com/open-policy-agent/opa/util/test"
)

func TestJSONRoundTrip(t *testing.T) {
Expand All @@ -22,3 +25,32 @@ func TestJSONRoundTrip(t *testing.T) {
t.Errorf("expected JSON roundtrip to set struct value")
}
}

func TestFindManifestLocations(t *testing.T) {
t.Parallel()

fs := map[string]string{
"/.git": "",
"/foo/bar/baz/.manifest": "",
"/foo/bar/qux/.manifest": "",
"/foo/bar/.regal/.manifest.yaml": "",
"/node_modules/.manifest": "",
}

test.WithTempFS(fs, func(root string) {
locations, err := FindManifestLocations(root)
if err != nil {
t.Error(err)
}

if len(locations) != 2 {
t.Errorf("expected 2 locations, got %d", len(locations))
}

expected := []string{"foo/bar/baz", "foo/bar/qux"}

if !slices.Equal(locations, expected) {
t.Errorf("expected %v, got %v", expected, locations)
}
})
}
22 changes: 2 additions & 20 deletions internal/lsp/bundles/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/open-policy-agent/opa/bundle"

rio "github.com/styrainc/regal/internal/io"
"github.com/styrainc/regal/internal/util"
)

Expand Down Expand Up @@ -56,26 +57,7 @@ func (c *Cache) Refresh() ([]string, error) {
}

// find all the bundle roots that are currently present on disk
var foundBundleRoots []string

err := filepath.Walk(c.workspacePath, func(path string, info os.FileInfo, _ error) error {
if info.IsDir() && (info.Name() == ".git" || info.Name() == ".idea" || info.Name() == "node_modules") {
return filepath.SkipDir
}

if info.IsDir() {
return nil
}

if filepath.Base(path) == ".manifest" {
foundBundleRoots = append(
foundBundleRoots,
strings.TrimPrefix(filepath.Dir(path), c.workspacePath),
)
}

return nil
})
foundBundleRoots, err := rio.FindManifestLocations(c.workspacePath)
if err != nil {
return nil, fmt.Errorf("failed to walk workspace path: %w", err)
}
Expand Down
9 changes: 9 additions & 0 deletions internal/lsp/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,12 @@ func NoWhiteSpaceCommentCommand(args []string) types.Command {
Arguments: toAnySlice(args),
}
}

func DirectoryStructureMismatchCommand(args []string) types.Command {
return types.Command{
Title: "Fix directory structure / package path mismatch",
Command: "regal.fix.directory-package-mismatch",
Tooltip: "Fix directory structure / package path mismatch",
Arguments: toAnySlice(args),
}
}
Loading