Skip to content

Commit 55f880a

Browse files
committed
Add raise re-raise semantics with stack preservation
1 parent 8daf6a6 commit 55f880a

8 files changed

Lines changed: 165 additions & 1 deletion

File tree

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ Goal: improve language ergonomics for complex script logic and recovery behavior
219219

220220
- [x] Add structured error handling syntax (`begin/rescue/ensure` or equivalent).
221221
- [x] Add typed error matching where feasible.
222-
- [ ] Define re-raise semantics and stack preservation.
222+
- [x] Define re-raise semantics and stack preservation.
223223
- [x] Ensure runtime errors preserve original position and call frames.
224224

225225
### Runtime Behavior

docs/errors.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,17 @@ def safe_div(a, b)
7878
end
7979
```
8080

81+
Re-raise the current rescued error with `raise`:
82+
83+
```vibe
84+
begin
85+
risky_call()
86+
rescue(AssertionError)
87+
audit("recovering assertion")
88+
raise
89+
end
90+
```
91+
8192
Semantics:
8293

8394
- `rescue` runs only when the `begin` body raises an error.
@@ -86,6 +97,8 @@ Semantics:
8697
- `ensure` always runs (success, rescue path, or failure path).
8798
- Without `rescue`, original runtime errors still propagate after `ensure` executes.
8899
- Unmatched typed rescues do not swallow the original error.
100+
- `raise` inside `rescue` re-raises the original error and preserves its stack frames.
101+
- `raise "message"` raises a new runtime error. Bare `raise` outside `rescue` is a runtime error.
89102

90103
## REPL Debugging
91104

vibes/ast.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ type ReturnStmt struct {
8484
func (s *ReturnStmt) stmtNode() {}
8585
func (s *ReturnStmt) Pos() Position { return s.position }
8686

87+
type RaiseStmt struct {
88+
Value Expression
89+
position Position
90+
}
91+
92+
func (s *RaiseStmt) stmtNode() {}
93+
func (s *RaiseStmt) Pos() Position { return s.position }
94+
8795
type AssignStmt struct {
8896
Target Expression
8997
Value Expression

vibes/execution.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ type Execution struct {
6363
receiverStack []Value
6464
envStack []*Env
6565
loopDepth int
66+
rescuedErrors []error
6667
strictEffects bool
6768
allowRequire bool
6869
}
@@ -315,6 +316,24 @@ func (exec *Execution) currentModuleContext() *moduleContext {
315316
return &ctx
316317
}
317318

319+
func (exec *Execution) pushRescuedError(err error) {
320+
exec.rescuedErrors = append(exec.rescuedErrors, err)
321+
}
322+
323+
func (exec *Execution) popRescuedError() {
324+
if len(exec.rescuedErrors) == 0 {
325+
return
326+
}
327+
exec.rescuedErrors = exec.rescuedErrors[:len(exec.rescuedErrors)-1]
328+
}
329+
330+
func (exec *Execution) currentRescuedError() error {
331+
if len(exec.rescuedErrors) == 0 {
332+
return nil
333+
}
334+
return exec.rescuedErrors[len(exec.rescuedErrors)-1]
335+
}
336+
318337
func (exec *Execution) evalStatements(stmts []Statement, env *Env) (Value, bool, error) {
319338
exec.pushEnv(env)
320339
defer exec.popEnv()
@@ -356,6 +375,8 @@ func (exec *Execution) evalStatement(stmt Statement, env *Env) (Value, bool, err
356375
case *ReturnStmt:
357376
val, err := exec.evalExpression(s.Value, env)
358377
return val, true, err
378+
case *RaiseStmt:
379+
return exec.evalRaiseStatement(s, env)
359380
case *AssignStmt:
360381
val, err := exec.evalExpression(s.Value, env)
361382
if err != nil {
@@ -1369,11 +1390,29 @@ func (exec *Execution) evalUntilStatement(stmt *UntilStmt, env *Env) (Value, boo
13691390
}
13701391
}
13711392

1393+
func (exec *Execution) evalRaiseStatement(stmt *RaiseStmt, env *Env) (Value, bool, error) {
1394+
if stmt.Value != nil {
1395+
val, err := exec.evalExpression(stmt.Value, env)
1396+
if err != nil {
1397+
return NewNil(), false, err
1398+
}
1399+
return NewNil(), false, exec.errorAt(stmt.Pos(), "%s", val.String())
1400+
}
1401+
1402+
err := exec.currentRescuedError()
1403+
if err == nil {
1404+
return NewNil(), false, exec.errorAt(stmt.Pos(), "raise used outside of rescue")
1405+
}
1406+
return NewNil(), false, err
1407+
}
1408+
13721409
func (exec *Execution) evalTryStatement(stmt *TryStmt, env *Env) (Value, bool, error) {
13731410
val, returned, err := exec.evalStatements(stmt.Body, env)
13741411

13751412
if err != nil && len(stmt.Rescue) > 0 && runtimeErrorMatchesRescueType(err, stmt.RescueTy) {
1413+
exec.pushRescuedError(err)
13761414
rescueVal, rescueReturned, rescueErr := exec.evalStatements(stmt.Rescue, env)
1415+
exec.popRescuedError()
13771416
if rescueErr != nil {
13781417
val = NewNil()
13791418
returned = false

vibes/lexer.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,8 @@ func lookupIdent(ident string) TokenType {
411411
return tokenRescue
412412
case "ensure":
413413
return tokenEnsure
414+
case "raise":
415+
return tokenRaise
414416
case "end":
415417
return tokenEnd
416418
case "return":

vibes/parser.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ func (p *parser) parseStatement() Statement {
123123
return p.parseClassStatement()
124124
case tokenReturn:
125125
return p.parseReturnStatement()
126+
case tokenRaise:
127+
return p.parseRaiseStatement()
126128
case tokenIf:
127129
return p.parseIfStatement()
128130
case tokenFor:
@@ -263,6 +265,19 @@ func (p *parser) parseReturnStatement() Statement {
263265
return &ReturnStmt{Value: value, position: pos}
264266
}
265267

268+
func (p *parser) parseRaiseStatement() Statement {
269+
pos := p.curToken.Pos
270+
if p.peekToken.Type == tokenEOF || p.peekToken.Type == tokenEnd || p.peekToken.Type == tokenEnsure || p.peekToken.Type == tokenRescue || p.peekToken.Pos.Line != pos.Line {
271+
return &RaiseStmt{position: pos}
272+
}
273+
p.nextToken()
274+
value := p.parseExpression(lowestPrec)
275+
if value == nil {
276+
return nil
277+
}
278+
return &RaiseStmt{Value: value, position: pos}
279+
}
280+
266281
func (p *parser) parseClassStatement() Statement {
267282
pos := p.curToken.Pos
268283
if !p.expectPeek(tokenIdent) {
@@ -1178,6 +1193,8 @@ func tokenLabel(tt TokenType) string {
11781193
return "'setter'"
11791194
case tokenEnd:
11801195
return "'end'"
1196+
case tokenRaise:
1197+
return "'raise'"
11811198
case tokenReturn:
11821199
return "'return'"
11831200
case tokenYield:

vibes/runtime_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,90 @@ func TestBeginRescueTypedUnknownTypeFailsCompile(t *testing.T) {
11041104
}
11051105
}
11061106

1107+
func TestBeginRescueReraisePreservesStack(t *testing.T) {
1108+
script := compileScript(t, `
1109+
def inner()
1110+
assert false, "boom"
1111+
end
1112+
1113+
def middle()
1114+
begin
1115+
inner()
1116+
rescue(AssertionError)
1117+
raise
1118+
end
1119+
end
1120+
1121+
def outer()
1122+
middle()
1123+
end
1124+
1125+
def catches_reraise()
1126+
begin
1127+
middle()
1128+
rescue(AssertionError)
1129+
"caught"
1130+
end
1131+
end
1132+
1133+
def raise_outside()
1134+
raise
1135+
end
1136+
1137+
def raise_new_message()
1138+
raise "custom boom"
1139+
end
1140+
`)
1141+
1142+
if got := callFunc(t, script, "catches_reraise", nil); !got.Equal(NewString("caught")) {
1143+
t.Fatalf("catches_reraise mismatch: %v", got)
1144+
}
1145+
1146+
_, err := script.Call(context.Background(), "outer", nil, CallOptions{})
1147+
if err == nil || !strings.Contains(err.Error(), "boom") {
1148+
t.Fatalf("expected reraise error, got %v", err)
1149+
}
1150+
var rtErr *RuntimeError
1151+
if !errors.As(err, &rtErr) {
1152+
t.Fatalf("expected RuntimeError, got %T", err)
1153+
}
1154+
if rtErr.Type != runtimeErrorTypeAssertion {
1155+
t.Fatalf("expected assertion error type %s, got %s", runtimeErrorTypeAssertion, rtErr.Type)
1156+
}
1157+
if len(rtErr.Frames) < 4 {
1158+
t.Fatalf("expected at least 4 frames, got %d", len(rtErr.Frames))
1159+
}
1160+
if rtErr.Frames[0].Function != "inner" {
1161+
t.Fatalf("expected inner frame first, got %s", rtErr.Frames[0].Function)
1162+
}
1163+
if rtErr.Frames[1].Function != "inner" {
1164+
t.Fatalf("expected inner call site second, got %s", rtErr.Frames[1].Function)
1165+
}
1166+
if rtErr.Frames[2].Function != "middle" {
1167+
t.Fatalf("expected middle frame third, got %s", rtErr.Frames[2].Function)
1168+
}
1169+
if rtErr.Frames[3].Function != "outer" {
1170+
t.Fatalf("expected outer frame fourth, got %s", rtErr.Frames[3].Function)
1171+
}
1172+
1173+
_, err = script.Call(context.Background(), "raise_outside", nil, CallOptions{})
1174+
if err == nil || !strings.Contains(err.Error(), "raise used outside of rescue") {
1175+
t.Fatalf("expected raise outside rescue error, got %v", err)
1176+
}
1177+
1178+
_, err = script.Call(context.Background(), "raise_new_message", nil, CallOptions{})
1179+
if err == nil || !strings.Contains(err.Error(), "custom boom") {
1180+
t.Fatalf("expected raise message error, got %v", err)
1181+
}
1182+
var raisedErr *RuntimeError
1183+
if !errors.As(err, &raisedErr) {
1184+
t.Fatalf("expected RuntimeError, got %T", err)
1185+
}
1186+
if raisedErr.Type != runtimeErrorTypeBase {
1187+
t.Fatalf("expected runtime error type %s, got %s", runtimeErrorTypeBase, raisedErr.Type)
1188+
}
1189+
}
1190+
11071191
func TestLoopControlBreakAndNext(t *testing.T) {
11081192
script := compileScript(t, `
11091193
def for_break()

vibes/token.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const (
5454
tokenBegin TokenType = "BEGIN"
5555
tokenRescue TokenType = "RESCUE"
5656
tokenEnsure TokenType = "ENSURE"
57+
tokenRaise TokenType = "RAISE"
5758
tokenEnd TokenType = "END"
5859
tokenReturn TokenType = "RETURN"
5960
tokenYield TokenType = "YIELD"

0 commit comments

Comments
 (0)