Skip to content

Commit

Permalink
lsp: Include deleting dirs as part of wksp changes
Browse files Browse the repository at this point in the history
Signed-off-by: Charlie Egan <charlie@styra.com>
  • Loading branch information
charlieegan3 committed Sep 10, 2024
1 parent 12b0869 commit 1137a6d
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 3 deletions.
34 changes: 34 additions & 0 deletions internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
12 changes: 10 additions & 2 deletions internal/lsp/server_template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(`{
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions internal/lsp/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
86 changes: 86 additions & 0 deletions internal/util/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
101 changes: 100 additions & 1 deletion internal/util/util_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package util

import "testing"
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)

func TestFindClosestMatchingRoot(t *testing.T) {
t.Parallel()
Expand Down Expand Up @@ -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"))
}
})
}
}

0 comments on commit 1137a6d

Please sign in to comment.