1package tools
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "testing"
13 "unicode/utf8"
14
15 "github.com/bnema/zut/packages/provider"
16)
17
18func TestASTToolReportsMissingBackend(t *testing.T) {
19 tool := &ASTTool{CWD: t.TempDir(), LookPath: func(string) (string, error) { return "", os.ErrNotExist }}
20 _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"$A","language":"go","path":"."}`), nil)
21 if err == nil || !strings.Contains(err.Error(), "ast-grep executable ('ast-grep' or 'sg') not found") {
22 t.Fatalf("Execute error = %v", err)
23 }
24}
25
26func TestASTToolReportsCaptureLimitWithoutBrokenPipe(t *testing.T) {
27 if runtime.GOOS == "windows" {
28 t.Skip("test fixture uses a POSIX script")
29 }
30 root := t.TempDir()
31 script := filepath.Join(root, "ast-grep")
32 body := "#!/bin/sh\nhead -c 4194305 /dev/zero | tr '\\0' x\n"
33 if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
34 t.Fatal(err)
35 }
36 tool := &ASTTool{CWD: root, LookPath: func(string) (string, error) { return script, nil }}
37 result, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"$A","language":"go","path":"."}`), nil)
38 if err != nil || !strings.Contains(result.Content[0].(provider.TextBlock).Text, "search output truncated") {
39 t.Fatalf("search result = %+v, error = %v", result, err)
40 }
41 _, err = tool.Execute(context.Background(), json.RawMessage(`{"pattern":"$A","language":"go","path":".","rewrite":"x"}`), nil)
42 if err == nil || !strings.Contains(err.Error(), "output exceeds") || strings.Contains(strings.ToLower(err.Error()), "broken pipe") {
43 t.Fatalf("rewrite error = %v", err)
44 }
45}
46
47func TestASTToolExecutesWithoutShellAndTracksDiscoveredFiles(t *testing.T) {
48 if runtime.GOOS == "windows" {
49 t.Skip("test fixture uses a POSIX script")
50 }
51 root := t.TempDir()
52 script := filepath.Join(root, "ast-grep")
53 body := "#!/bin/sh\nprintf '%s\\n' '{\"file\":\"main.go\",\"range\":{}}'\n"
54 if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
55 t.Fatal(err)
56 }
57 tool := &ASTTool{CWD: root, LookPath: func(string) (string, error) { return script, nil }}
58 result, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"fmt.Errorf($MSG)","language":"go","path":"."}`), nil)
59 if err != nil {
60 t.Fatalf("Execute: %v", err)
61 }
62 want := filepath.Join(root, "main.go")
63 if len(result.Context.Discovers) != 1 || result.Context.Discovers[0] != want {
64 t.Fatalf("discoveries = %v, want %s", result.Context.Discovers, want)
65 }
66}
67
68func TestASTToolPrefersArchExecutableName(t *testing.T) {
69 root := t.TempDir()
70 var lookedUp []string
71 tool := &ASTTool{CWD: root, LookPath: func(name string) (string, error) {
72 lookedUp = append(lookedUp, name)
73 if name == "ast-grep" {
74 return "/usr/bin/ast-grep", nil
75 }
76 return "", os.ErrNotExist
77 }}
78 got, err := tool.executable()
79 if err != nil || got != "/usr/bin/ast-grep" || len(lookedUp) != 1 {
80 t.Fatalf("executable = %q, %v; lookups = %v", got, err, lookedUp)
81 }
82}
83
84func TestASTToolRewritePreviewAndExecute(t *testing.T) {
85 binary, err := exec.LookPath("ast-grep")
86 if err != nil {
87 t.Skip("ast-grep not installed")
88 }
89 root := t.TempDir()
90 path := filepath.Join(root, "main.go")
91 original := "package main\nfunc main() { println(\"x\") }\n"
92 if err := os.WriteFile(path, []byte(original), 0o640); err != nil {
93 t.Fatal(err)
94 }
95 tool := &ASTTool{CWD: root, LookPath: func(string) (string, error) { return binary, nil }}
96 args := json.RawMessage(`{"pattern":"println($A)","language":"go","path":".","rewrite":"fmt.Println($A)"}`)
97 preview, err := tool.Preview(context.Background(), args)
98 if err != nil {
99 t.Fatalf("Preview: %v", err)
100 }
101 if !strings.Contains(preview.Content[0].(provider.TextBlock).Text, "+func main() { fmt.Println") {
102 t.Fatalf("preview = %q", preview.Content[0].(provider.TextBlock).Text)
103 }
104 if got, _ := os.ReadFile(path); string(got) != original {
105 t.Fatal("preview modified the file")
106 }
107 result, err := tool.Execute(context.Background(), args, nil)
108 if err != nil {
109 t.Fatalf("Execute: %v", err)
110 }
111 got, _ := os.ReadFile(path)
112 if !strings.Contains(string(got), "fmt.Println") || len(result.Context.Mutates) != 1 {
113 t.Fatalf("result = %q, context = %+v", got, result.Context)
114 }
115 if info, _ := os.Stat(path); runtime.GOOS != "windows" && info.Mode().Perm() != 0o640 {
116 t.Fatalf("mode = %v", info.Mode().Perm())
117 }
118}
119
120func TestASTRewriteFormatsGoSource(t *testing.T) {
121 root := t.TempDir()
122 path := filepath.Join(root, "main.go")
123 original := []byte("package main\n\nfunc add(left, right int) int { return left + right }\n")
124 if err := os.WriteFile(path, original, 0o644); err != nil {
125 t.Fatal(err)
126 }
127 start := bytes.Index(original, []byte("return left + right"))
128 match := astTestMatch(path, "return left + right", "return left + right\n\n", start, start+len("return left + right"))
129 plan, err := (&ASTTool{CWD: root}).planRewrite(root, astArgs{Language: "go", Rewrite: "{$$$BODY}"}, []astMatch{match})
130 if err != nil {
131 t.Fatalf("planRewrite: %v", err)
132 }
133 want := "package main\n\nfunc add(left, right int) int {\n\treturn left + right\n\n}\n"
134 if got := string(plan.changes[0].content); got != want {
135 t.Fatalf("rewrite = %q, want %q", got, want)
136 }
137}
138
139func TestASTRewriteUsesOutermostNestedMatch(t *testing.T) {
140 root := t.TempDir()
141 path := filepath.Join(root, "main.go")
142 original := []byte("package main\nfunc main() { f(g(x)) }\n")
143 if err := os.WriteFile(path, original, 0o644); err != nil {
144 t.Fatal(err)
145 }
146 matches := []astMatch{
147 astTestMatch(path, "f(g(x))", "WRAP(f)", 27, 34),
148 astTestMatch(path, "g(x)", "WRAP(g)", 29, 33),
149 }
150 plan, err := (&ASTTool{CWD: root}).planRewrite(root, astArgs{Rewrite: "WRAP($A)"}, matches)
151 if err != nil {
152 t.Fatalf("planRewrite: %v", err)
153 }
154 if got := string(plan.changes[0].content); !strings.Contains(got, "WRAP(f)") || strings.Contains(got, "WRAP(g)") {
155 t.Fatalf("rewrite = %q", got)
156 }
157}
158
159func astTestMatch(path, text, replacement string, start, end int) astMatch {
160 match := astMatch{File: path, Text: text, Replacement: replacement}
161 match.Range.ByteOffset.Start = start
162 match.Range.ByteOffset.End = end
163 return match
164}
165
166func TestBoundASTTextPreservesUTF8(t *testing.T) {
167 got := boundASTText(strings.Repeat("界", maxASTOutputBytes))
168 if !strings.Contains(got, "truncated") || !utf8.ValidString(got) {
169 t.Fatalf("invalid bounded output")
170 }
171}
172
173func TestASTFallbackRejectsOtherSG(t *testing.T) {
174 if runtime.GOOS == "windows" {
175 t.Skip("POSIX script")
176 }
177 root := t.TempDir()
178 path := filepath.Join(root, "sg")
179 if err := os.WriteFile(path, []byte("#!/bin/sh\necho 'shadow-utils 1.0'\n"), 0o755); err != nil {
180 t.Fatal(err)
181 }
182 tool := &ASTTool{LookPath: func(name string) (string, error) {
183 if name == "sg" {
184 return path, nil
185 }
186 return "", os.ErrNotExist
187 }}
188 if _, err := tool.executable(); err == nil {
189 t.Fatal("accepted non ast-grep sg")
190 }
191 if err := os.WriteFile(path, []byte("#!/bin/sh\necho 'ast-grep 0.44.1'\n"), 0o755); err != nil {
192 t.Fatal(err)
193 }
194 if got, err := tool.executable(); err != nil || got != path {
195 t.Fatalf("executable = %q, %v", got, err)
196 }
197}
198
199func TestASTRewriteFormattingDependsOnOriginal(t *testing.T) {
200 for _, tc := range []struct{ name, original, replacement, want string }{
201 {"unclean", "package main\nfunc main(){println(1)}\n", "println(2)", "package main\nfunc main(){println(2)}\n"},
202 {"crlf", "package main\r\n\r\nfunc main() { println(1) }\r\n", "println( 2 )", "package main\r\n\r\nfunc main() { println(2) }\r\n"},
203 {"mixed", "package main\r\n\nfunc main() { println(1) }\n", "println( 2 )", "package main\r\n\nfunc main() { println( 2 ) }\n"},
204 } {
205 t.Run(tc.name, func(t *testing.T) {
206 root := t.TempDir()
207 path := filepath.Join(root, "main.go")
208 if err := os.WriteFile(path, []byte(tc.original), 0o644); err != nil {
209 t.Fatal(err)
210 }
211 start := strings.Index(tc.original, "println(1)")
212 match := astTestMatch(path, "println(1)", tc.replacement, start, start+len("println(1)"))
213 plan, err := (&ASTTool{CWD: root}).planRewrite(root, astArgs{Language: "go", Rewrite: "x"}, []astMatch{match})
214 if err != nil {
215 t.Fatal(err)
216 }
217 if got := string(plan.changes[0].content); got != tc.want {
218 t.Fatalf("got %q, want %q", got, tc.want)
219 }
220 })
221 }
222}
223
224func TestASTScopeAndReadOnly(t *testing.T) {
225 root := t.TempDir()
226 outside := t.TempDir()
227 path := filepath.Join(outside, "main.go")
228 if err := os.WriteFile(path, []byte("package main\n"), 0o644); err != nil {
229 t.Fatal(err)
230 }
231 sandbox := NewSandbox(root)
232 sandbox.Lock()
233 tool := &ASTTool{CWD: root, Sandbox: sandbox}
234 // Marshal the path: raw Windows backslashes are invalid JSON escapes.
235 args, err := json.Marshal(map[string]string{"pattern": "$A", "language": "go", "path": path})
236 if err != nil {
237 t.Fatal(err)
238 }
239 if _, err := tool.Execute(context.Background(), args, nil); err == nil || !strings.Contains(err.Error(), "outside sandbox") {
240 t.Fatalf("jail: %v", err)
241 }
242 tool.ReadOnly = true
243 if _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"$A","language":"go","path":".","rewrite":"x"}`), nil); err == nil || !strings.Contains(err.Error(), "read-only") {
244 t.Fatalf("read-only: %v", err)
245 }
246 tool.ReadOnly = false
247 match := astTestMatch(path, "package", "module", 0, 7)
248 if _, err := tool.planRewrite(root, astArgs{Rewrite: "module"}, []astMatch{match}); err == nil || !strings.Contains(err.Error(), "outside sandbox") {
249 t.Fatalf("write scope: %v", err)
250 }
251 permissions := &PermissionSet{}
252 permissions.FS.Read = []string{root, outside}
253 permissions.FS.Write = []string{root}
254 unlocked := NewSandbox(root)
255 unlocked.SetPermissions(permissions)
256 if _, err := (&ASTTool{CWD: root, Sandbox: unlocked}).planRewrite(root, astArgs{Rewrite: "module"}, []astMatch{match}); err == nil || !strings.Contains(err.Error(), "outside declared scopes") {
257 t.Fatalf("permission: %v", err)
258 }
259}
260
261func TestASTWarningsAndExitError(t *testing.T) {
262 if runtime.GOOS == "windows" {
263 t.Skip("POSIX script")
264 }
265 root := t.TempDir()
266 script := filepath.Join(root, "ast-grep")
267 tool := &ASTTool{CWD: root, LookPath: func(string) (string, error) { return script, nil }}
268 if err := os.WriteFile(script, []byte("#!/bin/sh\necho 'Pattern contains an ERROR node' >&2\n"), 0o755); err != nil {
269 t.Fatal(err)
270 }
271 for _, rewrite := range []string{"", `,"rewrite":"x"`} {
272 result, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"$A","language":"go","path":"."`+rewrite+`}`), nil)
273 if err != nil || !strings.Contains(result.Content[0].(provider.TextBlock).Text, "No structural matches.\nPattern contains an ERROR node") {
274 t.Fatalf("result = %+v, %v", result, err)
275 }
276 }
277 if err := os.WriteFile(script, []byte("#!/bin/sh\necho 'bad pattern' >&2\nexit 2\n"), 0o755); err != nil {
278 t.Fatal(err)
279 }
280 if _, err := tool.Execute(context.Background(), json.RawMessage(`{"pattern":"$A","language":"go","path":"."}`), nil); err == nil || !strings.Contains(err.Error(), "bad pattern") {
281 t.Fatalf("exit error: %v", err)
282 }
283}
284
285func TestASTApplyRewriteValidatesAllBeforeWriting(t *testing.T) {
286 root := t.TempDir()
287 first := filepath.Join(root, "a.go")
288 second := filepath.Join(root, "b.go")
289 for _, path := range []string{first, second} {
290 if err := os.WriteFile(path, []byte("old"), 0o640); err != nil {
291 t.Fatal(err)
292 }
293 }
294 changes := []astChange{
295 {path: first, original: []byte("old"), content: []byte("new"), mode: 0o640},
296 {path: second, original: []byte("old"), content: []byte("new"), mode: 0o640},
297 }
298 if err := os.WriteFile(second, []byte("changed"), 0o640); err != nil {
299 t.Fatal(err)
300 }
301 if err := (&ASTTool{}).applyRewrite(changes); err == nil || !strings.Contains(err.Error(), "changed while applying") {
302 t.Fatalf("error = %v", err)
303 }
304 if got, _ := os.ReadFile(first); string(got) != "old" {
305 t.Fatalf("first file changed: %q", got)
306 }
307 if err := os.WriteFile(second, []byte("old"), 0o640); err != nil {
308 t.Fatal(err)
309 }
310 if err := (&ASTTool{}).applyRewrite(changes); err != nil {
311 t.Fatal(err)
312 }
313 if got, _ := os.ReadFile(first); string(got) != "new" {
314 t.Fatalf("first file = %q", got)
315 }
316 // Windows has no Unix permission bits to preserve.
317 if runtime.GOOS != "windows" {
318 if info, err := os.Stat(first); err != nil || info.Mode().Perm() != 0o640 {
319 t.Fatalf("mode = %v, %v", info, err)
320 }
321 }
322}