Skip to main content

perl_dap/breakpoint/
validator.rs

1//! Breakpoint validation using AST analysis
2//!
3//! This module provides AST-based validation for breakpoint locations.
4//! It checks whether a given line number contains executable code or is
5//! a non-executable location like a comment, blank line, or heredoc interior.
6
7use super::BreakpointError;
8use perl_parser::Parser;
9use perl_parser::ast::{Node, NodeKind};
10use ropey::Rope;
11
12/// Reason why a breakpoint was rejected or adjusted
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ValidationReason {
15    /// The line is blank (whitespace only)
16    BlankLine,
17    /// The line contains only comments
18    CommentLine,
19    /// The breakpoint is inside heredoc content
20    HeredocInterior,
21    /// The line is inside a POD documentation section
22    PodLine,
23    /// The line number exceeds the file length
24    LineOutOfRange,
25    /// Unable to parse the source file
26    ParseError,
27    /// A conditional breakpoint expression is invalid
28    InvalidCondition,
29}
30
31impl std::fmt::Display for ValidationReason {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            ValidationReason::BlankLine => write!(f, "Breakpoint set on blank line"),
35            ValidationReason::CommentLine => write!(f, "Breakpoint set on comment or blank line"),
36            ValidationReason::HeredocInterior => write!(f, "Breakpoint set inside heredoc content"),
37            ValidationReason::PodLine => write!(f, "Breakpoint set inside POD documentation"),
38            ValidationReason::LineOutOfRange => write!(f, "Line number exceeds file length"),
39            ValidationReason::ParseError => write!(f, "Unable to parse source file"),
40            ValidationReason::InvalidCondition => {
41                write!(f, "Conditional breakpoint expression is invalid")
42            }
43        }
44    }
45}
46
47/// Result of breakpoint validation
48#[derive(Debug, Clone)]
49pub struct BreakpointValidation {
50    /// Whether the breakpoint is valid and can be set
51    pub verified: bool,
52    /// The line number (may be adjusted to nearest valid line)
53    pub line: i64,
54    /// Column number (optional)
55    pub column: Option<i64>,
56    /// Reason for rejection if not verified
57    pub reason: Option<ValidationReason>,
58    /// Human-readable message describing the validation result
59    pub message: Option<String>,
60}
61
62impl BreakpointValidation {
63    /// Create a successful validation result
64    pub fn verified(line: i64, column: Option<i64>) -> Self {
65        Self { verified: true, line, column, reason: None, message: None }
66    }
67
68    /// Create a failed validation result
69    pub fn rejected(line: i64, reason: ValidationReason) -> Self {
70        let message = Some(reason.to_string());
71        Self { verified: false, line, column: None, reason: Some(reason), message }
72    }
73
74    /// Create a validation result with an adjusted line
75    pub fn adjusted(new_line: i64, reason: ValidationReason) -> Self {
76        let message = Some(format!("{}, adjusted to line {}", reason, new_line));
77        Self { verified: true, line: new_line, column: None, reason: Some(reason), message }
78    }
79}
80
81/// Trait for breakpoint validation
82pub trait BreakpointValidator {
83    /// Validate a breakpoint at the given line number (1-based)
84    fn validate(&self, line: i64) -> BreakpointValidation;
85
86    /// Validate a breakpoint with optional column
87    fn validate_with_column(&self, line: i64, column: Option<i64>) -> BreakpointValidation;
88
89    /// Check if a line contains executable code
90    fn is_executable_line(&self, line: i64) -> bool;
91
92    /// Validate a conditional breakpoint expression
93    ///
94    /// Checks that the condition string is a syntactically valid Perl expression
95    /// that can be used as a breakpoint condition.
96    fn validate_condition(&self, line: i64, condition: &str) -> BreakpointValidation;
97}
98
99/// A byte range representing start (inclusive) and end (exclusive) offsets
100#[derive(Debug, Clone, Copy)]
101struct ByteRange {
102    start: usize,
103    end: usize,
104}
105
106/// AST-based breakpoint validator
107///
108/// Uses the Perl parser to build an AST and validate breakpoint locations
109/// against the parsed structure.
110pub struct AstBreakpointValidator {
111    /// The parsed AST
112    ast: Node,
113    /// Rope for efficient line/byte position mapping
114    rope: Rope,
115    /// Original source code
116    source: String,
117    /// Byte ranges of POD sections (=head1 ... =cut)
118    pod_regions: Vec<ByteRange>,
119}
120
121impl AstBreakpointValidator {
122    /// Create a new validator for the given source code
123    ///
124    /// # Arguments
125    ///
126    /// * `source` - The Perl source code to validate against
127    ///
128    /// # Errors
129    ///
130    /// Returns an error if the source cannot be parsed.
131    pub fn new(source: &str) -> Result<Self, BreakpointError> {
132        let mut parser = Parser::new(source);
133        let ast = parser.parse().map_err(|e| BreakpointError::ParseError(format!("{:?}", e)))?;
134        let rope = Rope::from_str(source);
135        let pod_regions = Self::find_pod_regions(source);
136        Ok(Self { ast, rope, source: source.to_string(), pod_regions })
137    }
138
139    /// Scan source text for POD documentation regions.
140    ///
141    /// POD begins with a line matching `=<directive>` (e.g. `=head1`, `=pod`, `=over`)
142    /// at column 0 (or after a newline) and ends with `=cut` on its own line.
143    /// If no `=cut` is found the POD extends to EOF.
144    fn find_pod_regions(source: &str) -> Vec<ByteRange> {
145        let mut regions = Vec::new();
146        let mut pod_start: Option<usize> = None;
147        let mut offset = 0;
148
149        for line in source.split('\n') {
150            let trimmed = line.trim_end_matches('\r');
151            if pod_start.is_some() {
152                // We are inside a POD section -- look for =cut
153                if trimmed == "=cut" {
154                    let end = offset + line.len();
155                    if let Some(start) = pod_start.take() {
156                        regions.push(ByteRange { start, end });
157                    }
158                }
159            } else if Self::is_pod_directive(trimmed) {
160                pod_start = Some(offset);
161            }
162            offset += line.len() + 1; // +1 for the '\n' delimiter
163        }
164
165        // If POD was never closed, extend to EOF
166        if let Some(start) = pod_start {
167            regions.push(ByteRange { start, end: source.len() });
168        }
169
170        regions
171    }
172
173    /// Returns `true` when `line` (already trimmed of trailing CR/LF) looks
174    /// like a POD directive that opens a documentation section.
175    fn is_pod_directive(line: &str) -> bool {
176        // POD directives: =head1-4, =pod, =over, =back, =begin, =end, =for, =encoding, =item
177        // Must start with '=' followed by a letter.
178        if !line.starts_with('=') {
179            return false;
180        }
181        let after_eq = &line[1..];
182        // Must start with an ASCII letter to be a POD directive
183        after_eq.starts_with(|c: char| c.is_ascii_alphabetic())
184    }
185
186    /// Check if a byte offset falls inside any POD region
187    fn is_inside_pod_region(&self, byte_offset: usize) -> bool {
188        self.pod_regions.iter().any(|r| byte_offset >= r.start && byte_offset < r.end)
189    }
190
191    /// Get the line range (start byte, end byte) for a given 1-based line number
192    fn line_byte_range(&self, line: i64) -> Option<(usize, usize)> {
193        let line_idx = (line - 1).max(0) as usize;
194        if line_idx >= self.rope.len_lines() {
195            return None;
196        }
197
198        let line_start = self.rope.line_to_byte(line_idx);
199        let line_end = if line_idx + 1 < self.rope.len_lines() {
200            self.rope.line_to_byte(line_idx + 1)
201        } else {
202            self.rope.len_bytes()
203        };
204
205        Some((line_start, line_end))
206    }
207
208    /// Check if a line contains only comments or whitespace
209    fn is_comment_or_blank_line(&self, line_start: usize, line_end: usize) -> bool {
210        let line_text = &self.source[line_start..line_end.min(self.source.len())];
211
212        // Fast path: Check if blank (only whitespace)
213        if line_text.trim().is_empty() {
214            return true;
215        }
216
217        // Fast path: Check if comment (starts with # after whitespace)
218        if line_text.trim_start().starts_with('#') {
219            return true;
220        }
221
222        // AST-based validation: Check if line contains only comment nodes
223        self.has_only_comments_in_range(line_start, line_end)
224    }
225
226    /// Check if all nodes in a range are comments
227    ///
228    /// Note: Comments are stripped during lexing and not represented in the AST.
229    /// The fast path in `is_comment_or_blank_line` handles comment detection.
230    /// This function checks if there are no executable nodes in the range.
231    fn has_only_comments_in_range(&self, start: usize, end: usize) -> bool {
232        self.has_only_comments_in_range_node(&self.ast, start, end)
233    }
234
235    fn has_only_comments_in_range_node(&self, node: &Node, start: usize, end: usize) -> bool {
236        // Check if node overlaps with line range
237        if node.location.start >= end || node.location.end <= start {
238            return false;
239        }
240
241        match &node.kind {
242            NodeKind::Program { statements } => {
243                // Get all nodes that overlap with the line range
244                let nodes_in_range: Vec<_> = statements
245                    .iter()
246                    .filter(|s| s.location.start < end && s.location.end > start)
247                    .collect();
248
249                // If no AST nodes in range, it's a blank/comment line
250                nodes_in_range.is_empty()
251            }
252            // Any other node type means there's executable code
253            _ => false,
254        }
255    }
256
257    /// Check if a byte offset is inside a heredoc interior (body content)
258    fn is_inside_heredoc_interior(&self, byte_offset: usize) -> bool {
259        self.is_inside_heredoc_interior_node(&self.ast, byte_offset)
260    }
261
262    #[allow(clippy::only_used_in_recursion)]
263    fn is_inside_heredoc_interior_node(&self, node: &Node, byte_offset: usize) -> bool {
264        // Check if this is a heredoc with a body span containing the offset
265        if let NodeKind::Heredoc { body_span: Some(span), .. } = &node.kind {
266            if byte_offset >= span.start && byte_offset < span.end {
267                return true;
268            }
269        }
270
271        // Recursively check all children
272        let mut found = false;
273        node.for_each_child(|child| {
274            if !found && self.is_inside_heredoc_interior_node(child, byte_offset) {
275                found = true;
276            }
277        });
278        found
279    }
280}
281
282impl BreakpointValidator for AstBreakpointValidator {
283    fn validate(&self, line: i64) -> BreakpointValidation {
284        self.validate_with_column(line, None)
285    }
286
287    fn validate_with_column(&self, line: i64, column: Option<i64>) -> BreakpointValidation {
288        // Get byte range for the line
289        let Some((line_start, line_end)) = self.line_byte_range(line) else {
290            return BreakpointValidation::rejected(line, ValidationReason::LineOutOfRange);
291        };
292
293        // Validation 1: Inside heredoc interior
294        // Check BEFORE comment/blank check because heredoc interior lines have no AST nodes
295        // and would otherwise be incorrectly classified as blank/comment lines
296        if self.is_inside_heredoc_interior(line_start) {
297            return BreakpointValidation::rejected(line, ValidationReason::HeredocInterior);
298        }
299
300        // Validation 2: Inside POD documentation section
301        // Check BEFORE comment/blank because POD lines have no AST nodes and would otherwise
302        // be incorrectly classified as blank or comment lines
303        if self.is_inside_pod_region(line_start) {
304            return BreakpointValidation::rejected(line, ValidationReason::PodLine);
305        }
306
307        // Validation 3: Comment or blank line
308        if self.is_comment_or_blank_line(line_start, line_end) {
309            // Check if the line is truly blank or just a comment
310            let line_text = &self.source[line_start..line_end.min(self.source.len())];
311            let reason = if line_text.trim().is_empty() {
312                ValidationReason::BlankLine
313            } else {
314                ValidationReason::CommentLine
315            };
316            return BreakpointValidation::rejected(line, reason);
317        }
318
319        // Breakpoint is valid
320        BreakpointValidation::verified(line, column)
321    }
322
323    fn is_executable_line(&self, line: i64) -> bool {
324        self.validate(line).verified
325    }
326
327    fn validate_condition(&self, line: i64, condition: &str) -> BreakpointValidation {
328        // First validate the line itself
329        let line_result = self.validate(line);
330        if !line_result.verified {
331            return line_result;
332        }
333
334        // Validate the condition expression
335        let trimmed = condition.trim();
336
337        // Empty condition is always invalid
338        if trimmed.is_empty() {
339            return BreakpointValidation::rejected(line, ValidationReason::InvalidCondition);
340        }
341
342        // Reject conditions containing dangerous constructs that should not
343        // appear in a debug-time expression evaluated on every hit.
344        if Self::condition_has_dangerous_construct(trimmed) {
345            return BreakpointValidation::rejected(line, ValidationReason::InvalidCondition);
346        }
347
348        // Try to parse the condition as a Perl expression.
349        // We wrap it in a statement context so the parser can handle it.
350        let wrapped = format!("if ({trimmed}) {{ 1; }}");
351        let mut parser = Parser::new(&wrapped);
352        match parser.parse() {
353            Ok(_) => BreakpointValidation::verified(line, None),
354            Err(_) => BreakpointValidation::rejected(line, ValidationReason::InvalidCondition),
355        }
356    }
357}
358
359impl AstBreakpointValidator {
360    /// Detect dangerous constructs that should not be used in breakpoint conditions.
361    ///
362    /// Breakpoint conditions are evaluated on every hit and should be pure
363    /// expressions without side effects that could alter program state.
364    fn condition_has_dangerous_construct(condition: &str) -> bool {
365        // System/exec calls
366        if condition.contains("system(")
367            || condition.contains("exec(")
368            || condition.contains("qx(")
369            || condition.contains("qx{")
370            || condition.contains("qx/")
371        {
372            return true;
373        }
374
375        // Backtick command execution
376        if condition.contains('`') {
377            return true;
378        }
379
380        // File operations that mutate state
381        if condition.contains("unlink(")
382            || condition.contains("rename(")
383            || condition.contains("rmdir(")
384            || condition.contains("mkdir(")
385        {
386            return true;
387        }
388
389        // eval string (eval BLOCK is less dangerous but string eval can do anything)
390        // We check for `eval "` or `eval '` or `eval $` patterns
391        let eval_pattern = condition.find("eval");
392        if let Some(idx) = eval_pattern {
393            let after = &condition[idx + 4..];
394            let after_trimmed = after.trim_start();
395            // eval followed by a string/variable (not a block) is dangerous
396            if after_trimmed.starts_with('"')
397                || after_trimmed.starts_with('\'')
398                || after_trimmed.starts_with('$')
399            {
400                return true;
401            }
402        }
403
404        false
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use perl_tdd_support::must;
412
413    #[test]
414    fn test_validate_executable_line() {
415        let source = "my $x = 1;\n";
416        let validator = must(AstBreakpointValidator::new(source));
417
418        let result = validator.validate(1);
419        assert!(result.verified);
420        assert_eq!(result.line, 1);
421        assert!(result.reason.is_none());
422    }
423
424    #[test]
425    fn test_validate_comment_line() {
426        let source = "# This is a comment\nmy $x = 1;\n";
427        let validator = must(AstBreakpointValidator::new(source));
428
429        let result = validator.validate(1);
430        assert!(!result.verified);
431        assert_eq!(result.reason, Some(ValidationReason::CommentLine));
432    }
433
434    #[test]
435    fn test_validate_blank_line() {
436        let source = "my $x = 1;\n\nmy $y = 2;\n";
437        let validator = must(AstBreakpointValidator::new(source));
438
439        let result = validator.validate(2);
440        assert!(!result.verified);
441        assert_eq!(result.reason, Some(ValidationReason::BlankLine));
442    }
443
444    #[test]
445    fn test_validate_line_out_of_range() {
446        let source = "my $x = 1;\n";
447        let validator = must(AstBreakpointValidator::new(source));
448
449        let result = validator.validate(100);
450        assert!(!result.verified);
451        assert_eq!(result.reason, Some(ValidationReason::LineOutOfRange));
452    }
453
454    #[test]
455    fn test_is_executable_line() {
456        let source = "# comment\nmy $x = 1;\n\nmy $y = 2;\n";
457        let validator = must(AstBreakpointValidator::new(source));
458
459        assert!(!validator.is_executable_line(1)); // comment
460        assert!(validator.is_executable_line(2)); // code
461        assert!(!validator.is_executable_line(3)); // blank
462        assert!(validator.is_executable_line(4)); // code
463    }
464
465    // -----------------------------------------------------------------------
466    // POD line detection
467    // -----------------------------------------------------------------------
468
469    #[test]
470    fn test_pod_head1_line_rejected() {
471        let source = "my $x = 1;\n\n=head1 NAME\n\nSome pod text\n\n=cut\n\nmy $y = 2;\n";
472        let validator = must(AstBreakpointValidator::new(source));
473
474        // =head1 line
475        let result = validator.validate(3);
476        assert!(!result.verified);
477        assert_eq!(result.reason, Some(ValidationReason::PodLine));
478    }
479
480    #[test]
481    fn test_pod_body_text_rejected() {
482        let source = "my $x = 1;\n\n=head1 NAME\n\nSome pod text\n\n=cut\n\nmy $y = 2;\n";
483        let validator = must(AstBreakpointValidator::new(source));
484
485        // "Some pod text" line (line 5)
486        let result = validator.validate(5);
487        assert!(!result.verified);
488        assert_eq!(result.reason, Some(ValidationReason::PodLine));
489    }
490
491    #[test]
492    fn test_pod_cut_line_rejected() {
493        let source = "my $x = 1;\n\n=head1 NAME\n\nSome pod text\n\n=cut\n\nmy $y = 2;\n";
494        let validator = must(AstBreakpointValidator::new(source));
495
496        // =cut line (line 7)
497        let result = validator.validate(7);
498        assert!(!result.verified);
499        assert_eq!(result.reason, Some(ValidationReason::PodLine));
500    }
501
502    #[test]
503    fn test_code_after_pod_is_executable() {
504        let source = "my $x = 1;\n\n=head1 NAME\n\nSome pod text\n\n=cut\n\nmy $y = 2;\n";
505        let validator = must(AstBreakpointValidator::new(source));
506
507        // Code before POD (line 1)
508        assert!(validator.is_executable_line(1));
509        // Code after POD (line 9)
510        assert!(validator.is_executable_line(9));
511    }
512
513    #[test]
514    fn test_pod_without_cut_extends_to_eof() {
515        let source = "my $x = 1;\n=pod\nThis is pod documentation\nThat never ends\n";
516        let validator = must(AstBreakpointValidator::new(source));
517
518        assert!(validator.is_executable_line(1));
519        let r2 = validator.validate(2);
520        assert!(!r2.verified);
521        assert_eq!(r2.reason, Some(ValidationReason::PodLine));
522        let r3 = validator.validate(3);
523        assert!(!r3.verified);
524        assert_eq!(r3.reason, Some(ValidationReason::PodLine));
525        let r4 = validator.validate(4);
526        assert!(!r4.verified);
527        assert_eq!(r4.reason, Some(ValidationReason::PodLine));
528    }
529
530    #[test]
531    fn test_multiple_pod_sections() {
532        let source = "my $a = 1;\n\n=head1 SYNOPSIS\n\nFirst section\n\n=cut\n\nmy $b = 2;\n\n=head2 METHODS\n\nSecond section\n\n=cut\n\nmy $c = 3;\n";
533        let validator = must(AstBreakpointValidator::new(source));
534
535        assert!(validator.is_executable_line(1)); // my $a = 1;
536        assert_eq!(validator.validate(3).reason, Some(ValidationReason::PodLine)); // =head1
537        assert_eq!(validator.validate(5).reason, Some(ValidationReason::PodLine)); // First section
538        assert_eq!(validator.validate(7).reason, Some(ValidationReason::PodLine)); // =cut
539        assert!(validator.is_executable_line(9)); // my $b = 2;
540        assert_eq!(validator.validate(11).reason, Some(ValidationReason::PodLine)); // =head2
541        assert!(validator.is_executable_line(17)); // my $c = 3;
542    }
543
544    // -----------------------------------------------------------------------
545    // Conditional breakpoint validation
546    // -----------------------------------------------------------------------
547
548    #[test]
549    fn test_condition_valid_comparison() {
550        let source = "my $x = 1;\nmy $y = 2;\n";
551        let validator = must(AstBreakpointValidator::new(source));
552
553        let result = validator.validate_condition(1, "$x > 5");
554        assert!(result.verified);
555    }
556
557    #[test]
558    fn test_condition_valid_equality() {
559        let source = "my $x = 1;\n";
560        let validator = must(AstBreakpointValidator::new(source));
561
562        let result = validator.validate_condition(1, "$x == 42");
563        assert!(result.verified);
564    }
565
566    #[test]
567    fn test_condition_valid_string_eq() {
568        let source = "my $name = 'test';\n";
569        let validator = must(AstBreakpointValidator::new(source));
570
571        let result = validator.validate_condition(1, "$name eq 'hello'");
572        assert!(result.verified);
573    }
574
575    #[test]
576    fn test_condition_empty_rejected() {
577        let source = "my $x = 1;\n";
578        let validator = must(AstBreakpointValidator::new(source));
579
580        let result = validator.validate_condition(1, "");
581        assert!(!result.verified);
582        assert_eq!(result.reason, Some(ValidationReason::InvalidCondition));
583    }
584
585    #[test]
586    fn test_condition_whitespace_only_rejected() {
587        let source = "my $x = 1;\n";
588        let validator = must(AstBreakpointValidator::new(source));
589
590        let result = validator.validate_condition(1, "   ");
591        assert!(!result.verified);
592        assert_eq!(result.reason, Some(ValidationReason::InvalidCondition));
593    }
594
595    #[test]
596    fn test_condition_system_call_rejected() {
597        let source = "my $x = 1;\n";
598        let validator = must(AstBreakpointValidator::new(source));
599
600        let result = validator.validate_condition(1, "system('rm -rf /')");
601        assert!(!result.verified);
602        assert_eq!(result.reason, Some(ValidationReason::InvalidCondition));
603    }
604
605    #[test]
606    fn test_condition_backtick_rejected() {
607        let source = "my $x = 1;\n";
608        let validator = must(AstBreakpointValidator::new(source));
609
610        let result = validator.validate_condition(1, "`ls`");
611        assert!(!result.verified);
612        assert_eq!(result.reason, Some(ValidationReason::InvalidCondition));
613    }
614
615    #[test]
616    fn test_condition_exec_rejected() {
617        let source = "my $x = 1;\n";
618        let validator = must(AstBreakpointValidator::new(source));
619
620        let result = validator.validate_condition(1, "exec('/bin/sh')");
621        assert!(!result.verified);
622        assert_eq!(result.reason, Some(ValidationReason::InvalidCondition));
623    }
624
625    #[test]
626    fn test_condition_unlink_rejected() {
627        let source = "my $x = 1;\n";
628        let validator = must(AstBreakpointValidator::new(source));
629
630        let result = validator.validate_condition(1, "unlink('/tmp/foo')");
631        assert!(!result.verified);
632        assert_eq!(result.reason, Some(ValidationReason::InvalidCondition));
633    }
634
635    #[test]
636    fn test_condition_eval_string_rejected() {
637        let source = "my $x = 1;\n";
638        let validator = must(AstBreakpointValidator::new(source));
639
640        let result = validator.validate_condition(1, "eval \"dangerous code\"");
641        assert!(!result.verified);
642        assert_eq!(result.reason, Some(ValidationReason::InvalidCondition));
643    }
644
645    #[test]
646    fn test_condition_on_invalid_line_rejected() {
647        let source = "# comment\nmy $x = 1;\n";
648        let validator = must(AstBreakpointValidator::new(source));
649
650        // Line 1 is a comment, condition cannot be set there
651        let result = validator.validate_condition(1, "$x > 0");
652        assert!(!result.verified);
653        assert_eq!(result.reason, Some(ValidationReason::CommentLine));
654    }
655
656    #[test]
657    fn test_condition_defined_check() {
658        let source = "my $x = undef;\n";
659        let validator = must(AstBreakpointValidator::new(source));
660
661        let result = validator.validate_condition(1, "defined($x)");
662        assert!(result.verified);
663    }
664
665    #[test]
666    fn test_condition_logical_operators() {
667        let source = "my $x = 1;\n";
668        let validator = must(AstBreakpointValidator::new(source));
669
670        let result = validator.validate_condition(1, "$x > 0 && $x < 100");
671        assert!(result.verified);
672    }
673
674    // -----------------------------------------------------------------------
675    // POD region detection internals
676    // -----------------------------------------------------------------------
677
678    #[test]
679    fn test_is_pod_directive_basic() {
680        assert!(AstBreakpointValidator::is_pod_directive("=head1 NAME"));
681        assert!(AstBreakpointValidator::is_pod_directive("=head2 METHODS"));
682        assert!(AstBreakpointValidator::is_pod_directive("=pod"));
683        assert!(AstBreakpointValidator::is_pod_directive("=cut"));
684        assert!(AstBreakpointValidator::is_pod_directive("=over 4"));
685        assert!(AstBreakpointValidator::is_pod_directive("=back"));
686        assert!(AstBreakpointValidator::is_pod_directive("=begin html"));
687        assert!(AstBreakpointValidator::is_pod_directive("=end html"));
688        assert!(AstBreakpointValidator::is_pod_directive("=for text"));
689        assert!(AstBreakpointValidator::is_pod_directive("=encoding utf8"));
690        assert!(AstBreakpointValidator::is_pod_directive("=item *"));
691    }
692
693    #[test]
694    fn test_is_pod_directive_rejects_non_pod() {
695        assert!(!AstBreakpointValidator::is_pod_directive("my $x = 1;"));
696        assert!(!AstBreakpointValidator::is_pod_directive("# comment"));
697        assert!(!AstBreakpointValidator::is_pod_directive(""));
698        assert!(!AstBreakpointValidator::is_pod_directive("=123"));
699        assert!(!AstBreakpointValidator::is_pod_directive("=="));
700    }
701
702    #[test]
703    fn test_find_pod_regions_empty() {
704        let regions = AstBreakpointValidator::find_pod_regions("my $x = 1;\n");
705        assert!(regions.is_empty());
706    }
707
708    #[test]
709    fn test_find_pod_regions_single_section() {
710        let source = "my $x = 1;\n=head1 NAME\nTest\n=cut\nmy $y = 2;\n";
711        let regions = AstBreakpointValidator::find_pod_regions(source);
712        assert_eq!(regions.len(), 1);
713        // The region should cover from "=head1" through "=cut"
714        let text = &source[regions[0].start..regions[0].end];
715        assert!(text.starts_with("=head1"));
716        assert!(text.ends_with("=cut"));
717    }
718
719    #[test]
720    fn test_find_pod_regions_unclosed() {
721        let source = "my $x = 1;\n=pod\nSome docs\n";
722        let regions = AstBreakpointValidator::find_pod_regions(source);
723        assert_eq!(regions.len(), 1);
724        assert_eq!(regions[0].end, source.len());
725    }
726}