fix(agent): preflight LSP workspace edits
- commit
- d007024a7bbcd93e32591ea9ce46ae3b270c58ff
- author
- brice · · 2026-08-04 12:10 UTC
- committer
- brice · · 2026-08-04 12:56 UTC
- parent
- 9051051
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +49 -8
M packages/agent/lsp/edits.go +26 -8
| @@ -43,26 +43,44 @@ func ApplyWorkspaceEdit(root string, edit WorkspaceEdit) error { | |||
| 43 | 43 | } | |
| 44 | 44 | all[change.TextDocument.URI] = append(all[change.TextDocument.URI], change.Edits...) | |
| 45 | 45 | } | |
| 46 | + | targets, err := prepareEditTargets(rootReal, all) | |
| 47 | + | if err != nil { | |
| 48 | + | return err | |
| 49 | + | } | |
| 50 | + | for _, location := range targets { | |
| 51 | + | if err := applyTextEdits(location.target, location.changes); err != nil { | |
| 52 | + | return fmt.Errorf("apply edit to %s: %w", location.path, err) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | return nil | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | type editTarget struct { | |
| 59 | + | path string | |
| 60 | + | target string | |
| 61 | + | changes []TextEdit | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func prepareEditTargets(rootReal string, all map[string][]TextEdit) ([]editTarget, error) { | |
| 65 | + | targets := make([]editTarget, 0, len(all)) | |
| 46 | 66 | for uri, changes := range all { | |
| 47 | 67 | path, err := uriToPath(uri) | |
| 48 | 68 | if err != nil { | |
| 49 | - | return err | |
| 69 | + | return nil, err | |
| 50 | 70 | } | |
| 51 | 71 | if err := safeWorkspacePath(rootReal, path); err != nil { | |
| 52 | - | return err | |
| 72 | + | return nil, err | |
| 53 | 73 | } | |
| 54 | 74 | target, err := filepath.EvalSymlinks(path) | |
| 55 | 75 | if err != nil { | |
| 56 | - | return fmt.Errorf("workspace edit target %q: %w", path, err) | |
| 76 | + | return nil, fmt.Errorf("workspace edit target %q: %w", path, err) | |
| 57 | 77 | } | |
| 58 | 78 | if err := safeWorkspacePath(rootReal, target); err != nil { | |
| 59 | - | return err | |
| 60 | - | } | |
| 61 | - | if err := applyTextEdits(target, changes); err != nil { | |
| 62 | - | return fmt.Errorf("apply edit to %s: %w", path, err) | |
| 79 | + | return nil, err | |
| 63 | 80 | } | |
| 81 | + | targets = append(targets, editTarget{path: path, target: target, changes: changes}) | |
| 64 | 82 | } | |
| 65 | - | return nil | |
| 83 | + | return targets, nil | |
| 66 | 84 | } | |
| 67 | 85 | ||
| 68 | 86 | func safeWorkspacePath(root, path string) error { | |
| @@ -43,26 +43,44 @@ func ApplyWorkspaceEdit(root string, edit WorkspaceEdit) error { | |||||
| 43 | } | 43 | } | ||
| 44 | all[change.TextDocument.URI] = append(all[change.TextDocument.URI], change.Edits...) | 44 | all[change.TextDocument.URI] = append(all[change.TextDocument.URI], change.Edits...) | ||
| 45 | } | 45 | } | ||
| 46 | + | targets, err := prepareEditTargets(rootReal, all) | |||
| 47 | + | if err != nil { | |||
| 48 | + | return err | |||
| 49 | + | } | |||
| 50 | + | for _, location := range targets { | |||
| 51 | + | if err := applyTextEdits(location.target, location.changes); err != nil { | |||
| 52 | + | return fmt.Errorf("apply edit to %s: %w", location.path, err) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | return nil | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | type editTarget struct { | |||
| 59 | + | path string | |||
| 60 | + | target string | |||
| 61 | + | changes []TextEdit | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func prepareEditTargets(rootReal string, all map[string][]TextEdit) ([]editTarget, error) { | |||
| 65 | + | targets := make([]editTarget, 0, len(all)) | |||
| 46 | for uri, changes := range all { | 66 | for uri, changes := range all { | ||
| 47 | path, err := uriToPath(uri) | 67 | path, err := uriToPath(uri) | ||
| 48 | if err != nil { | 68 | if err != nil { | ||
| 49 | - | return err | 69 | + | return nil, err |
| 50 | } | 70 | } | ||
| 51 | if err := safeWorkspacePath(rootReal, path); err != nil { | 71 | if err := safeWorkspacePath(rootReal, path); err != nil { | ||
| 52 | - | return err | 72 | + | return nil, err |
| 53 | } | 73 | } | ||
| 54 | target, err := filepath.EvalSymlinks(path) | 74 | target, err := filepath.EvalSymlinks(path) | ||
| 55 | if err != nil { | 75 | if err != nil { | ||
| 56 | - | return fmt.Errorf("workspace edit target %q: %w", path, err) | 76 | + | return nil, fmt.Errorf("workspace edit target %q: %w", path, err) |
| 57 | } | 77 | } | ||
| 58 | if err := safeWorkspacePath(rootReal, target); err != nil { | 78 | if err := safeWorkspacePath(rootReal, target); err != nil { | ||
| 59 | - | return err | 79 | + | return nil, err |
| 60 | - | } | |||
| 61 | - | if err := applyTextEdits(target, changes); err != nil { | |||
| 62 | - | return fmt.Errorf("apply edit to %s: %w", path, err) | |||
| 63 | } | 80 | } | ||
| 81 | + | targets = append(targets, editTarget{path: path, target: target, changes: changes}) | |||
| 64 | } | 82 | } | ||
| 65 | - | return nil | 83 | + | return targets, nil |
| 66 | } | 84 | } | ||
| 67 | 85 | ||||
| 68 | func safeWorkspacePath(root, path string) error { | 86 | func safeWorkspacePath(root, path string) error { | ||
M packages/agent/lsp/lsp_test.go +23 -0
| @@ -357,6 +357,29 @@ func TestApplyWorkspaceEditUsesUTF16AndRejectsOutside(t *testing.T) { | |||
| 357 | 357 | } | |
| 358 | 358 | } | |
| 359 | 359 | ||
| 360 | + | func TestWorkspaceEditPreflightsAllTargets(t *testing.T) { | |
| 361 | + | root := t.TempDir() | |
| 362 | + | inside := filepath.Join(root, "inside.txt") | |
| 363 | + | outside := filepath.Join(t.TempDir(), "outside.txt") | |
| 364 | + | if err := os.WriteFile(inside, []byte("old\n"), 0o644); err != nil { | |
| 365 | + | t.Fatal(err) | |
| 366 | + | } | |
| 367 | + | if err := os.WriteFile(outside, []byte("old\n"), 0o644); err != nil { | |
| 368 | + | t.Fatal(err) | |
| 369 | + | } | |
| 370 | + | rootReal, err := filepath.EvalSymlinks(root) | |
| 371 | + | if err != nil { | |
| 372 | + | t.Fatal(err) | |
| 373 | + | } | |
| 374 | + | _, err = prepareEditTargets(rootReal, map[string][]TextEdit{ | |
| 375 | + | URIForPath(inside): {{Range: Range{}, NewText: "inside"}}, | |
| 376 | + | URIForPath(outside): {{Range: Range{}, NewText: "outside"}}, | |
| 377 | + | }) | |
| 378 | + | if err == nil { | |
| 379 | + | t.Fatal("workspace edit accepted an unsafe target after a safe target") | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | ||
| 360 | 383 | func TestApplyWorkspaceEditFollowsInWorkspaceSymlink(t *testing.T) { | |
| 361 | 384 | if runtime.GOOS == "windows" { | |
| 362 | 385 | t.Skip("symlink permissions vary on Windows") | |
| @@ -357,6 +357,29 @@ func TestApplyWorkspaceEditUsesUTF16AndRejectsOutside(t *testing.T) { | |||||
| 357 | } | 357 | } | ||
| 358 | } | 358 | } | ||
| 359 | 359 | ||||
| 360 | + | func TestWorkspaceEditPreflightsAllTargets(t *testing.T) { | |||
| 361 | + | root := t.TempDir() | |||
| 362 | + | inside := filepath.Join(root, "inside.txt") | |||
| 363 | + | outside := filepath.Join(t.TempDir(), "outside.txt") | |||
| 364 | + | if err := os.WriteFile(inside, []byte("old\n"), 0o644); err != nil { | |||
| 365 | + | t.Fatal(err) | |||
| 366 | + | } | |||
| 367 | + | if err := os.WriteFile(outside, []byte("old\n"), 0o644); err != nil { | |||
| 368 | + | t.Fatal(err) | |||
| 369 | + | } | |||
| 370 | + | rootReal, err := filepath.EvalSymlinks(root) | |||
| 371 | + | if err != nil { | |||
| 372 | + | t.Fatal(err) | |||
| 373 | + | } | |||
| 374 | + | _, err = prepareEditTargets(rootReal, map[string][]TextEdit{ | |||
| 375 | + | URIForPath(inside): {{Range: Range{}, NewText: "inside"}}, | |||
| 376 | + | URIForPath(outside): {{Range: Range{}, NewText: "outside"}}, | |||
| 377 | + | }) | |||
| 378 | + | if err == nil { | |||
| 379 | + | t.Fatal("workspace edit accepted an unsafe target after a safe target") | |||
| 380 | + | } | |||
| 381 | + | } | |||
| 382 | + | ||||
| 360 | func TestApplyWorkspaceEditFollowsInWorkspaceSymlink(t *testing.T) { | 383 | func TestApplyWorkspaceEditFollowsInWorkspaceSymlink(t *testing.T) { | ||
| 361 | if runtime.GOOS == "windows" { | 384 | if runtime.GOOS == "windows" { | ||
| 362 | t.Skip("symlink permissions vary on Windows") | 385 | t.Skip("symlink permissions vary on Windows") | ||