1use super::BreakpointError;
8use perl_parser::Parser;
9use perl_parser::ast::{Node, NodeKind};
10use ropey::Rope;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ValidationReason {
15 BlankLine,
17 CommentLine,
19 HeredocInterior,
21 PodLine,
23 LineOutOfRange,
25 ParseError,
27 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#[derive(Debug, Clone)]
49pub struct BreakpointValidation {
50 pub verified: bool,
52 pub line: i64,
54 pub column: Option<i64>,
56 pub reason: Option<ValidationReason>,
58 pub message: Option<String>,
60}
61
62impl BreakpointValidation {
63 pub fn verified(line: i64, column: Option<i64>) -> Self {
65 Self { verified: true, line, column, reason: None, message: None }
66 }
67
68 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 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
81pub trait BreakpointValidator {
83 fn validate(&self, line: i64) -> BreakpointValidation;
85
86 fn validate_with_column(&self, line: i64, column: Option<i64>) -> BreakpointValidation;
88
89 fn is_executable_line(&self, line: i64) -> bool;
91
92 fn validate_condition(&self, line: i64, condition: &str) -> BreakpointValidation;
97}
98
99#[derive(Debug, Clone, Copy)]
101struct ByteRange {
102 start: usize,
103 end: usize,
104}
105
106pub struct AstBreakpointValidator {
111 ast: Node,
113 rope: Rope,
115 source: String,
117 pod_regions: Vec<ByteRange>,
119}
120
121impl AstBreakpointValidator {
122 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 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 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; }
164
165 if let Some(start) = pod_start {
167 regions.push(ByteRange { start, end: source.len() });
168 }
169
170 regions
171 }
172
173 fn is_pod_directive(line: &str) -> bool {
176 if !line.starts_with('=') {
179 return false;
180 }
181 let after_eq = &line[1..];
182 after_eq.starts_with(|c: char| c.is_ascii_alphabetic())
184 }
185
186 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 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 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 if line_text.trim().is_empty() {
214 return true;
215 }
216
217 if line_text.trim_start().starts_with('#') {
219 return true;
220 }
221
222 self.has_only_comments_in_range(line_start, line_end)
224 }
225
226 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 if node.location.start >= end || node.location.end <= start {
238 return false;
239 }
240
241 match &node.kind {
242 NodeKind::Program { statements } => {
243 let nodes_in_range: Vec<_> = statements
245 .iter()
246 .filter(|s| s.location.start < end && s.location.end > start)
247 .collect();
248
249 nodes_in_range.is_empty()
251 }
252 _ => false,
254 }
255 }
256
257 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 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 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 let Some((line_start, line_end)) = self.line_byte_range(line) else {
290 return BreakpointValidation::rejected(line, ValidationReason::LineOutOfRange);
291 };
292
293 if self.is_inside_heredoc_interior(line_start) {
297 return BreakpointValidation::rejected(line, ValidationReason::HeredocInterior);
298 }
299
300 if self.is_inside_pod_region(line_start) {
304 return BreakpointValidation::rejected(line, ValidationReason::PodLine);
305 }
306
307 if self.is_comment_or_blank_line(line_start, line_end) {
309 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 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 let line_result = self.validate(line);
330 if !line_result.verified {
331 return line_result;
332 }
333
334 let trimmed = condition.trim();
336
337 if trimmed.is_empty() {
339 return BreakpointValidation::rejected(line, ValidationReason::InvalidCondition);
340 }
341
342 if Self::condition_has_dangerous_construct(trimmed) {
345 return BreakpointValidation::rejected(line, ValidationReason::InvalidCondition);
346 }
347
348 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 fn condition_has_dangerous_construct(condition: &str) -> bool {
365 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 if condition.contains('`') {
377 return true;
378 }
379
380 if condition.contains("unlink(")
382 || condition.contains("rename(")
383 || condition.contains("rmdir(")
384 || condition.contains("mkdir(")
385 {
386 return true;
387 }
388
389 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 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)); assert!(validator.is_executable_line(2)); assert!(!validator.is_executable_line(3)); assert!(validator.is_executable_line(4)); }
464
465 #[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 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 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 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 assert!(validator.is_executable_line(1));
509 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)); assert_eq!(validator.validate(3).reason, Some(ValidationReason::PodLine)); assert_eq!(validator.validate(5).reason, Some(ValidationReason::PodLine)); assert_eq!(validator.validate(7).reason, Some(ValidationReason::PodLine)); assert!(validator.is_executable_line(9)); assert_eq!(validator.validate(11).reason, Some(ValidationReason::PodLine)); assert!(validator.is_executable_line(17)); }
543
544 #[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 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 #[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 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}