Skip to content

Commit ca17b65

Browse files
committed
Add static analyze command for script linting
1 parent 7fb1e29 commit ca17b65

6 files changed

Lines changed: 212 additions & 1 deletion

File tree

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 analyze <script.vibe>` runs script-level lint checks (e.g., unreachable statements).
179180
- Add new recipes in the `Justfile` as workflows grow.
180181

181182
CI also publishes benchmark artifacts via `.github/workflows/benchmarks.yml` on

ROADMAP.md

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

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

327327
### Runtime Quality

cmd/vibes/analyze.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"flag"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"sort"
10+
11+
"github.com/mgomes/vibescript/vibes"
12+
)
13+
14+
type lintWarning struct {
15+
Function string
16+
Pos vibes.Position
17+
Message string
18+
}
19+
20+
func analyzeCommand(args []string) error {
21+
fs := flag.NewFlagSet("analyze", flag.ContinueOnError)
22+
fs.SetOutput(new(flagErrorSink))
23+
if err := fs.Parse(args); err != nil {
24+
return err
25+
}
26+
27+
remaining := fs.Args()
28+
if len(remaining) == 0 {
29+
return errors.New("vibes analyze: script path required")
30+
}
31+
32+
scriptPath, err := filepath.Abs(remaining[0])
33+
if err != nil {
34+
return fmt.Errorf("resolve script path: %w", err)
35+
}
36+
input, err := os.ReadFile(scriptPath)
37+
if err != nil {
38+
return fmt.Errorf("read script: %w", err)
39+
}
40+
41+
engine := vibes.MustNewEngine(vibes.Config{})
42+
script, err := engine.Compile(string(input))
43+
if err != nil {
44+
return fmt.Errorf("analysis compile failed: %w", err)
45+
}
46+
47+
warnings := analyzeScriptWarnings(script)
48+
if len(warnings) == 0 {
49+
fmt.Println("No issues found")
50+
return nil
51+
}
52+
53+
for _, warning := range warnings {
54+
line := warning.Pos.Line
55+
column := warning.Pos.Column
56+
if line <= 0 {
57+
line = 1
58+
}
59+
if column <= 0 {
60+
column = 1
61+
}
62+
fmt.Printf("%s:%d:%d: %s (%s)\n", scriptPath, line, column, warning.Message, warning.Function)
63+
}
64+
65+
return fmt.Errorf("analysis found %d issue(s)", len(warnings))
66+
}
67+
68+
func analyzeScriptWarnings(script *vibes.Script) []lintWarning {
69+
warnings := make([]lintWarning, 0)
70+
for _, fn := range script.Functions() {
71+
lintStatements(fn.Name, fn.Body, &warnings)
72+
}
73+
74+
sort.SliceStable(warnings, func(i, j int) bool {
75+
if warnings[i].Pos.Line != warnings[j].Pos.Line {
76+
return warnings[i].Pos.Line < warnings[j].Pos.Line
77+
}
78+
if warnings[i].Pos.Column != warnings[j].Pos.Column {
79+
return warnings[i].Pos.Column < warnings[j].Pos.Column
80+
}
81+
return warnings[i].Function < warnings[j].Function
82+
})
83+
84+
return warnings
85+
}
86+
87+
func lintStatements(function string, statements []vibes.Statement, warnings *[]lintWarning) bool {
88+
terminated := false
89+
for _, stmt := range statements {
90+
if terminated {
91+
*warnings = append(*warnings, lintWarning{
92+
Function: function,
93+
Pos: stmt.Pos(),
94+
Message: "unreachable statement",
95+
})
96+
continue
97+
}
98+
if statementTerminates(function, stmt, warnings) {
99+
terminated = true
100+
}
101+
}
102+
return terminated
103+
}
104+
105+
func statementTerminates(function string, stmt vibes.Statement, warnings *[]lintWarning) bool {
106+
switch typed := stmt.(type) {
107+
case *vibes.ReturnStmt, *vibes.RaiseStmt:
108+
return true
109+
case *vibes.IfStmt:
110+
return ifStatementTerminates(function, typed, warnings)
111+
case *vibes.ForStmt:
112+
lintStatements(function, typed.Body, warnings)
113+
return false
114+
case *vibes.WhileStmt:
115+
lintStatements(function, typed.Body, warnings)
116+
return false
117+
case *vibes.UntilStmt:
118+
lintStatements(function, typed.Body, warnings)
119+
return false
120+
case *vibes.TryStmt:
121+
bodyTerminated := lintStatements(function, typed.Body, warnings)
122+
rescueTerminated := false
123+
if len(typed.Rescue) > 0 {
124+
rescueTerminated = lintStatements(function, typed.Rescue, warnings)
125+
}
126+
ensureTerminated := false
127+
if len(typed.Ensure) > 0 {
128+
ensureTerminated = lintStatements(function, typed.Ensure, warnings)
129+
}
130+
if ensureTerminated {
131+
return true
132+
}
133+
if len(typed.Rescue) == 0 {
134+
return false
135+
}
136+
return bodyTerminated && rescueTerminated
137+
default:
138+
return false
139+
}
140+
}
141+
142+
func ifStatementTerminates(function string, stmt *vibes.IfStmt, warnings *[]lintWarning) bool {
143+
consequentTerminated := lintStatements(function, stmt.Consequent, warnings)
144+
if len(stmt.Alternate) == 0 {
145+
return false
146+
}
147+
148+
elseIfAllTerminated := true
149+
for _, elseIf := range stmt.ElseIf {
150+
if !ifStatementTerminates(function, elseIf, warnings) {
151+
elseIfAllTerminated = false
152+
}
153+
}
154+
alternateTerminated := lintStatements(function, stmt.Alternate, warnings)
155+
return consequentTerminated && elseIfAllTerminated && alternateTerminated
156+
}

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 "analyze":
30+
return analyzeCommand(args[2:])
2931
case "repl":
3032
return runREPL()
3133
case "help", "-h", "--help":
@@ -98,6 +100,7 @@ func printUsage() {
98100
fmt.Fprintf(os.Stderr, "Usage: %s <command> [flags] [args...]\n\n", prog)
99101
fmt.Fprintln(os.Stderr, "Commands:")
100102
fmt.Fprintln(os.Stderr, " run <script> Execute a script file")
103+
fmt.Fprintln(os.Stderr, " analyze <script> Analyze a script for lint issues")
101104
fmt.Fprintln(os.Stderr, " repl Start interactive REPL")
102105
fmt.Fprintln(os.Stderr, " help Show this help message")
103106
fmt.Fprintln(os.Stderr, "")

cmd/vibes/main_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,43 @@ func TestRunCommandRequiresScriptPath(t *testing.T) {
7171
}
7272
}
7373

74+
func TestAnalyzeCommandNoIssues(t *testing.T) {
75+
scriptPath := writeScript(t, `def run()
76+
value = 1
77+
value
78+
end`)
79+
80+
out, err := captureStdout(t, func() error {
81+
return analyzeCommand([]string{scriptPath})
82+
})
83+
if err != nil {
84+
t.Fatalf("analyzeCommand failed: %v", err)
85+
}
86+
if !strings.Contains(out, "No issues found") {
87+
t.Fatalf("unexpected analyze output: %q", out)
88+
}
89+
}
90+
91+
func TestAnalyzeCommandReportsUnreachableStatements(t *testing.T) {
92+
scriptPath := writeScript(t, `def run()
93+
return 1
94+
2
95+
end`)
96+
97+
out, err := captureStdout(t, func() error {
98+
return analyzeCommand([]string{scriptPath})
99+
})
100+
if err == nil {
101+
t.Fatalf("expected analyze command to report lint failures")
102+
}
103+
if !strings.Contains(err.Error(), "analysis found 1 issue(s)") {
104+
t.Fatalf("unexpected analyze error: %v", err)
105+
}
106+
if !strings.Contains(out, "unreachable statement") {
107+
t.Fatalf("expected unreachable statement warning, got %q", out)
108+
}
109+
}
110+
74111
func TestComputeModulePathsIncludesScriptDirAndDedupesExtras(t *testing.T) {
75112
scriptDir := t.TempDir()
76113
scriptPath := filepath.Join(scriptDir, "main.vibe")

vibes/execution.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4268,6 +4268,20 @@ func (s *Script) Function(name string) (*ScriptFunction, bool) {
42684268
return fn, ok
42694269
}
42704270

4271+
// Functions returns compiled functions in deterministic name order.
4272+
func (s *Script) Functions() []*ScriptFunction {
4273+
names := make([]string, 0, len(s.functions))
4274+
for name := range s.functions {
4275+
names = append(names, name)
4276+
}
4277+
sort.Strings(names)
4278+
out := make([]*ScriptFunction, 0, len(names))
4279+
for _, name := range names {
4280+
out = append(out, s.functions[name])
4281+
}
4282+
return out
4283+
}
4284+
42714285
func (s *Script) bindFunctionOwnership() {
42724286
for _, fn := range s.functions {
42734287
fn.owner = s

0 commit comments

Comments
 (0)