Skip to content

Commit a27d73d

Browse files
committed
Add canonical vibes fmt command and CI check
1 parent ca17b65 commit a27d73d

8 files changed

Lines changed: 230 additions & 3 deletions

File tree

.github/workflows/test.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ jobs:
3737
if: runner.os != 'Windows'
3838
run: just lint
3939

40+
- name: Check Vibe formatting
41+
if: runner.os != 'Windows'
42+
run: go run ./cmd/vibes fmt -check .
43+
4044
- name: Run tests (Windows)
4145
if: runner.os == 'Windows'
4246
shell: pwsh
@@ -52,6 +56,11 @@ jobs:
5256
exit 1
5357
}
5458
59+
- name: Check Vibe formatting (Windows)
60+
if: runner.os == 'Windows'
61+
shell: pwsh
62+
run: go run ./cmd/vibes fmt -check .
63+
5564
- name: Run golangci-lint (Windows)
5665
if: runner.os == 'Windows'
5766
shell: pwsh

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ This repository uses [Just](https://github.com/casey/just) for common tasks:
176176
- `just test` runs the full Go test suite (`go test ./...`).
177177
- `just bench` runs the core execution benchmarks (`go test ./vibes -run '^$' -bench '^BenchmarkExecution' -benchmem`).
178178
- `just lint` checks formatting (`gofmt`) and runs `golangci-lint` with a generous timeout.
179+
- `vibes fmt <path>` applies canonical formatting to `.vibe` files (`-check` for CI, `-w` to write).
179180
- `vibes analyze <script.vibe>` runs script-level lint checks (e.g., unreachable statements).
180181
- Add new recipes in the `Justfile` as workflows grow.
181182

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ Goal: improve day-to-day developer productivity and interpreter robustness.
319319

320320
### Tooling
321321

322-
- [ ] Add canonical formatter command and CI check.
322+
- [x] Add canonical formatter command and CI check.
323323
- [ ] Add language server protocol (LSP) prototype (hover, completion, diagnostics).
324324
- [x] Add static analysis command for script-level linting.
325325
- [x] Improve REPL inspection commands (globals/functions/types).

cmd/vibes/fmt.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"flag"
6+
"fmt"
7+
"io/fs"
8+
"os"
9+
"path/filepath"
10+
"sort"
11+
"strings"
12+
)
13+
14+
func fmtCommand(args []string) error {
15+
fs := flag.NewFlagSet("fmt", flag.ContinueOnError)
16+
fs.SetOutput(new(flagErrorSink))
17+
write := fs.Bool("w", false, "write result to source files instead of stdout")
18+
check := fs.Bool("check", false, "fail if any source file needs formatting")
19+
if err := fs.Parse(args); err != nil {
20+
return err
21+
}
22+
23+
targets := fs.Args()
24+
if len(targets) == 0 {
25+
return errors.New("vibes fmt: path required")
26+
}
27+
28+
files, err := collectVibeFiles(targets)
29+
if err != nil {
30+
return err
31+
}
32+
if len(files) == 0 {
33+
return nil
34+
}
35+
36+
changedCount := 0
37+
for _, path := range files {
38+
originalBytes, err := os.ReadFile(path)
39+
if err != nil {
40+
return fmt.Errorf("read %s: %w", path, err)
41+
}
42+
original := string(originalBytes)
43+
formatted := formatVibeSource(original)
44+
changed := formatted != original
45+
if changed {
46+
changedCount++
47+
}
48+
49+
switch {
50+
case *write && changed:
51+
info, err := os.Stat(path)
52+
if err != nil {
53+
return fmt.Errorf("stat %s: %w", path, err)
54+
}
55+
if err := os.WriteFile(path, []byte(formatted), info.Mode().Perm()); err != nil {
56+
return fmt.Errorf("write %s: %w", path, err)
57+
}
58+
case !*write && !*check:
59+
fmt.Print(formatted)
60+
}
61+
}
62+
63+
if *check && changedCount > 0 {
64+
return fmt.Errorf("vibes fmt: %d file(s) need formatting", changedCount)
65+
}
66+
67+
return nil
68+
}
69+
70+
func collectVibeFiles(targets []string) ([]string, error) {
71+
seen := make(map[string]struct{})
72+
files := make([]string, 0)
73+
addFile := func(path string) {
74+
if filepath.Ext(path) != ".vibe" {
75+
return
76+
}
77+
abs, err := filepath.Abs(path)
78+
if err != nil {
79+
return
80+
}
81+
if _, ok := seen[abs]; ok {
82+
return
83+
}
84+
seen[abs] = struct{}{}
85+
files = append(files, abs)
86+
}
87+
88+
for _, target := range targets {
89+
info, err := os.Stat(target)
90+
if err != nil {
91+
return nil, fmt.Errorf("stat %s: %w", target, err)
92+
}
93+
if !info.IsDir() {
94+
addFile(target)
95+
continue
96+
}
97+
err = filepath.WalkDir(target, func(path string, entry fs.DirEntry, walkErr error) error {
98+
if walkErr != nil {
99+
return walkErr
100+
}
101+
if entry.IsDir() {
102+
return nil
103+
}
104+
addFile(path)
105+
return nil
106+
})
107+
if err != nil {
108+
return nil, fmt.Errorf("walk %s: %w", target, err)
109+
}
110+
}
111+
112+
sort.Strings(files)
113+
return files, nil
114+
}
115+
116+
func formatVibeSource(source string) string {
117+
normalized := strings.ReplaceAll(source, "\r\n", "\n")
118+
normalized = strings.ReplaceAll(normalized, "\r", "\n")
119+
120+
lines := strings.Split(normalized, "\n")
121+
for i, line := range lines {
122+
lines[i] = strings.TrimRight(line, " \t")
123+
}
124+
125+
joined := strings.Join(lines, "\n")
126+
joined = strings.TrimRight(joined, "\n")
127+
return joined + "\n"
128+
}

cmd/vibes/fmt_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestFmtCommandRequiresPath(t *testing.T) {
11+
err := fmtCommand(nil)
12+
if err == nil {
13+
t.Fatalf("expected path required error")
14+
}
15+
if !strings.Contains(err.Error(), "path required") {
16+
t.Fatalf("unexpected error: %v", err)
17+
}
18+
}
19+
20+
func TestFmtCommandCheckDetectsUnformattedFiles(t *testing.T) {
21+
path := writeVibeFile(t, "def run() \n 1\t \nend")
22+
err := fmtCommand([]string{"-check", path})
23+
if err == nil {
24+
t.Fatalf("expected formatting check failure")
25+
}
26+
if !strings.Contains(err.Error(), "need formatting") {
27+
t.Fatalf("unexpected check error: %v", err)
28+
}
29+
}
30+
31+
func TestFmtCommandWriteFormatsFileInPlace(t *testing.T) {
32+
path := writeVibeFile(t, "def run() \n 1\t \nend")
33+
if err := fmtCommand([]string{"-w", path}); err != nil {
34+
t.Fatalf("fmt -w failed: %v", err)
35+
}
36+
37+
updated, err := os.ReadFile(path)
38+
if err != nil {
39+
t.Fatalf("read formatted file: %v", err)
40+
}
41+
if got := string(updated); got != "def run()\n 1\nend\n" {
42+
t.Fatalf("unexpected formatted output: %q", got)
43+
}
44+
}
45+
46+
func TestFmtCommandPrintsFormattedOutput(t *testing.T) {
47+
path := writeVibeFile(t, "def run() \n 1\t \nend")
48+
out, err := captureStdout(t, func() error {
49+
return fmtCommand([]string{path})
50+
})
51+
if err != nil {
52+
t.Fatalf("fmt command failed: %v", err)
53+
}
54+
if out != "def run()\n 1\nend\n" {
55+
t.Fatalf("unexpected stdout output: %q", out)
56+
}
57+
}
58+
59+
func TestFmtCommandFormatsDirectories(t *testing.T) {
60+
root := t.TempDir()
61+
first := filepath.Join(root, "a.vibe")
62+
second := filepath.Join(root, "nested", "b.vibe")
63+
if err := os.MkdirAll(filepath.Dir(second), 0o755); err != nil {
64+
t.Fatalf("mkdir nested: %v", err)
65+
}
66+
if err := os.WriteFile(first, []byte("def run() \n 1 \nend"), 0o644); err != nil {
67+
t.Fatalf("write first file: %v", err)
68+
}
69+
if err := os.WriteFile(second, []byte("def run() \n 2\t\nend"), 0o644); err != nil {
70+
t.Fatalf("write second file: %v", err)
71+
}
72+
73+
if err := fmtCommand([]string{"-w", root}); err != nil {
74+
t.Fatalf("fmt directory failed: %v", err)
75+
}
76+
if err := fmtCommand([]string{"-check", root}); err != nil {
77+
t.Fatalf("expected no formatting diffs after write, got %v", err)
78+
}
79+
}
80+
81+
func writeVibeFile(t *testing.T, content string) string {
82+
t.Helper()
83+
path := filepath.Join(t.TempDir(), "script.vibe")
84+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
85+
t.Fatalf("write vibe file: %v", err)
86+
}
87+
return path
88+
}

cmd/vibes/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ func runCLI(args []string) error {
2626
switch args[1] {
2727
case "run":
2828
return runCommand(args[2:])
29+
case "fmt":
30+
return fmtCommand(args[2:])
2931
case "analyze":
3032
return analyzeCommand(args[2:])
3133
case "repl":
@@ -100,6 +102,7 @@ func printUsage() {
100102
fmt.Fprintf(os.Stderr, "Usage: %s <command> [flags] [args...]\n\n", prog)
101103
fmt.Fprintln(os.Stderr, "Commands:")
102104
fmt.Fprintln(os.Stderr, " run <script> Execute a script file")
105+
fmt.Fprintln(os.Stderr, " fmt <path> Canonical formatting for .vibe files")
103106
fmt.Fprintln(os.Stderr, " analyze <script> Analyze a script for lint issues")
104107
fmt.Fprintln(os.Stderr, " repl Start interactive REPL")
105108
fmt.Fprintln(os.Stderr, " help Show this help message")

examples/basics/functions_and_calls.vibe

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,3 @@ def max_value(a, b)
2121
b
2222
end
2323
end
24-

examples/capabilities/iteration.vibe

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,3 @@ def total_raised_for_player(player_id)
88
end
99
total
1010
end
11-

0 commit comments

Comments
 (0)