|
| 1 | +As a follow up to [#774](https://github.com/babashka/sci/issues/774), it has |
| 2 | +come up that users want to request a stack trace at the point of execution, just |
| 3 | +for debugging / printing. To facilitate this we should pre-process the stack |
| 4 | +instead of constructing during an exception such that you have it readily |
| 5 | +available. |
| 6 | + |
| 7 | +You can see the beginnings of it in this case: `(try (/ 1 0) (catch ^:sci/error |
| 8 | +Exception e))`where the error has one stack element about `/` but none of the |
| 9 | +preceding ones, which we can add during analysis time. |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +# Plan: Stack Trace at Point of Execution |
| 14 | + |
| 15 | +## Summary |
| 16 | + |
| 17 | +Add `sci.core/current-stacktrace` to return the lexical stack (enclosing forms/functions) at any point during execution. Stack info is built during analysis and inlined as a constant, so zero runtime overhead. |
| 18 | + |
| 19 | +## API |
| 20 | + |
| 21 | +```clojure |
| 22 | +;; sci.core/current-stacktrace is a special var recognized by the analyzer. |
| 23 | +;; When called from SCI code, returns the lexical stack at that point: |
| 24 | +;; => [{:ns user :name inner :file "script.clj" :line 1 :column 14} |
| 25 | +;; {:ns user :name outer :file "script.clj" :line 2 :column 14}] |
| 26 | + |
| 27 | +;; Host exposes to users as they wish: |
| 28 | +(def ctx (sci/init {:namespaces |
| 29 | + {'debug {'stacktrace sci.core/current-stacktrace}}})) |
| 30 | + |
| 31 | +;; SCI user calls it: |
| 32 | +(defn my-fn [] |
| 33 | + (debug/stacktrace)) ;; analyzer inlines the lexical stack here |
| 34 | +``` |
| 35 | + |
| 36 | +## Implementation |
| 37 | + |
| 38 | +### Approach |
| 39 | +Treat `current-stacktrace` as a **special form** recognized by the analyzer. When the analyzer encounters a call to the function, it replaces it with a constant containing the current lexical stack. Zero runtime cost. |
| 40 | + |
| 41 | +### Step 1: Track lexical stack during analysis |
| 42 | + |
| 43 | +In `src/sci/impl/analyzer.cljc`: |
| 44 | +- Add `:lexical-stack` key to `ctx` (initially `[]`) |
| 45 | +- Push stack frame when entering `defn`/`fn`/`let`/`loop` (line ~355, ~540, ~767) |
| 46 | +- Use `make-stack` from utils to create frames |
| 47 | + |
| 48 | +```clojure |
| 49 | +;; In analyze-fn*, after creating fn-name: |
| 50 | +(let [stack-frame (utils/make-stack (meta fn-expr)) |
| 51 | + stack-frame (assoc stack-frame :name fn-name) |
| 52 | + ctx (update ctx :lexical-stack (fnil conj []) stack-frame)] |
| 53 | + ...) |
| 54 | +``` |
| 55 | + |
| 56 | +### Step 2: Handle current-stacktrace calls |
| 57 | + |
| 58 | +In `src/sci/impl/analyzer.cljc`, in `analyze-call`: |
| 59 | +- Check if resolved symbol is `current-stacktrace` |
| 60 | +- If so, return a constant node with `(:lexical-stack ctx)` |
| 61 | + |
| 62 | +```clojure |
| 63 | +;; In analyze-call, after resolving f: |
| 64 | +(if (= 'sci.core/current-stacktrace (some-> f meta :sci/built-in)) |
| 65 | + (->constant (:lexical-stack ctx)) |
| 66 | + ;; ... normal call handling |
| 67 | + ) |
| 68 | +``` |
| 69 | + |
| 70 | +### Step 3: Register the function |
| 71 | + |
| 72 | +In `src/sci/impl/namespaces.cljc`: |
| 73 | +- Add `current-stacktrace` to `sci.core` namespace |
| 74 | +- Mark it with metadata so analyzer can recognize it |
| 75 | + |
| 76 | +In `src/sci/core.cljc`: |
| 77 | +- Add public `current-stacktrace` function (for documentation/API) |
| 78 | + |
| 79 | +### Files to modify |
| 80 | + |
| 81 | +| File | Changes | |
| 82 | +|------|---------| |
| 83 | +| `src/sci/impl/analyzer.cljc` | Track `:lexical-stack` in ctx, push on fn/let/loop entry, handle `current-stacktrace` calls | |
| 84 | +| `src/sci/impl/namespaces.cljc` | Add `current-stacktrace` to `sci.core` namespace with marker metadata | |
| 85 | +| `src/sci/core.cljc` | Add `current-stacktrace` for public API/docs | |
| 86 | + |
| 87 | +## Note on aliasing |
| 88 | + |
| 89 | +When host exposes `current-stacktrace` under a different name: |
| 90 | +```clojure |
| 91 | +(sci/init {:namespaces {'debug {'st sci.core/current-stacktrace}}}) |
| 92 | +``` |
| 93 | +The analyzer resolves `debug/st` to the same var, recognizes it by identity (via metadata marker), and inlines the stack. The var carries the marker, not the symbol. |
| 94 | + |
| 95 | +## Verification |
| 96 | + |
| 97 | +1. Add test in `test/sci/stacktrace_test.cljc`: |
| 98 | +```clojure |
| 99 | +(deftest current-stacktrace-test |
| 100 | + (let [ctx (sci/init {:namespaces |
| 101 | + {'debug {'stacktrace sci.core/current-stacktrace}}}) |
| 102 | + result (sci/eval-string* ctx |
| 103 | + "(defn inner [] (debug/stacktrace)) |
| 104 | + (defn outer [] (inner)) |
| 105 | + (outer)")] |
| 106 | + (is (= 'inner (:name (first result)))) |
| 107 | + (is (= 'outer (:name (second result)))))) |
| 108 | +``` |
| 109 | + |
| 110 | +2. Run: `script/test/jvm` |
| 111 | + |
| 112 | +--- |
| 113 | + |
| 114 | +## POC Results |
| 115 | + |
| 116 | +Minimal POC implemented in `analyzer.cljc` only (2 changes). Results: |
| 117 | + |
| 118 | +### With plain `fn` (line/column preserved): |
| 119 | +```clojure |
| 120 | +(sci/eval-string "((fn outer [] ((fn inner [] (sci.impl/current-stacktrace)))))") |
| 121 | +;; => [{:line 1, :column 2, :ns user, :file nil, :name outer} |
| 122 | +;; {:line 1, :column 16, :ns user, :file nil, :name inner}] |
| 123 | +``` |
| 124 | + |
| 125 | +### With `defn` (line/column lost due to macro expansion): |
| 126 | +```clojure |
| 127 | +(sci/eval-string "(defn outer [] (defn inner [] (sci.impl/current-stacktrace)) (inner)) (outer)") |
| 128 | +;; => [{:ns user, :file nil, :name outer} |
| 129 | +;; {:ns user, :file nil, :name inner}] |
| 130 | +``` |
| 131 | + |
| 132 | +### Known limitation |
| 133 | +`defn` macro expansion creates a new `fn*` form without preserving line/column from original expression. This can be fixed by passing metadata through the macro expansion. |
0 commit comments