Skip to main content

perl_dap_stack/
lib.rs

1//! Stack trace handling for Perl DAP
2//!
3//! This crate provides types and utilities for parsing and managing stack traces
4//! in the Debug Adapter Protocol (DAP) format for Perl debugging.
5//!
6//! # Overview
7//!
8//! The crate provides:
9//!
10//! - [`StackFrame`] - Represents a single stack frame
11//! - [`StackTraceProvider`] - Trait for stack trace retrieval
12//! - [`PerlStackParser`] - Parser for Perl debugger stack output
13//! - [`FrameClassifier`] - Classifies frames as user code vs library code
14//!
15//! # Example
16//!
17//! ```rust
18//! use perl_dap_stack::{StackFrame, Source, PerlStackParser};
19//!
20//! let mut parser = PerlStackParser::new();
21//! let output = "  #0  main::foo at /path/script.pl line 42";
22//!
23//! if let Some(frame) = parser.parse_frame(output, 0) {
24//!     assert_eq!(frame.name, "main::foo");
25//!     assert_eq!(frame.line, 42);
26//! }
27//! ```
28
29mod classifier;
30mod parser;
31mod visibility;
32
33pub use classifier::{FrameCategory, FrameClassifier, PerlFrameClassifier};
34pub use parser::{PerlStackParser, StackParseError};
35pub use visibility::{
36    filter_user_visible_frames, is_internal_frame, is_internal_frame_name_and_path,
37};
38
39use serde::{Deserialize, Serialize};
40
41/// Represents a stack frame in the call stack.
42///
43/// This struct follows the DAP specification for stack frames and includes
44/// all necessary information for debugger navigation.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct StackFrame {
48    /// Unique identifier for this frame within the debug session
49    pub id: i64,
50
51    /// The name of the frame (typically the function name)
52    pub name: String,
53
54    /// The source file associated with this frame
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub source: Option<Source>,
57
58    /// The 1-based line number in the source file
59    pub line: i64,
60
61    /// The 1-based column number (defaults to 1)
62    pub column: i64,
63
64    /// The optional end line (for multi-line frames)
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub end_line: Option<i64>,
67
68    /// The optional end column
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub end_column: Option<i64>,
71
72    /// Whether the frame can be restarted
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub can_restart: Option<bool>,
75
76    /// Presentation hint for UI rendering
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub presentation_hint: Option<StackFramePresentationHint>,
79
80    /// Module information
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub module_id: Option<String>,
83}
84
85impl StackFrame {
86    /// Creates a new stack frame with the given ID, name, and location.
87    #[must_use]
88    pub fn new(id: i64, name: impl Into<String>, source: Option<Source>, line: i64) -> Self {
89        Self {
90            id,
91            name: name.into(),
92            source,
93            line,
94            column: 1,
95            end_line: None,
96            end_column: None,
97            can_restart: None,
98            presentation_hint: None,
99            module_id: None,
100        }
101    }
102
103    /// Creates a stack frame for a Perl subroutine.
104    #[must_use]
105    pub fn for_subroutine(id: i64, package: &str, sub_name: &str, file: &str, line: i64) -> Self {
106        let name = if package.is_empty() || package == "main" {
107            sub_name.to_string()
108        } else {
109            format!("{}::{}", package, sub_name)
110        };
111
112        Self::new(id, name, Some(Source::new(file)), line)
113    }
114
115    /// Sets the column for this frame.
116    #[must_use]
117    pub fn with_column(mut self, column: i64) -> Self {
118        self.column = column;
119        self
120    }
121
122    /// Sets the end position for this frame.
123    #[must_use]
124    pub fn with_end(mut self, end_line: i64, end_column: i64) -> Self {
125        self.end_line = Some(end_line);
126        self.end_column = Some(end_column);
127        self
128    }
129
130    /// Sets the presentation hint for this frame.
131    #[must_use]
132    pub fn with_presentation_hint(mut self, hint: StackFramePresentationHint) -> Self {
133        self.presentation_hint = Some(hint);
134        self
135    }
136
137    /// Sets the module ID for this frame.
138    #[must_use]
139    pub fn with_module(mut self, module_id: impl Into<String>) -> Self {
140        self.module_id = Some(module_id.into());
141        self
142    }
143
144    /// Returns the full qualified name of this frame.
145    #[must_use]
146    pub fn qualified_name(&self) -> &str {
147        &self.name
148    }
149
150    /// Returns the file path if available.
151    #[must_use]
152    pub fn file_path(&self) -> Option<&str> {
153        self.source.as_ref().and_then(|s| s.path.as_deref())
154    }
155
156    /// Returns true if this frame represents user code (not library/core).
157    #[must_use]
158    pub fn is_user_code(&self) -> bool {
159        self.presentation_hint.as_ref() != Some(&StackFramePresentationHint::Subtle)
160    }
161}
162
163impl Default for StackFrame {
164    fn default() -> Self {
165        Self::new(0, "<unknown>", None, 0)
166    }
167}
168
169/// Presentation hints for stack frame display.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "camelCase")]
172pub enum StackFramePresentationHint {
173    /// Normal frame (user code)
174    Normal,
175    /// Label frame (e.g., exception handler)
176    Label,
177    /// Subtle frame (library code, typically collapsed)
178    Subtle,
179}
180
181/// Represents a source file in the debugging context.
182#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct Source {
185    /// The short name of the source file
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub name: Option<String>,
188
189    /// The full path to the source file
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub path: Option<String>,
192
193    /// A reference ID for retrieving source content dynamically
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub source_reference: Option<i64>,
196
197    /// The origin of the source (e.g., "eval", "require")
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub origin: Option<String>,
200
201    /// Presentation hint for the source
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub presentation_hint: Option<SourcePresentationHint>,
204}
205
206impl Source {
207    /// Creates a new source from a file path.
208    #[must_use]
209    pub fn new(path: impl Into<String>) -> Self {
210        let path_str = path.into();
211        let name =
212            std::path::Path::new(&path_str).file_name().and_then(|n| n.to_str()).map(String::from);
213
214        Self {
215            name,
216            path: Some(path_str),
217            source_reference: None,
218            origin: None,
219            presentation_hint: None,
220        }
221    }
222
223    /// Creates a source with a dynamic reference (no file path).
224    #[must_use]
225    pub fn from_reference(reference: i64, name: impl Into<String>) -> Self {
226        Self {
227            name: Some(name.into()),
228            path: None,
229            source_reference: Some(reference),
230            origin: None,
231            presentation_hint: None,
232        }
233    }
234
235    /// Sets the origin for this source.
236    #[must_use]
237    pub fn with_origin(mut self, origin: impl Into<String>) -> Self {
238        self.origin = Some(origin.into());
239        self
240    }
241
242    /// Sets the presentation hint.
243    #[must_use]
244    pub fn with_presentation_hint(mut self, hint: SourcePresentationHint) -> Self {
245        self.presentation_hint = Some(hint);
246        self
247    }
248
249    /// Returns true if this source is from an eval.
250    #[must_use]
251    pub fn is_eval(&self) -> bool {
252        self.origin.as_deref() == Some("eval")
253            || self.path.as_ref().is_some_and(|p| p.contains("(eval"))
254    }
255
256    /// Returns true if this source has a file on disk.
257    #[must_use]
258    pub fn has_file(&self) -> bool {
259        self.path.is_some() && !self.is_eval()
260    }
261}
262
263/// Presentation hints for source display.
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub enum SourcePresentationHint {
267    /// Normal source file
268    Normal,
269    /// Emphasize this source (e.g., current file)
270    Emphasize,
271    /// Deemphasize this source (e.g., library code)
272    Deemphasize,
273}
274
275/// Trait for providing stack traces.
276///
277/// Implementations of this trait retrieve stack trace information from
278/// a debugging session.
279pub trait StackTraceProvider {
280    /// The error type for stack trace retrieval.
281    type Error;
282
283    /// Gets the current stack trace.
284    ///
285    /// # Arguments
286    ///
287    /// * `thread_id` - The thread to get the stack trace for
288    /// * `start_frame` - The starting frame index (0-based)
289    /// * `levels` - Maximum number of frames to return (None = all)
290    ///
291    /// # Returns
292    ///
293    /// A vector of stack frames, ordered from innermost (current) to outermost.
294    fn get_stack_trace(
295        &self,
296        thread_id: i64,
297        start_frame: usize,
298        levels: Option<usize>,
299    ) -> Result<Vec<StackFrame>, Self::Error>;
300
301    /// Gets the total number of frames in the stack.
302    ///
303    /// # Arguments
304    ///
305    /// * `thread_id` - The thread to query
306    fn total_frames(&self, thread_id: i64) -> Result<usize, Self::Error>;
307
308    /// Gets a single frame by ID.
309    ///
310    /// # Arguments
311    ///
312    /// * `frame_id` - The frame identifier
313    fn get_frame(&self, frame_id: i64) -> Result<Option<StackFrame>, Self::Error>;
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_stack_frame_new() {
322        let frame = StackFrame::new(1, "main::foo", Some(Source::new("/path/to/file.pl")), 42);
323
324        assert_eq!(frame.id, 1);
325        assert_eq!(frame.name, "main::foo");
326        assert_eq!(frame.line, 42);
327        assert_eq!(frame.column, 1);
328        assert!(frame.source.is_some());
329    }
330
331    #[test]
332    fn test_stack_frame_for_subroutine() {
333        let frame =
334            StackFrame::for_subroutine(1, "My::Package", "do_stuff", "/lib/My/Package.pm", 100);
335
336        assert_eq!(frame.name, "My::Package::do_stuff");
337        assert_eq!(frame.line, 100);
338        assert_eq!(frame.file_path(), Some("/lib/My/Package.pm"));
339    }
340
341    #[test]
342    fn test_stack_frame_for_main() {
343        let frame = StackFrame::for_subroutine(1, "main", "run", "/script.pl", 10);
344
345        assert_eq!(frame.name, "run");
346    }
347
348    #[test]
349    fn test_stack_frame_with_presentation_hint() {
350        let frame = StackFrame::new(1, "foo", None, 1)
351            .with_presentation_hint(StackFramePresentationHint::Subtle);
352
353        assert_eq!(frame.presentation_hint, Some(StackFramePresentationHint::Subtle));
354        assert!(!frame.is_user_code());
355    }
356
357    #[test]
358    fn test_source_new() {
359        let source = Source::new("/path/to/file.pm");
360
361        assert_eq!(source.path, Some("/path/to/file.pm".to_string()));
362        assert_eq!(source.name, Some("file.pm".to_string()));
363    }
364
365    #[test]
366    fn test_source_is_eval() {
367        let eval_source = Source::new("(eval 42)");
368        assert!(eval_source.is_eval());
369
370        let file_source = Source::new("/path/to/file.pl");
371        assert!(!file_source.is_eval());
372
373        let origin_eval = Source::new("/path/to/file.pl").with_origin("eval");
374        assert!(origin_eval.is_eval());
375    }
376
377    #[test]
378    fn test_source_has_file() {
379        let file_source = Source::new("/path/to/file.pl");
380        assert!(file_source.has_file());
381
382        let eval_source = Source::new("(eval 42)");
383        assert!(!eval_source.has_file());
384
385        let ref_source = Source::from_reference(1, "dynamic");
386        assert!(!ref_source.has_file());
387    }
388
389    #[test]
390    fn test_source_from_reference() {
391        let source = Source::from_reference(42, "eval code");
392
393        assert_eq!(source.source_reference, Some(42));
394        assert_eq!(source.name, Some("eval code".to_string()));
395        assert!(source.path.is_none());
396    }
397}