From 1137a6db193709bc9bc692dec66cb3b1925ee9b5 Mon Sep 17 00:00:00 2001 From: Charlie Egan Date: Tue, 10 Sep 2024 14:10:04 +0100 Subject: [PATCH] lsp: Include deleting dirs as part of wksp changes Signed-off-by: Charlie Egan --- internal/lsp/server.go | 34 +++++++++ internal/lsp/server_template_test.go | 12 +++- internal/lsp/types/types.go | 11 +++ internal/util/util.go | 86 +++++++++++++++++++++++ internal/util/util_test.go | 101 ++++++++++++++++++++++++++- 5 files changed, 241 insertions(+), 3 deletions(-) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 9a5795be..3516880b 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -747,6 +747,40 @@ func (l *LanguageServer) StartTemplateWorker(ctx context.Context) { edits = append(edits, renameParams.Edit.DocumentChanges[0]) } + // check if there are any dirs to clean + if len(renameParams.Edit.DocumentChanges) > 0 { + dirs, err := util.DirCleanUpPaths( + uri.ToPath(l.clientIdentifier, renameParams.Edit.DocumentChanges[0].OldURI), + []string{ + // stop at the root + l.workspacePath(), + // also preserve any dirs needed for the new file + uri.ToPath(l.clientIdentifier, renameParams.Edit.DocumentChanges[0].NewURI), + }, + ) + if err != nil { + l.logError(fmt.Errorf("failed to delete empty directories: %w", err)) + + continue + } + + for _, dir := range dirs { + edits = append( + edits, + types.DeleteFile{ + Kind: "delete", + URI: uri.FromPath(l.clientIdentifier, dir), + Options: &types.DeleteFileOptions{ + Recursive: true, + IgnoreIfNotExists: true, + }, + }, + ) + } + + l.cache.Delete(renameParams.Edit.DocumentChanges[0].OldURI) + } + err = l.conn.Call(ctx, methodWorkspaceApplyEdit, map[string]any{ "label": "Template new Rego file", "edit": map[string]any{ diff --git a/internal/lsp/server_template_test.go b/internal/lsp/server_template_test.go index 22af8e95..b8921c41 100644 --- a/internal/lsp/server_template_test.go +++ b/internal/lsp/server_template_test.go @@ -242,7 +242,7 @@ func TestNewFileTemplating(t *testing.T) { } // 6. Validate that the client received a workspace edit - timeout = time.NewTimer(defaultTimeout) + timeout = time.NewTimer(3 * time.Second) defer timeout.Stop() expectedMessage := fmt.Sprintf(`{ @@ -277,11 +277,19 @@ func TestNewFileTemplating(t *testing.T) { "ignoreIfExists": false, "overwrite": false } + }, + { + "kind": "delete", + "options": { + "ignoreIfNotExists": true, + "recursive": true + }, + "uri": "file://%[3]s/foo/bar" } ] }, "label": "Template new Rego file" -}`, newFileURI, expectedNewFileURI) +}`, newFileURI, expectedNewFileURI, tempDir) for { var success bool diff --git a/internal/lsp/types/types.go b/internal/lsp/types/types.go index 1087a032..c96b86c9 100644 --- a/internal/lsp/types/types.go +++ b/internal/lsp/types/types.go @@ -243,6 +243,17 @@ type RenameFile struct { AnnotationIdentifier *string `json:"annotationId,omitempty"` } +type DeleteFileOptions struct { + Recursive bool `json:"recursive"` + IgnoreIfNotExists bool `json:"ignoreIfNotExists"` +} + +type DeleteFile struct { + Kind string `json:"kind"` // must always be "delete" + URI string `json:"uri"` + Options *DeleteFileOptions `json:"options,omitempty"` +} + // WorkspaceRenameEdit is a WorkspaceEdit that is used for renaming files. // Perhaps we should use generics and a union type here instead. type WorkspaceRenameEdit struct { diff --git a/internal/util/util.go b/internal/util/util.go index c5e0d11f..e0e7cd8c 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -159,3 +159,89 @@ func DeleteEmptyDirs(dir string) error { return nil } + +// DirCleanUpPaths will, for a given target file, list all the dirs that would +// be empty if the target file was deleted. +func DirCleanUpPaths(target string, preserve []string) ([]string, error) { + dirs := make([]string, 0) + + preserveDirs := make(map[string]struct{}) + + for _, p := range preserve { + for { + preserveDirs[p] = struct{}{} + + p = filepath.Dir(p) + + if p == "." || p == "/" { + break + } + + if _, ok := preserveDirs[p]; ok { + break + } + } + } + + dir := filepath.Dir(target) + + for { + // check if we reached the preserved dir + _, ok := preserveDirs[dir] + if ok { + break + } + + parts := strings.Split(dir, string(filepath.Separator)) + if len(parts) == 1 { + break + } + + info, err := os.Stat(dir) + if err != nil { + return nil, fmt.Errorf("failed to stat directory %s: %w", dir, err) + } + + if !info.IsDir() { + return nil, fmt.Errorf("expected directory, got file %s", dir) + } + + files, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("failed to read directory %s: %w", dir, err) + } + + empty := true + + for _, file := range files { + // exclude the target + abs := filepath.Join(dir, file.Name()) + if abs == target { + continue + } + + // exclude any other marked dirs + if file.IsDir() && len(dirs) > 0 { + if dirs[len(dirs)-1] == abs { + continue + } + } + + empty = false + + break + } + + if !empty { + break + } + + dirs = append(dirs, dir) + + fmt.Fprintln(os.Stderr, "added", dir) + + dir = filepath.Dir(dir) + } + + return dirs, nil +} diff --git a/internal/util/util_test.go b/internal/util/util_test.go index 66ebbea3..37decfdb 100644 --- a/internal/util/util_test.go +++ b/internal/util/util_test.go @@ -1,6 +1,12 @@ package util -import "testing" +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" +) func TestFindClosestMatchingRoot(t *testing.T) { t.Parallel() @@ -48,3 +54,96 @@ func TestFindClosestMatchingRoot(t *testing.T) { }) } } + +func TestDirCleanUpPaths(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + State map[string]string + DeleteTarget string + AdditionalPreserveTargets []string + Expected []string + }{ + "simple": { + DeleteTarget: "foo/bar.rego", + State: map[string]string{ + "foo/bar.rego": "package foo", + }, + Expected: []string{"foo"}, + }, + "not empty": { + DeleteTarget: "foo/bar.rego", + State: map[string]string{ + "foo/bar.rego": "package foo", + "foo/baz.rego": "package foo", + }, + Expected: []string{}, + }, + "all the way up": { + DeleteTarget: "foo/bar/baz/bax.rego", + State: map[string]string{ + "foo/bar/baz/bax.rego": "package baz", + }, + Expected: []string{"foo/bar/baz", "foo/bar", "foo"}, + }, + "almost all the way up": { + DeleteTarget: "foo/bar/baz/bax.rego", + State: map[string]string{ + "foo/bar/baz/bax.rego": "package baz", + "foo/bax.rego": "package foo", + }, + Expected: []string{"foo/bar/baz", "foo/bar"}, + }, + "with preserve targets": { + DeleteTarget: "foo/bar/baz/bax.rego", + AdditionalPreserveTargets: []string{ + "foo/bar/baz_test/bax.rego", + }, + State: map[string]string{ + "foo/bar/baz/bax.rego": "package baz", + "foo/bax.rego": "package foo", + }, + // foo/bar is not deleted because of the preserve target + Expected: []string{"foo/bar/baz"}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + for k, v := range test.State { + err := os.MkdirAll(filepath.Dir(filepath.Join(tempDir, k)), 0o755) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + err = os.WriteFile(filepath.Join(tempDir, k), []byte(v), 0o600) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + + expected := make([]string, len(test.Expected)) + for i, v := range test.Expected { + expected[i] = filepath.Join(tempDir, v) + } + + additionalPreserveTargets := []string{tempDir} + for i, v := range test.AdditionalPreserveTargets { + additionalPreserveTargets[i] = filepath.Join(tempDir, v) + } + + got, err := DirCleanUpPaths(filepath.Join(tempDir, test.DeleteTarget), additionalPreserveTargets) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !slices.Equal(got, expected) { + t.Fatalf("expected\n%v\ngot:\n%v", strings.Join(expected, "\n"), strings.Join(got, "\n")) + } + }) + } +}