Skip to main content

perl_dap/debug_adapter/
frames.rs

1//! Stack frame management: stack trace parsing, scopes.
2
3use super::*;
4
5impl DebugAdapter {
6    /// Handle stackTrace request
7    pub(super) fn handle_stack_trace(
8        &self,
9        seq: i64,
10        request_seq: i64,
11        arguments: Option<Value>,
12    ) -> DapMessage {
13        let args: Option<StackTraceArguments> =
14            arguments.and_then(|v| serde_json::from_value(v).ok());
15        let start_frame =
16            args.as_ref().and_then(|value| value.start_frame).unwrap_or(0).max(0) as usize;
17        let levels = args.as_ref().and_then(|value| value.levels).unwrap_or(0);
18        let requested_count = if levels <= 0 { None } else { Some(levels as usize) };
19        let mut framed_output_lines = None;
20
21        // Ask the debugger for an explicit stack snapshot when a live session is present.
22        if let Some(ref mut session) = *lock_or_recover(&self.session, "debug_adapter.session")
23            && let Some(stdin) = session.process.stdin.as_mut()
24        {
25            let commands = vec!["T".to_string()];
26            match self.send_framed_debugger_commands(stdin, &commands) {
27                Ok((begin, end)) => {
28                    framed_output_lines = self.capture_framed_debugger_output(
29                        &begin,
30                        &end,
31                        DEBUGGER_QUERY_WAIT_MS * 8,
32                    );
33                }
34                Err(error) => {
35                    tracing::warn!(%error, "Failed to send framed stackTrace command, falling back");
36                    let _ = stdin.write_all(b"T\n");
37                    let _ = stdin.flush();
38                    Self::wait_for_debugger_output_window(DEBUGGER_QUERY_WAIT_MS as u32);
39                }
40            }
41        }
42
43        let parsed_frames = if let Some(lines) = framed_output_lines.as_ref() {
44            let output = lines.join("\n");
45            let framed_frames =
46                Self::filter_user_visible_frames(Self::parse_stack_frames_from_text(&output));
47            if framed_frames.is_empty() {
48                // The framed T output contained only internal debugger frames (e.g.
49                // `@ = DB::DB called from file '...' line N` at top-level stops) or
50                // none at all.  These are filtered out by filter_user_visible_frames.
51                //
52                // Do NOT fall back to snapshot parsing here: the snapshot buffer
53                // contains the entire session history, including the initial implicit
54                // stop context line (e.g. line 4 in a 7-line fixture), which appears
55                // BEFORE the current breakpoint context line (e.g. line 5).
56                // Snapshot-based parsing returns frames in output order, so the FIRST
57                // frame would be the stale line-4 context, not the current line-5 stop.
58                //
59                // The output reader already parsed the most recent context line and
60                // stored it in session.stack_frames.  Returning an empty vec here
61                // causes the caller to fall through to that authoritative source.
62                Vec::new()
63            } else {
64                framed_frames
65            }
66        } else {
67            // Snapshot buffer is unreliable when framed transport fails: it holds
68            // the full session history so snapshot-based parsing returns frames in
69            // buffer order — the stale pre-stop context line appears before the
70            // current stop line, producing a wrong first frame.  Return empty so
71            // the caller falls through to session.stack_frames, which the output
72            // reader populates with the authoritative current-stop frame.
73            Vec::new()
74        };
75
76        let stack_frames = if !parsed_frames.is_empty() {
77            // Keep parsed frames as best-effort latest snapshot.
78            if let Some(ref mut session) = *lock_or_recover(&self.session, "debug_adapter.session")
79            {
80                session.stack_frames = parsed_frames.clone();
81            }
82            parsed_frames
83        } else if let Some(ref session) = *lock_or_recover(&self.session, "debug_adapter.session") {
84            Self::filter_user_visible_frames(session.stack_frames.clone())
85        } else if let Some(pid) = *lock_or_recover(&self.attached_pid, "debug_adapter.attached_pid")
86        {
87            vec![StackFrame {
88                id: Self::i64_to_i32_saturating(i64::from(pid)),
89                name: format!("attached::process::{pid}"),
90                source: Source {
91                    name: Some(format!("pid:{pid}")),
92                    path: format!("pid://{pid}"),
93                    source_reference: None,
94                },
95                line: 1,
96                column: 1,
97                end_line: None,
98                end_column: None,
99            }]
100        } else {
101            // No active session — return honest empty list per DAP spec
102            Vec::new()
103        };
104        // Capture full depth before pagination so totalFrames reports the real
105        // stack depth, not the size of the paginated window (DAP spec §StackTraceResponse:
106        // "totalFrames: The total number of frames available in the stack").
107        let total_frames = stack_frames.len();
108        let stack_frames = Self::paginate_stack_frames(stack_frames, start_frame, requested_count);
109
110        DapMessage::Response {
111            seq,
112            request_seq,
113            success: true,
114            command: "stackTrace".to_string(),
115            body: Some(json!({
116                "stackFrames": stack_frames,
117                "totalFrames": total_frames
118            })),
119            message: None,
120        }
121    }
122
123    /// Handle scopes request
124    pub fn handle_scopes(
125        &self,
126        seq: i64,
127        request_seq: i64,
128        arguments: Option<Value>,
129    ) -> DapMessage {
130        let args: ScopesArguments = match arguments.and_then(|v| serde_json::from_value(v).ok()) {
131            Some(a) => a,
132            None => {
133                return DapMessage::Response {
134                    seq,
135                    request_seq,
136                    success: false,
137                    command: "scopes".to_string(),
138                    body: None,
139                    message: Some("Missing frameId".to_string()),
140                };
141            }
142        };
143
144        let frame_id = Self::i64_to_i32_saturating(args.frame_id);
145
146        // AC8.3: Hierarchical scope inspection
147        // Use VariableReference codec to encode scope refs into disjoint wire bands.
148        use crate::debug_adapter::var_ref::{ScopeKind, VariableReference};
149        let locals_ref =
150            VariableReference::Scope { frame_id, kind: ScopeKind::Locals }.encode().unwrap_or(0);
151        let package_ref =
152            VariableReference::Scope { frame_id, kind: ScopeKind::Package }.encode().unwrap_or(0);
153        let globals_ref =
154            VariableReference::Scope { frame_id, kind: ScopeKind::Globals }.encode().unwrap_or(0);
155
156        let scopes_body = ScopesResponseBody {
157            scopes: vec![
158                Scope {
159                    name: "Locals".to_string(),
160                    presentation_hint: Some("locals".to_string()),
161                    variables_reference: i64::from(locals_ref),
162                    expensive: false,
163                    named_variables: None,
164                    indexed_variables: None,
165                },
166                Scope {
167                    name: "Package".to_string(),
168                    presentation_hint: None,
169                    variables_reference: i64::from(package_ref),
170                    expensive: true,
171                    named_variables: None,
172                    indexed_variables: None,
173                },
174                Scope {
175                    name: "Globals".to_string(),
176                    presentation_hint: None,
177                    variables_reference: i64::from(globals_ref),
178                    expensive: true,
179                    named_variables: None,
180                    indexed_variables: None,
181                },
182            ],
183        };
184
185        DapMessage::Response {
186            seq,
187            request_seq,
188            success: true,
189            command: "scopes".to_string(),
190            body: serde_json::to_value(&scopes_body).ok(),
191            message: None,
192        }
193    }
194}
195
196impl DebugAdapter {
197    fn paginate_stack_frames(
198        stack_frames: Vec<StackFrame>,
199        start_frame: usize,
200        levels: Option<usize>,
201    ) -> Vec<StackFrame> {
202        let iter = stack_frames.into_iter().skip(start_frame);
203        match levels {
204            Some(limit) => iter.take(limit).collect(),
205            None => iter.collect(),
206        }
207    }
208}
209
210#[cfg(test)]
211mod pagination_tests {
212    use super::*;
213
214    fn make_frame(id: i32, name: &str) -> StackFrame {
215        StackFrame {
216            id,
217            name: name.to_string(),
218            source: Source {
219                name: Some("test.pl".to_string()),
220                path: "/tmp/test.pl".to_string(),
221                source_reference: None,
222            },
223            line: id,
224            column: 1,
225            end_line: None,
226            end_column: None,
227        }
228    }
229
230    /// Regression: paginate_stack_frames used to be called BEFORE capturing the
231    /// full depth, so totalFrames reported the slice length instead of the full
232    /// stack depth.  This unit test locks the correct invariant:
233    ///   totalFrames == pre-pagination length >= paginated-window length
234    #[test]
235    fn total_frames_is_pre_pagination_length() -> Result<(), Box<dyn std::error::Error>> {
236        let all_frames: Vec<StackFrame> = (1..=5).map(|i| make_frame(i, "main::step")).collect();
237        let total_before = all_frames.len();
238
239        // Paginate to window of 2, starting at offset 0.
240        let paginated = DebugAdapter::paginate_stack_frames(all_frames, 0, Some(2));
241
242        assert_eq!(paginated.len(), 2, "paginated window should be 2");
243        assert_eq!(total_before, 5, "total_frames must be full depth (5)");
244        assert!(
245            total_before >= paginated.len(),
246            "total_frames ({total_before}) must be >= paginated len ({})",
247            paginated.len()
248        );
249        Ok(())
250    }
251
252    /// startFrame beyond the stack depth: paginated slice is empty, but the
253    /// pre-pagination total is still the real depth.
254    #[test]
255    fn total_frames_with_start_frame_beyond_depth() -> Result<(), Box<dyn std::error::Error>> {
256        let all_frames: Vec<StackFrame> = (1..=3).map(|i| make_frame(i, "main::step")).collect();
257        let total_before = all_frames.len();
258
259        let paginated = DebugAdapter::paginate_stack_frames(all_frames, 10, Some(2));
260
261        assert_eq!(paginated.len(), 0, "paginated slice beyond depth should be empty");
262        assert_eq!(total_before, 3, "total_frames must still report full depth when start > depth");
263        Ok(())
264    }
265
266    /// No pagination (None levels): total_frames == paginated length (no difference).
267    #[test]
268    fn total_frames_no_pagination_unchanged() -> Result<(), Box<dyn std::error::Error>> {
269        let all_frames: Vec<StackFrame> = (1..=4).map(|i| make_frame(i, "main::step")).collect();
270        let total_before = all_frames.len();
271
272        let paginated = DebugAdapter::paginate_stack_frames(all_frames, 0, None);
273
274        assert_eq!(paginated.len(), total_before, "no pagination: total == paginated");
275        Ok(())
276    }
277}