v0.47.0 1ddba1e

ast_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close