Skip to content

Commit 338ffe3

Browse files
committed
feat: add call stack tracking and stack traces
Rad function calls now track a call stack separate from Go's, enabling user-facing stack traces in error messages. Each CallFrame records: - Function name (or "<anonymous>" for lambdas) - Call site span (where the function was called) - Definition site span (where the function is defined) The stack is pushed on entry and popped on exit. When a runtime error occurs inside nested functions, users see the full Rad call chain: = stack: at inner (script.rad:6:5) in outer (script.rad:8:1) Also refactors internal panic handling to produce friendlier "This is a bug in Rad" messages with a GitHub issue link, rather than exposing raw Go stack traces.
1 parent 2a09807 commit 338ffe3

File tree

5 files changed

+181
-30
lines changed

5 files changed

+181
-30
lines changed

core/diagnostic.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,13 @@ func NewSecondaryLabel(span Span, message string) Label {
9191

9292
// Diagnostic represents a single diagnostic message with optional multi-span context.
9393
type Diagnostic struct {
94-
Severity Severity
95-
Code rl.Error // From rts/rl/errors.go
96-
Message string // One-line summary
97-
Labels []Label // Primary + secondary spans
98-
Hints []string // "= help: ..." lines
99-
Source string // Complete source for rendering
94+
Severity Severity
95+
Code rl.Error // From rts/rl/errors.go
96+
Message string // One-line summary
97+
Labels []Label // Primary + secondary spans
98+
Hints []string // "= help: ..." lines
99+
Source string // Complete source for rendering
100+
CallStack []CallFrame // Rad call stack at time of error (most recent first)
100101
}
101102

102103
// NewDiagnostic creates a diagnostic with a single primary label.
@@ -139,6 +140,12 @@ func (d Diagnostic) WithSecondaryLabel(span Span, message string) Diagnostic {
139140
return d
140141
}
141142

143+
// WithCallStack attaches a call stack to the diagnostic and returns the modified diagnostic.
144+
func (d Diagnostic) WithCallStack(stack []CallFrame) Diagnostic {
145+
d.CallStack = stack
146+
return d
147+
}
148+
142149
// PrimarySpan returns the first primary span, or nil if none exists.
143150
func (d Diagnostic) PrimarySpan() *Span {
144151
for _, label := range d.Labels {

core/diagnostic_render.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func NewDiagnosticRenderer(w io.Writer) *DiagnosticRenderer {
2727
func (r *DiagnosticRenderer) Render(d Diagnostic) {
2828
r.renderHeader(d)
2929
r.renderLabels(d)
30+
r.renderCallStack(d)
3031
r.renderHints(d)
3132
r.renderInfoLine(d)
3233
fmt.Fprintln(r.writer)
@@ -292,6 +293,37 @@ func (r *DiagnosticRenderer) renderInfoLine(d Diagnostic) {
292293
}
293294
}
294295

296+
// renderCallStack renders the call stack if present.
297+
func (r *DiagnosticRenderer) renderCallStack(d Diagnostic) {
298+
if len(d.CallStack) == 0 {
299+
return
300+
}
301+
302+
fmt.Fprintf(r.writer, " %s\n", r.cyan("= stack:"))
303+
for i, frame := range d.CallStack {
304+
indent := " "
305+
prefix := "in"
306+
if i == 0 {
307+
prefix = "at"
308+
}
309+
310+
// Format location
311+
location := ""
312+
if frame.CallSite != nil {
313+
location = fmt.Sprintf("%s:%d:%d",
314+
frame.CallSite.File,
315+
frame.CallSite.StartLine(),
316+
frame.CallSite.StartColumn())
317+
}
318+
319+
if location != "" {
320+
fmt.Fprintf(r.writer, "%s%s %s (%s)\n", indent, prefix, r.blue(frame.FunctionName), location)
321+
} else {
322+
fmt.Fprintf(r.writer, "%s%s %s\n", indent, prefix, r.blue(frame.FunctionName))
323+
}
324+
}
325+
}
326+
295327
// Color helper methods
296328
func (r *DiagnosticRenderer) red(s string) string {
297329
if !r.useColor {

core/interpreter.go

Lines changed: 91 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ type InterpreterInput struct {
6666
InvokedCommand *ScriptCommand
6767
}
6868

69+
// CallFrame represents a function call in the Rad call stack.
70+
// Used for providing stack traces in error messages.
71+
type CallFrame struct {
72+
FunctionName string // Name of the function (or "<anonymous>" for lambdas)
73+
CallSite *Span // Where the function was called from
74+
DefSite *Span // Where the function is defined
75+
}
76+
6977
type Interpreter struct {
7078
sd *ScriptData
7179
invokedCommand *ScriptCommand
@@ -76,6 +84,9 @@ type Interpreter struct {
7684
forWhileLoopLevel int
7785
// Used to track current delimiter, currently for correct delimiter escaping handling
7886
delimiterStack *com.Stack[Delimiter]
87+
88+
// Call stack for Rad function calls (not Go stack)
89+
callStack []CallFrame
7990
}
8091

8192
func NewInterpreter(input InterpreterInput) *Interpreter {
@@ -933,6 +944,10 @@ func (i *Interpreter) getOnlyChild(node *ts.Node) *ts.Node {
933944
// emitDiagnostic renders a diagnostic and exits with error code 1.
934945
// This is the new error reporting method that replaces errorf/errorDetailsf.
935946
func (i *Interpreter) emitDiagnostic(d Diagnostic) {
947+
// Automatically attach call stack if not already present
948+
if len(d.CallStack) == 0 && len(i.callStack) > 0 {
949+
d = d.WithCallStack(i.CallStack())
950+
}
936951
renderer := NewDiagnosticRenderer(RIo.StdErr)
937952
renderer.Render(d)
938953
RExit.Exit(1)
@@ -970,6 +985,32 @@ func (i *Interpreter) emitErrorWithSecondary(code rl.Error, primaryNode *ts.Node
970985
i.emitDiagnostic(diag)
971986
}
972987

988+
// pushCallFrame pushes a new frame onto the call stack.
989+
func (i *Interpreter) pushCallFrame(name string, callSite, defSite *Span) {
990+
i.callStack = append(i.callStack, CallFrame{
991+
FunctionName: name,
992+
CallSite: callSite,
993+
DefSite: defSite,
994+
})
995+
}
996+
997+
// popCallFrame removes the top frame from the call stack.
998+
func (i *Interpreter) popCallFrame() {
999+
if len(i.callStack) > 0 {
1000+
i.callStack = i.callStack[:len(i.callStack)-1]
1001+
}
1002+
}
1003+
1004+
// CallStack returns a copy of the current call stack (most recent first).
1005+
func (i *Interpreter) CallStack() []CallFrame {
1006+
result := make([]CallFrame, len(i.callStack))
1007+
// Reverse so most recent is first
1008+
for idx, frame := range i.callStack {
1009+
result[len(i.callStack)-1-idx] = frame
1010+
}
1011+
return result
1012+
}
1013+
9731014
// emitUndefinedVariableError emits an error for an undefined variable with
9741015
// "did you mean?" suggestions for similar variable names.
9751016
func (i *Interpreter) emitUndefinedVariableError(node *ts.Node, name string) {
@@ -1296,6 +1337,11 @@ func (i *Interpreter) GetSrc() string {
12961337
return i.sd.Src
12971338
}
12981339

1340+
// GetScriptName returns the name/path of the current script.
1341+
func (i *Interpreter) GetScriptName() string {
1342+
return i.sd.ScriptName
1343+
}
1344+
12991345
func (i *Interpreter) GetSrcForNode(node *ts.Node) string {
13001346
return i.GetSrc()[node.StartByte():node.EndByte()]
13011347
}
@@ -1343,35 +1389,56 @@ func (i *Interpreter) getAssignLeftNodes(node *ts.Node) []ts.Node {
13431389
// fallbackNode is used for error reporting when the panic is not a RadPanic.
13441390
// msgArgs are optional context values to include in the error message before the panic value.
13451391
func (i *Interpreter) handlePanicRecovery(r interface{}, fallbackNode *ts.Node, msgArgs ...interface{}) {
1346-
if r != nil {
1347-
radPanic, ok := r.(*RadPanic)
1348-
if ok {
1349-
err := radPanic.Err()
1350-
msg := err.Msg().Plain()
1351-
code := rl.ErrGenericRuntime
1352-
if !com.IsBlank(string(err.Code)) {
1353-
code = err.Code
1354-
}
1355-
// Use err.Node if available, otherwise fall back to fallbackNode
1356-
node := err.Node
1357-
if node == nil {
1358-
node = fallbackNode
1359-
}
1360-
i.emitError(code, node, msg)
1392+
if r == nil {
1393+
return
1394+
}
1395+
1396+
// RadPanic is expected - it's how Rad propagates user-facing errors
1397+
if radPanic, ok := r.(*RadPanic); ok {
1398+
err := radPanic.Err()
1399+
msg := err.Msg().Plain()
1400+
code := rl.ErrGenericRuntime
1401+
if !com.IsBlank(string(err.Code)) {
1402+
code = err.Code
13611403
}
1362-
if !IsTest {
1363-
// Build error message with panic details
1364-
var msgBuilder strings.Builder
1365-
msgBuilder.WriteString("Bug: Panic:")
1366-
for _, arg := range msgArgs {
1367-
msgBuilder.WriteString(fmt.Sprintf(" %v", arg))
1368-
}
1369-
msgBuilder.WriteString(fmt.Sprintf(" %v\n%s", r, debug.Stack()))
1370-
i.emitError(rl.ErrInternalBug, fallbackNode, msgBuilder.String())
1404+
// Use err.Node if available, otherwise fall back to fallbackNode
1405+
node := err.Node
1406+
if node == nil {
1407+
node = fallbackNode
13711408
}
1409+
i.emitError(code, node, msg)
1410+
return
1411+
}
1412+
1413+
// Non-RadPanic means an internal bug - this shouldn't happen
1414+
// Skip in tests since test framework may use panics for control flow (e.g., exit simulation)
1415+
if !IsTest {
1416+
i.emitInternalBug(r, fallbackNode, msgArgs...)
13721417
}
13731418
}
13741419

1420+
// emitInternalBug reports an internal Rad bug to the user.
1421+
// This should only be called for unexpected panics that are NOT RadPanic.
1422+
func (i *Interpreter) emitInternalBug(panicValue interface{}, fallbackNode *ts.Node, msgArgs ...interface{}) {
1423+
var msgBuilder strings.Builder
1424+
msgBuilder.WriteString("This is a bug in Rad. Please report it at:\n")
1425+
msgBuilder.WriteString(" https://github.com/amterp/rad/issues\n\n")
1426+
1427+
msgBuilder.WriteString("Panic: ")
1428+
for _, arg := range msgArgs {
1429+
msgBuilder.WriteString(fmt.Sprintf("%v ", arg))
1430+
}
1431+
msgBuilder.WriteString(fmt.Sprintf("%v\n\n", panicValue))
1432+
1433+
if !IsTest {
1434+
// Include Go stack trace for debugging (skip in tests for deterministic output)
1435+
msgBuilder.WriteString("Go stack trace:\n")
1436+
msgBuilder.WriteString(string(debug.Stack()))
1437+
}
1438+
1439+
i.emitError(rl.ErrInternalBug, fallbackNode, msgBuilder.String())
1440+
}
1441+
13751442
// withCatch wraps body execution with panic catching. If catchNode is nil, just executes body.
13761443
// On RadPanic, calls onErr callback to handle the error (assign variables, run catch block, etc.).
13771444
// Propagates control flow (return/break/continue/yield) from the catch block.

core/testing/misc_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,3 +412,31 @@ print(completely_different_name)
412412
t.Errorf("Expected no suggestion for very different variable name")
413413
}
414414
}
415+
416+
func Test_Misc_StackTraceShownInNestedFunctionError(t *testing.T) {
417+
script := `
418+
fn inner():
419+
x = undefined_var
420+
421+
fn outer():
422+
inner()
423+
424+
outer()
425+
`
426+
setupAndRunCode(t, script, "--color=never")
427+
// Get the error output before it gets reset
428+
output := stdErrBuffer.String()
429+
t.Logf("Full error output:\n%s", output)
430+
// Verify basic error
431+
if !strings.Contains(output, "RAD20028") {
432+
t.Errorf("Expected RAD20028 in output")
433+
}
434+
if !strings.Contains(output, "undefined_var") {
435+
t.Errorf("Expected 'undefined_var' in output")
436+
}
437+
// Stack trace should show nested function calls
438+
if !strings.Contains(output, "= stack:") {
439+
t.Errorf("Expected '= stack:' in error output for nested function error")
440+
}
441+
assertExitCode(t, 1)
442+
}

core/type_fn.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,23 @@ func (fn RadFn) Execute(f FuncInvocation) (out RadValue) {
209209

210210
// todo this should be more shared between the two branches?
211211
if fn.BuiltInFunc == nil {
212+
// Push call frame for user-defined functions
213+
var callSite, defSite *Span
214+
if f.callNode != nil {
215+
cs := NewSpanFromNode(f.callNode, i.GetScriptName())
216+
callSite = &cs
217+
}
218+
if fn.ReprNode != nil {
219+
ds := NewSpanFromNode(fn.ReprNode, i.GetScriptName())
220+
defSite = &ds
221+
}
222+
fnName := fn.Name()
223+
if fnName == "" {
224+
fnName = "<anonymous>"
225+
}
226+
i.pushCallFrame(fnName, callSite, defSite)
227+
defer i.popCallFrame()
228+
212229
res := i.runBlock(fn.Stmts)
213230
typeCheck(i, typing.ReturnT, f.callNode, res.Val)
214231
if fn.IsBlock {

0 commit comments

Comments
 (0)