Skip to main content

perl_dap/stack/
classifier.rs

1//! Frame classifier for distinguishing user code from library code.
2//!
3//! This module provides utilities for classifying stack frames based on their
4//! source location, helping debuggers present relevant frames to users.
5
6use super::{StackFrame, StackFramePresentationHint};
7
8/// Categories for stack frame classification.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum FrameCategory {
11    /// User code (the developer's own code)
12    User,
13    /// Library code (third-party modules)
14    Library,
15    /// Core Perl code (built-in modules and internals)
16    Core,
17    /// Eval-generated code
18    Eval,
19    /// Unknown origin
20    Unknown,
21}
22
23impl FrameCategory {
24    /// Returns the appropriate presentation hint for this category.
25    #[must_use]
26    pub fn presentation_hint(&self) -> StackFramePresentationHint {
27        match self {
28            FrameCategory::User => StackFramePresentationHint::Normal,
29            FrameCategory::Eval => StackFramePresentationHint::Label,
30            FrameCategory::Library | FrameCategory::Core | FrameCategory::Unknown => {
31                StackFramePresentationHint::Subtle
32            }
33        }
34    }
35
36    /// Returns true if this category represents user-written code.
37    #[must_use]
38    pub fn is_user_code(&self) -> bool {
39        matches!(self, FrameCategory::User)
40    }
41
42    /// Returns true if this category represents external code.
43    #[must_use]
44    pub fn is_external(&self) -> bool {
45        matches!(self, FrameCategory::Library | FrameCategory::Core)
46    }
47}
48
49/// Trait for classifying stack frames.
50///
51/// Implementations determine whether a stack frame represents user code,
52/// library code, or core Perl internals.
53pub trait FrameClassifier {
54    /// Classifies a stack frame.
55    ///
56    /// # Arguments
57    ///
58    /// * `frame` - The frame to classify
59    ///
60    /// # Returns
61    ///
62    /// The classification category for the frame.
63    fn classify(&self, frame: &StackFrame) -> FrameCategory;
64
65    /// Applies classification to a frame, setting its presentation hint.
66    ///
67    /// # Arguments
68    ///
69    /// * `frame` - The frame to classify and update
70    ///
71    /// # Returns
72    ///
73    /// The frame with updated presentation hint.
74    fn apply_classification(&self, frame: StackFrame) -> StackFrame {
75        let category = self.classify(&frame);
76        frame.with_presentation_hint(category.presentation_hint())
77    }
78
79    /// Classifies and filters a list of frames.
80    ///
81    /// # Arguments
82    ///
83    /// * `frames` - The frames to classify
84    /// * `include_external` - Whether to include library/core frames
85    ///
86    /// # Returns
87    ///
88    /// Classified frames with appropriate presentation hints.
89    fn classify_all(&self, frames: Vec<StackFrame>, include_external: bool) -> Vec<StackFrame> {
90        frames
91            .into_iter()
92            .map(|f| self.apply_classification(f))
93            .filter(|f| include_external || f.is_user_code())
94            .collect()
95    }
96}
97
98/// Default Perl frame classifier.
99///
100/// This classifier uses path-based heuristics to determine frame categories:
101///
102/// - Core modules: Paths containing `/perl/`, `/perl5/`, or standard module names
103/// - Library code: Paths in common library directories (lib, vendor, local)
104/// - Eval code: Files named `(eval N)` or with eval origin
105/// - User code: Everything else (assumed to be project code)
106#[derive(Debug, Default)]
107pub struct PerlFrameClassifier {
108    /// Paths considered to be user code directories
109    user_paths: Vec<String>,
110    /// Paths considered to be library directories
111    library_paths: Vec<String>,
112}
113
114impl PerlFrameClassifier {
115    /// Creates a new classifier with default settings.
116    #[must_use]
117    pub fn new() -> Self {
118        Self { user_paths: Vec::new(), library_paths: Vec::new() }
119    }
120
121    /// Adds a path that should be considered user code.
122    ///
123    /// Files under this path will be classified as user code.
124    #[must_use]
125    pub fn with_user_path(mut self, path: impl Into<String>) -> Self {
126        self.user_paths.push(path.into());
127        self
128    }
129
130    /// Adds a path that should be considered library code.
131    ///
132    /// Files under this path will be classified as library code.
133    #[must_use]
134    pub fn with_library_path(mut self, path: impl Into<String>) -> Self {
135        self.library_paths.push(path.into());
136        self
137    }
138
139    fn is_under_root(path: &str, root: &str) -> bool {
140        let raw_root = root;
141        let root = root.trim_end_matches(['/', '\\']);
142        if root.is_empty() {
143            return Self::root_matches_filesystem_root(path, raw_root);
144        }
145
146        if path == root {
147            return true;
148        }
149
150        if let Some(remainder) = path.strip_prefix(root) {
151            return remainder.starts_with(['/', '\\']);
152        }
153
154        false
155    }
156
157    fn root_matches_filesystem_root(path: &str, root: &str) -> bool {
158        let uses_unix_root = !root.is_empty() && root.chars().all(|ch| ch == '/');
159        let uses_windows_root = !root.is_empty() && root.chars().all(|ch| ch == '\\');
160        (uses_unix_root && path.starts_with('/')) || (uses_windows_root && path.starts_with('\\'))
161    }
162
163    /// Checks if a path is under any of the user paths.
164    fn is_under_user_path(&self, path: &str) -> bool {
165        self.user_paths.iter().any(|user_path| Self::is_under_root(path, user_path))
166    }
167
168    /// Checks if a path is under any of the library paths.
169    fn is_under_library_path(&self, path: &str) -> bool {
170        self.library_paths.iter().any(|lib_path| Self::is_under_root(path, lib_path))
171    }
172
173    /// Checks if a path looks like a Perl core module.
174    fn is_core_path(path: &str) -> bool {
175        // Common core module path patterns
176        let core_patterns = ["/perl/", "/perl5/", "/site_perl/", "/vendor_perl/", "/lib/perl5/"];
177
178        // Core module packages
179        let core_packages = [
180            "strict.pm",
181            "warnings.pm",
182            "vars.pm",
183            "Exporter.pm",
184            "Carp.pm",
185            "constant.pm",
186            "overload.pm",
187            "AutoLoader.pm",
188            "base.pm",
189            "parent.pm",
190            "feature.pm",
191            "utf8.pm",
192            "encoding.pm",
193            "lib.pm",
194        ];
195
196        // Check path patterns
197        for pattern in &core_patterns {
198            if path.contains(pattern) {
199                return true;
200            }
201        }
202
203        // Check if it's a known core module
204        for module in &core_packages {
205            if path.ends_with(module) {
206                return true;
207            }
208        }
209
210        false
211    }
212
213    /// Checks if a path looks like a library module.
214    fn is_library_path(path: &str) -> bool {
215        // Common library path patterns
216        let library_patterns =
217            ["/local/lib/", "/vendor/", "/cpan/", "/.cpanm/", "/extlib/", "/fatlib/"];
218
219        for pattern in &library_patterns {
220            if path.contains(pattern) {
221                return true;
222            }
223        }
224
225        false
226    }
227
228    /// Checks if a path looks like an eval source.
229    fn is_eval_source(path: &str) -> bool {
230        path.starts_with("(eval") || path.contains("(eval ")
231    }
232}
233
234impl FrameClassifier for PerlFrameClassifier {
235    fn classify(&self, frame: &StackFrame) -> FrameCategory {
236        // Check source
237        let path = match frame.file_path() {
238            Some(p) => p,
239            None => return FrameCategory::Unknown,
240        };
241
242        // Check for eval
243        if Self::is_eval_source(path) {
244            return FrameCategory::Eval;
245        }
246
247        // Also check source origin
248        if frame.source.as_ref().is_some_and(|s| s.is_eval()) {
249            return FrameCategory::Eval;
250        }
251
252        // Check explicit user paths first
253        if self.is_under_user_path(path) {
254            return FrameCategory::User;
255        }
256
257        // Check explicit library paths
258        if self.is_under_library_path(path) {
259            return FrameCategory::Library;
260        }
261
262        // Check for core modules
263        if Self::is_core_path(path) {
264            return FrameCategory::Core;
265        }
266
267        // Check for library modules
268        if Self::is_library_path(path) {
269            return FrameCategory::Library;
270        }
271
272        // Default to user code if we can't determine otherwise
273        // This is intentional: we want to show frames by default rather than hide them
274        FrameCategory::User
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::stack::Source;
282
283    fn frame_with_path(path: &str) -> StackFrame {
284        StackFrame::new(1, "test", Some(Source::new(path)), 1)
285    }
286
287    #[test]
288    fn test_classify_user_code() {
289        let classifier = PerlFrameClassifier::new();
290
291        // Regular project files should be user code
292        let frame = frame_with_path("/home/user/project/lib/MyApp/Module.pm");
293        assert_eq!(classifier.classify(&frame), FrameCategory::User);
294
295        let frame = frame_with_path("./script.pl");
296        assert_eq!(classifier.classify(&frame), FrameCategory::User);
297    }
298
299    #[test]
300    fn test_classify_core_modules() {
301        let classifier = PerlFrameClassifier::new();
302
303        let frame = frame_with_path("/usr/lib/perl5/strict.pm");
304        assert_eq!(classifier.classify(&frame), FrameCategory::Core);
305
306        let frame = frame_with_path("/usr/share/perl/5.30/Exporter.pm");
307        assert_eq!(classifier.classify(&frame), FrameCategory::Core);
308
309        let frame = frame_with_path("/usr/lib/perl/5.30/warnings.pm");
310        assert_eq!(classifier.classify(&frame), FrameCategory::Core);
311    }
312
313    #[test]
314    fn test_classify_library_code() {
315        let classifier = PerlFrameClassifier::new();
316
317        // Use paths that match library patterns but not core patterns
318        let frame = frame_with_path("/home/user/project/extlib/Moose.pm");
319        assert_eq!(classifier.classify(&frame), FrameCategory::Library);
320
321        let frame = frame_with_path("/home/user/.cpanm/work/1234/Foo-1.0/lib/Foo.pm");
322        assert_eq!(classifier.classify(&frame), FrameCategory::Library);
323    }
324
325    #[test]
326    fn test_classify_eval() {
327        let classifier = PerlFrameClassifier::new();
328
329        let frame = frame_with_path("(eval 42)");
330        assert_eq!(classifier.classify(&frame), FrameCategory::Eval);
331
332        let frame = frame_with_path("(eval 10)[script.pl:5]");
333        assert_eq!(classifier.classify(&frame), FrameCategory::Eval);
334
335        // Frame with eval origin
336        let mut frame = frame_with_path("/path/file.pm");
337        frame.source = Some(Source::new("/path/file.pm").with_origin("eval"));
338        assert_eq!(classifier.classify(&frame), FrameCategory::Eval);
339    }
340
341    #[test]
342    fn test_classify_no_source() {
343        let classifier = PerlFrameClassifier::new();
344
345        let frame = StackFrame::new(1, "test", None, 1);
346        assert_eq!(classifier.classify(&frame), FrameCategory::Unknown);
347    }
348
349    #[test]
350    fn test_explicit_user_path() {
351        let classifier = PerlFrameClassifier::new().with_user_path("/my/project/");
352
353        // Should be classified as user even if path looks like library
354        let frame = frame_with_path("/my/project/local/lib/perl5/MyModule.pm");
355        assert_eq!(classifier.classify(&frame), FrameCategory::User);
356    }
357
358    #[test]
359    fn test_explicit_library_path() {
360        let classifier = PerlFrameClassifier::new().with_library_path("/opt/mylibs/");
361
362        let frame = frame_with_path("/opt/mylibs/SomeModule.pm");
363        assert_eq!(classifier.classify(&frame), FrameCategory::Library);
364    }
365
366    #[test]
367    fn test_explicit_path_matching_uses_directory_boundaries() {
368        let classifier = PerlFrameClassifier::new().with_user_path("/workspace/project");
369        let frame = frame_with_path("/workspace/project2/extlib/App.pm");
370        assert_eq!(classifier.classify(&frame), FrameCategory::Library);
371
372        let classifier = PerlFrameClassifier::new().with_library_path("/opt/lib");
373        let frame = frame_with_path("/opt/library/Foo.pm");
374        assert_eq!(classifier.classify(&frame), FrameCategory::User);
375    }
376
377    #[test]
378    fn test_explicit_path_matching_preserves_filesystem_roots() {
379        let classifier = PerlFrameClassifier::new().with_user_path("/");
380        let frame = frame_with_path("/workspace/project/extlib/App.pm");
381        assert_eq!(classifier.classify(&frame), FrameCategory::User);
382
383        let classifier = PerlFrameClassifier::new().with_library_path("\\");
384        let frame = frame_with_path("\\workspace\\project\\App.pm");
385        assert_eq!(classifier.classify(&frame), FrameCategory::Library);
386    }
387
388    #[test]
389    fn test_frame_category_presentation_hint() {
390        assert_eq!(FrameCategory::User.presentation_hint(), StackFramePresentationHint::Normal);
391        assert_eq!(FrameCategory::Library.presentation_hint(), StackFramePresentationHint::Subtle);
392        assert_eq!(FrameCategory::Core.presentation_hint(), StackFramePresentationHint::Subtle);
393        assert_eq!(FrameCategory::Eval.presentation_hint(), StackFramePresentationHint::Label);
394    }
395
396    #[test]
397    fn test_apply_classification() {
398        let classifier = PerlFrameClassifier::new();
399        let frame = frame_with_path("/usr/lib/perl5/strict.pm");
400
401        let classified = classifier.apply_classification(frame);
402        assert_eq!(classified.presentation_hint, Some(StackFramePresentationHint::Subtle));
403    }
404
405    #[test]
406    fn test_classify_all() {
407        let classifier = PerlFrameClassifier::new();
408
409        let frames = vec![
410            frame_with_path("/home/user/project/script.pl"),
411            frame_with_path("/usr/lib/perl5/strict.pm"),
412            frame_with_path("/home/user/project/lib/App.pm"),
413        ];
414
415        // With external frames
416        let classified = classifier.classify_all(frames.clone(), true);
417        assert_eq!(classified.len(), 3);
418
419        // Without external frames
420        let classified = classifier.classify_all(frames, false);
421        assert_eq!(classified.len(), 2);
422    }
423
424    #[test]
425    fn test_is_user_code() {
426        assert!(FrameCategory::User.is_user_code());
427        assert!(!FrameCategory::Library.is_user_code());
428        assert!(!FrameCategory::Core.is_user_code());
429        assert!(!FrameCategory::Eval.is_user_code());
430    }
431
432    #[test]
433    fn test_is_external() {
434        assert!(!FrameCategory::User.is_external());
435        assert!(FrameCategory::Library.is_external());
436        assert!(FrameCategory::Core.is_external());
437        assert!(!FrameCategory::Eval.is_external());
438    }
439}