1mod breakpoints;
7mod data_breakpoints;
8mod evaluation;
9mod execution;
10mod frames;
11mod output;
12mod patterns;
13mod process;
14mod variables;
15
16mod dispatch;
17mod parsing;
18mod regexes;
19pub(crate) mod safe_eval;
20mod session;
21mod sync_utils;
22mod transport;
23pub mod var_ref;
24mod variable_cache;
25
26use crate::breakpoint::{AstBreakpointValidator, BreakpointValidator};
27use crate::eval::SafeEvaluator;
28use crate::feature_catalog::has_feature as catalog_has_feature;
29use crate::inline_values::{collect_inline_values_with_runtime, extract_variable_names};
30use crate::protocol::{
31 BreakpointLocation, BreakpointLocationsArguments, BreakpointLocationsResponseBody,
32 CompletionItem, CompletionsArguments, CompletionsResponseBody, ContinueArguments,
33 ContinueResponseBody, DataBreakpointInfoArguments, DataBreakpointInfoResponseBody,
34 DisconnectArguments, EvaluateArguments, EvaluateResponseBody, ExceptionDetails,
35 ExceptionInfoArguments, ExceptionInfoResponseBody, GotoArguments, GotoTarget,
36 GotoTargetsArguments, GotoTargetsResponseBody, InlineValuesArguments, InlineValuesResponseBody,
37 LoadedSourcesResponseBody, Module, ModulesArguments, ModulesResponseBody, NextArguments,
38 PauseArguments, RestartArguments, Scope, ScopesArguments, ScopesResponseBody,
39 SetDataBreakpointsArguments, SetDataBreakpointsResponseBody, SetExceptionBreakpointsArguments,
40 SetExpressionArguments, SetExpressionResponseBody, SetFunctionBreakpointsArguments,
41 SetVariableArguments, SetVariableResponseBody, SourceArguments, SourceResponseBody,
42 StackTraceArguments, StepInArguments, StepInTarget, StepInTargetsArguments,
43 StepInTargetsResponseBody, StepOutArguments, TerminateArguments, VariablesArguments,
44};
45use crate::stack::{PerlStackParser, is_internal_frame_name_and_path};
46use crate::tcp_attach::{DapEvent, TcpAttachConfig, TcpAttachSession};
47use crate::types::{Source, StackFrame, Variable};
48use crate::variables::{PerlVariableRenderer, RenderedVariable, VariableParser, VariableRenderer};
49use perl_lexer::DAP_COMPLETION_KEYWORDS;
50use perl_lsp_rs_core::transport::framing::ContentLengthFramer;
51use perl_module::path::module_path_to_name;
52use serde::{Deserialize, Serialize};
53use serde_json::{Value, json};
54use std::collections::{HashMap, HashSet};
55use std::io::{self, BufRead, BufReader, Read, Write};
56use std::net::TcpListener;
57use std::path::{Path, PathBuf};
58use std::process::{Child, Stdio};
59use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
60use std::sync::mpsc::{Sender, channel};
61use std::sync::{Arc, Mutex};
62use std::thread;
63use std::time::{Duration, Instant};
64
65use crate::breakpoints::{BreakpointHitOutcome, BreakpointStore};
66use crate::debug_adapter::data_breakpoints::DataBreakpointRecord;
67use crate::debug_adapter::session::{DebugSession, DebugState, ResumeMode};
68use crate::debug_adapter::variable_cache::{VariableCache, VariableCacheKind, slice_variables};
69use crate::security;
70#[cfg(unix)]
71use nix::sys::signal::{self, Signal};
72#[cfg(unix)]
73use nix::unistd::Pid;
74use patterns::*;
75use safe_eval::validate_safe_expression;
76use sync_utils::{emit_event_safe, lock_or_recover};
77
78fn is_escape_sequence(s: &str, match_start: usize) -> bool {
80 if match_start == 0 {
81 return false;
82 }
83 s.as_bytes()[match_start - 1] == b'\\'
84}
85
86pub struct DebugAdapter {
88 seq: Arc<Mutex<i64>>,
90 session: Arc<Mutex<Option<DebugSession>>>,
92 attached_pid: Arc<Mutex<Option<u32>>>,
94 tcp_session: Arc<Mutex<Option<TcpAttachSession>>>,
96 breakpoints: BreakpointStore,
98 thread_counter: Arc<Mutex<i32>>,
100 event_sender: Option<Sender<DapMessage>>,
102 recent_output: Arc<Mutex<RecentOutputBuffer>>,
104 function_breakpoints: Arc<Mutex<Vec<String>>>,
106 next_function_breakpoint_id: Arc<Mutex<i64>>,
108 exception_break_on_die: Arc<Mutex<bool>>,
110 exception_break_on_warn: Arc<Mutex<bool>>,
112 debugger_output_marker: Arc<AtomicU64>,
114 cancel_requested: Arc<AtomicBool>,
116 data_breakpoints: Arc<Mutex<Vec<DataBreakpointRecord>>>,
118 last_exception_message: Arc<Mutex<Option<String>>>,
120 last_launch_args: Arc<Mutex<Option<Value>>>,
122 goto_targets: Arc<Mutex<HashMap<i64, (String, i64)>>>,
124 next_goto_target_id: Arc<Mutex<i64>>,
126 workspace_root: Arc<Mutex<Option<PathBuf>>>,
128 transport_broken: Arc<AtomicBool>,
130 initialized: Arc<AtomicBool>,
132}
133
134#[derive(Debug, Serialize, Deserialize)]
136#[serde(tag = "type")]
137pub enum DapMessage {
138 #[serde(rename = "request")]
140 Request {
141 seq: i64,
143 command: String,
145 arguments: Option<Value>,
147 },
148 #[serde(rename = "response")]
150 Response {
151 seq: i64,
153 request_seq: i64,
155 success: bool,
157 command: String,
159 #[serde(skip_serializing_if = "Option::is_none")]
161 body: Option<Value>,
162 #[serde(skip_serializing_if = "Option::is_none")]
164 message: Option<String>,
165 },
166 #[serde(rename = "event")]
168 Event {
169 seq: i64,
171 event: String,
173 #[serde(skip_serializing_if = "Option::is_none")]
175 body: Option<Value>,
176 },
177}
178
179impl Default for DebugAdapter {
180 fn default() -> Self {
181 Self::new()
182 }
183}
184
185impl DebugAdapter {
186 pub fn new() -> Self {
188 Self {
189 seq: Arc::new(Mutex::new(0)),
190 session: Arc::new(Mutex::new(None)),
191 attached_pid: Arc::new(Mutex::new(None)),
192 tcp_session: Arc::new(Mutex::new(None)),
193 breakpoints: BreakpointStore::new(),
194 thread_counter: Arc::new(Mutex::new(0)),
195 event_sender: None,
196 recent_output: Arc::new(Mutex::new(RecentOutputBuffer::new())),
197 function_breakpoints: Arc::new(Mutex::new(Vec::new())),
198 next_function_breakpoint_id: Arc::new(Mutex::new(1)),
199 exception_break_on_die: Arc::new(Mutex::new(false)),
200 exception_break_on_warn: Arc::new(Mutex::new(false)),
201 debugger_output_marker: Arc::new(AtomicU64::new(1)),
202 cancel_requested: Arc::new(AtomicBool::new(false)),
203 data_breakpoints: Arc::new(Mutex::new(Vec::new())),
204 last_exception_message: Arc::new(Mutex::new(None)),
205 last_launch_args: Arc::new(Mutex::new(None)),
206 goto_targets: Arc::new(Mutex::new(HashMap::new())),
207 next_goto_target_id: Arc::new(Mutex::new(1)),
208 workspace_root: Arc::new(Mutex::new(None)),
209 transport_broken: Arc::new(AtomicBool::new(false)),
210 initialized: Arc::new(AtomicBool::new(false)),
211 }
212 }
213
214 pub fn set_event_sender(&mut self, sender: Sender<DapMessage>) {
216 self.event_sender = Some(sender);
217 }
218
219 fn validate_source_path(&self, path: &str) -> Result<PathBuf, String> {
225 let ws = lock_or_recover(&self.workspace_root, "debug_adapter.workspace_root");
226 match ws.as_ref() {
227 Some(root) => security::validate_path(Path::new(path), root)
228 .map_err(|e| format!("Path validation failed: {e}")),
229 None => {
230 Ok(PathBuf::from(path))
232 }
233 }
234 }
235
236 fn next_seq(&self) -> i64 {
238 let mut seq = lock_or_recover(&self.seq, "next_seq");
239 *seq += 1;
240 *seq
241 }
242
243 fn send_event(&self, event: &str, body: Option<Value>) {
245 if let Some(ref sender) = self.event_sender {
246 let seq = self.next_seq();
247 let msg = DapMessage::Event { seq, event: event.to_string(), body };
248 let _ = sender.send(msg);
249 }
250 }
251
252 fn snapshot_recent_output_lines(&self) -> Vec<String> {
254 let output = lock_or_recover(&self.recent_output, "debug_adapter.recent_output");
255 output.lines.iter().map(|line| line.raw.clone()).collect()
256 }
257
258 fn append_recent_output_line_locked(output: &mut RecentOutputBuffer, line: &str) {
259 if output.lines.len() >= RECENT_OUTPUT_MAX_LINES {
260 let _ = output.lines.pop_front();
261 }
262
263 let id = output.next_line_id;
264 output.next_line_id = output.next_line_id.saturating_add(1);
265 output.lines.push_back(RecentOutputLine {
266 id,
267 raw: line.to_string(),
268 normalized: Self::normalize_debugger_output_line(line),
269 });
270 }
271
272 fn next_debugger_marker_id(&self) -> u64 {
274 self.debugger_output_marker.fetch_add(1, Ordering::Relaxed)
275 }
276
277 fn write_debugger_command(stdin: &mut impl Write, command: &str) -> Result<(), String> {
279 stdin.write_all(command.as_bytes()).map_err(|e| format!("write debugger command: {e}"))?;
280 stdin.flush().map_err(|e| format!("flush debugger command: {e}"))?;
281 Ok(())
282 }
283
284 fn send_framed_debugger_commands(
288 &self,
289 stdin: &mut impl Write,
290 commands: &[String],
291 ) -> Result<(String, String), String> {
292 let marker_id = self.next_debugger_marker_id();
293 let begin_marker = format!("DAP_BEGIN_{marker_id}");
294 let end_marker = format!("DAP_END_{marker_id}");
295
296 Self::write_debugger_command(stdin, &format!("p \"{begin_marker}\"\n"))?;
297 for command in commands {
298 if command.ends_with('\n') {
299 Self::write_debugger_command(stdin, command)?;
300 } else {
301 Self::write_debugger_command(stdin, &format!("{command}\n"))?;
302 }
303 }
304 Self::write_debugger_command(stdin, &format!("p \"{end_marker}\"\n"))?;
305
306 Ok((begin_marker, end_marker))
307 }
308
309 fn capture_framed_debugger_output(
311 &self,
312 begin_marker: &str,
313 end_marker: &str,
314 timeout_ms: u64,
315 ) -> Option<Vec<String>> {
316 let deadline =
317 Instant::now() + Duration::from_millis(Self::debugger_timeout_budget_ms(timeout_ms));
318 let mut next_scan_id = 0_u64;
319 let mut saw_begin_marker = false;
320 let mut framed_lines = Vec::new();
321
322 loop {
323 if self.cancel_requested.load(Ordering::Acquire) {
325 self.cancel_requested.store(false, Ordering::Release);
326 return None;
327 }
328
329 {
330 let output = lock_or_recover(&self.recent_output, "debug_adapter.recent_output");
331 for line in output.lines.iter().filter(|line| line.id >= next_scan_id) {
332 if !saw_begin_marker {
333 if Self::line_contains_full_marker(&line.normalized, begin_marker) {
334 saw_begin_marker = true;
335 framed_lines.clear();
336 }
337 } else if Self::line_contains_full_marker(&line.normalized, end_marker) {
338 return Some(framed_lines);
339 } else if !line.normalized.trim().is_empty() {
340 framed_lines.push(line.normalized.clone());
341 }
342 }
343
344 if let Some(last) = output.lines.back() {
345 next_scan_id = last.id.saturating_add(1);
346 }
347 }
348
349 if Instant::now() >= deadline {
350 return None;
351 }
352
353 thread::sleep(Duration::from_millis(DEBUGGER_FRAME_POLL_MS));
354 }
355 }
356
357 fn debugger_output_window_ms(timeout_ms: u32) -> u64 {
359 u64::from(timeout_ms).max(DEBUGGER_QUERY_WAIT_MS)
360 }
361
362 fn wait_for_debugger_output_window(timeout_ms: u32) {
363 thread::sleep(Duration::from_millis(Self::debugger_output_window_ms(timeout_ms)));
364 }
365
366 fn debugger_timeout_budget_ms(timeout_ms: u64) -> u64 {
372 let base = timeout_ms.max(DEBUGGER_QUERY_WAIT_MS);
373 if std::env::var_os("LLVM_PROFILE_FILE").is_some()
374 || std::env::var_os("CARGO_LLVM_COV").is_some()
375 {
376 base.clamp(15_000, 30_000)
377 } else {
378 base
379 }
380 }
381
382 fn i64_to_i32_saturating(value: i64) -> i32 {
384 match i32::try_from(value) {
385 Ok(v) => v,
386 Err(_) => {
387 if value.is_negative() {
388 i32::MIN
389 } else {
390 i32::MAX
391 }
392 }
393 }
394 }
395
396 fn line_contains_full_marker(line: &str, marker: &str) -> bool {
397 line.match_indices(marker).any(|(idx, _)| {
398 let before = line[..idx].chars().next_back();
399 let after = line[idx + marker.len()..].chars().next();
400 let before_ok =
401 before.is_none_or(|ch| !matches!(ch, 'A'..='Z' | 'a'..='z' | '0'..='9' | '_'));
402 let after_ok =
403 after.is_none_or(|ch| !matches!(ch, 'A'..='Z' | 'a'..='z' | '0'..='9' | '_'));
404 before_ok && after_ok
405 })
406 }
407
408 #[cfg(any(test, feature = "test-helpers"))]
412 pub fn push_recent_output_line_for_test(&self, line: &str) {
413 let mut output = lock_or_recover(&self.recent_output, "debug_adapter.push_recent_output");
414 Self::append_recent_output_line_locked(&mut output, line);
415 }
416
417 #[cfg(any(test, feature = "test-helpers"))]
425 pub fn seed_running_session_for_test(&self) {
426 use crate::debug_adapter::session::{DebugSession, DebugState, ResumeMode};
427 use crate::debug_adapter::variable_cache::VariableCache;
428 if let Ok(child) = std::process::Command::new("perl")
429 .arg("-e")
430 .arg("1")
431 .stdin(std::process::Stdio::piped())
432 .stdout(std::process::Stdio::piped())
433 .stderr(std::process::Stdio::piped())
434 .spawn()
435 {
436 if let Ok(mut guard) = self.session.lock() {
437 *guard = Some(DebugSession {
438 process: child,
439 state: DebugState::Running,
440 stack_frames: vec![],
441 variable_cache: VariableCache::default(),
442 thread_id: 1,
443 last_resume_mode: ResumeMode::Continue,
444 });
445 }
446 }
447 }
448
449 #[cfg(any(test, feature = "test-helpers"))]
456 pub fn seed_attached_pid_for_test(&self, pid: u32) {
457 if let Ok(mut guard) = self.attached_pid.lock() {
458 *guard = Some(pid);
459 }
460 }
461
462 #[cfg(test)]
463 fn seed_session_for_test(&self) {
464 let child = Self::spawn_noop_child_for_test();
466 let mut session = lock_or_recover(&self.session, "debug_adapter.seed_session");
467 *session = Some(DebugSession {
468 process: child,
469 state: DebugState::Stopped,
470 stack_frames: Vec::new(),
471 variable_cache: VariableCache::default(),
472 thread_id: 1,
473 last_resume_mode: ResumeMode::Unknown,
474 });
475 }
476
477 #[cfg(test)]
481 fn spawn_noop_child_for_test() -> std::process::Child {
482 use std::process::{Command, Stdio};
483 if let Ok(c) = Command::new("perl")
485 .arg("-e")
486 .arg("1")
487 .stdin(Stdio::piped())
488 .stdout(Stdio::piped())
489 .stderr(Stdio::piped())
490 .spawn()
491 {
492 return c;
493 }
494 #[cfg(windows)]
496 let (prog, args): (&str, &[&str]) = ("cmd", &["/c", "exit", "0"]);
497 #[cfg(not(windows))]
498 let (prog, args): (&str, &[&str]) = ("true", &[]);
499 Command::new(prog)
504 .args(args)
505 .stdin(Stdio::piped())
506 .stdout(Stdio::piped())
507 .stderr(Stdio::piped())
508 .spawn()
509 .unwrap_or_else(|e| {
510 panic!("seed_session_for_test: cannot spawn any noop subprocess ({prog}): {e}")
511 })
512 }
513
514 #[cfg(test)]
515 fn inject_stack_frames_for_test(&self, frames: Vec<StackFrame>) {
516 let mut session = lock_or_recover(&self.session, "debug_adapter.inject_frames");
517 if let Some(ref mut sess) = *session {
518 sess.stack_frames = frames;
519 }
520 }
521
522 #[cfg(test)]
523 fn stack_frames_snapshot_for_test(&self) -> Vec<StackFrame> {
524 let session = lock_or_recover(&self.session, "debug_adapter.snapshot_frames");
525 session.as_ref().map(|s| s.stack_frames.clone()).unwrap_or_default()
526 }
527
528 #[cfg(test)]
536 pub fn seed_eval_result_cache_for_test(
537 &self,
538 eval_ref_wire: i32,
539 variables: Vec<crate::types::Variable>,
540 ) {
541 let mut session = lock_or_recover(&self.session, "debug_adapter.seed_eval_result_cache");
542 if let Some(ref mut sess) = *session {
543 sess.variable_cache.upsert(eval_ref_wire, VariableCacheKind::EvaluateResult, variables);
544 }
545 }
546
547 #[cfg(any(test, feature = "test-helpers"))]
552 pub fn seed_stopped_session_with_frames_for_test(&self, frames: Vec<crate::types::StackFrame>) {
553 use std::process::{Command, Stdio};
554 let Ok(child) = Command::new("perl")
555 .arg("-e")
556 .arg("1")
557 .stdin(Stdio::piped())
558 .stdout(Stdio::piped())
559 .stderr(Stdio::piped())
560 .spawn()
561 else {
562 return;
563 };
564 let mut session = lock_or_recover(&self.session, "debug_adapter.seed_stopped_session");
565 *session = Some(DebugSession {
566 process: child,
567 state: DebugState::Stopped,
568 stack_frames: frames,
569 variable_cache: VariableCache::default(),
570 thread_id: 1,
571 last_resume_mode: ResumeMode::Unknown,
572 });
573 }
574}
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 fn create_breakpoint_test_perl_file()
580 -> Result<(tempfile::NamedTempFile, String), Box<dyn std::error::Error>> {
581 let mut file = tempfile::NamedTempFile::with_suffix(".pl")?;
582 let perl_code = r#"#!/usr/bin/perl
583use strict;
584use warnings;
585
586my $x = 1;
587my $y = 2;
588my $z = $x + $y;
589
590if ($x > 0) {
591 print "positive\n";
592}
593
594my @arr = (1, 2, 3);
595while (my $item = shift @arr) {
596 my $doubled = $item * 2;
597 print "$doubled\n";
598}
599
600sub process {
601 my ($value) = @_;
602 my $result = $value * 2;
603 return $result;
604}
605
606print "done\n";
607my $final = process($x);
608print "result: $final\n";
609"#;
610 file.write_all(perl_code.as_bytes())?;
611 file.flush()?;
612 let path = file.path().to_string_lossy().to_string();
613 Ok((file, path))
614 }
615
616 #[test]
617 fn test_debug_adapter_creation() {
618 let adapter = DebugAdapter::new();
619 assert!(adapter.session.lock().ok().is_some_and(|guard| guard.is_none()));
620 assert!(adapter.breakpoints.is_empty());
621 }
622
623 #[test]
624 fn test_sequence_numbers() {
625 let adapter = DebugAdapter::new();
626 assert_eq!(adapter.next_seq(), 1);
627 assert_eq!(adapter.next_seq(), 2);
628 assert_eq!(adapter.next_seq(), 3);
629 }
630
631 #[test]
632 fn test_debugger_output_window_ms_enforces_minimum_budget() {
633 assert_eq!(DebugAdapter::debugger_output_window_ms(1), DEBUGGER_QUERY_WAIT_MS);
634 }
635
636 #[test]
637 fn test_debugger_output_window_ms_honors_extended_budget() {
638 assert_eq!(DebugAdapter::debugger_output_window_ms(600), 600);
639 }
640
641 #[test]
642 fn test_initialize_response() -> Result<(), Box<dyn std::error::Error>> {
643 let mut adapter = DebugAdapter::new();
644 let response = adapter.handle_request(1, "initialize", None);
645
646 match response {
647 DapMessage::Response { success, command, body, .. } => {
648 assert!(success);
649 assert_eq!(command, "initialize");
650 assert!(body.is_some());
651 }
652 _ => return Err("Expected response".into()),
653 }
654 Ok(())
655 }
656
657 #[test]
658 fn test_set_breakpoints_replace_semantics_updates_adapter_store()
659 -> Result<(), Box<dyn std::error::Error>> {
660 let (_keep, source_path) = create_breakpoint_test_perl_file()?;
661 let mut adapter = DebugAdapter::new();
662
663 let first = adapter.handle_request(
664 1,
665 "setBreakpoints",
666 Some(json!({
667 "source": { "path": source_path },
668 "breakpoints": [{ "line": 10 }],
669 })),
670 );
671 match first {
672 DapMessage::Response { success: true, command, .. } => {
673 assert_eq!(command, "setBreakpoints");
674 }
675 other => {
676 return Err(format!("expected successful setBreakpoints, got {other:?}").into());
677 }
678 }
679
680 let first_lines: Vec<i64> =
681 adapter.breakpoints.get_breakpoints(&source_path).iter().map(|bp| bp.line).collect();
682 assert_eq!(first_lines, vec![10], "first request should seed one stored breakpoint");
683
684 let second = adapter.handle_request(
685 2,
686 "setBreakpoints",
687 Some(json!({
688 "source": { "path": source_path },
689 "breakpoints": [
690 { "line": 20 },
691 { "line": 26 },
692 ],
693 })),
694 );
695 match second {
696 DapMessage::Response { success: true, command, .. } => {
697 assert_eq!(command, "setBreakpoints");
698 }
699 other => {
700 return Err(format!("expected successful setBreakpoints, got {other:?}").into());
701 }
702 }
703
704 let stored_lines: Vec<i64> =
705 adapter.breakpoints.get_breakpoints(&source_path).iter().map(|bp| bp.line).collect();
706 assert_eq!(
707 stored_lines,
708 vec![20, 26],
709 "second request must replace the stored adapter breakpoints, not append to them"
710 );
711
712 Ok(())
713 }
714
715 #[test]
716 fn test_initialize_capabilities_follow_feature_catalog()
717 -> Result<(), Box<dyn std::error::Error>> {
718 let mut adapter = DebugAdapter::new();
719 let init = adapter.handle_request(1, "initialize", None);
720
721 let capabilities = match init {
722 DapMessage::Response { success: true, command, body: Some(body), .. }
723 if command == "initialize" =>
724 {
725 body
726 }
727 _ => return Err("Expected successful initialize response".into()),
728 };
729
730 let capability_map =
731 capabilities.as_object().ok_or("Initialize response body must be a JSON object")?;
732
733 let expectations = [
734 ("supportsConfigurationDoneRequest", crate::feature_catalog::has_feature("dap.core")),
735 ("supportsFunctionBreakpoints", crate::feature_catalog::has_feature("dap.core")),
736 (
737 "supportsConditionalBreakpoints",
738 crate::feature_catalog::has_feature("dap.breakpoints.basic"),
739 ),
740 (
741 "supportsHitConditionalBreakpoints",
742 crate::feature_catalog::has_feature("dap.breakpoints.hit_condition"),
743 ),
744 ("supportsEvaluateForHovers", crate::feature_catalog::has_feature("dap.core")),
745 ("supportsSetVariable", crate::feature_catalog::has_feature("dap.core")),
746 ("supportsValueFormattingOptions", crate::feature_catalog::has_feature("dap.core")),
747 ("supportTerminateDebuggee", crate::feature_catalog::has_feature("dap.core")),
748 ("supportsLogPoints", crate::feature_catalog::has_feature("dap.breakpoints.logpoints")),
749 (
750 "supportsExceptionOptions",
751 crate::feature_catalog::has_feature("dap.exceptions.die")
752 || crate::feature_catalog::has_feature("dap.exceptions.warn"),
753 ),
754 (
755 "supportsExceptionFilterOptions",
756 crate::feature_catalog::has_feature("dap.exceptions.die")
757 || crate::feature_catalog::has_feature("dap.exceptions.warn"),
758 ),
759 ("supportsInlineValues", crate::feature_catalog::has_feature("dap.inline_values")),
760 ("supportsTerminateRequest", crate::feature_catalog::has_feature("dap.core")),
761 ("supportsCompletionsRequest", crate::feature_catalog::has_feature("dap.completions")),
762 ("supportsModulesRequest", crate::feature_catalog::has_feature("dap.modules")),
763 ("supportsDataBreakpoints", crate::feature_catalog::has_feature("dap.watchpoints")),
764 (
765 "supportsTerminateThreadsRequest",
766 crate::feature_catalog::has_feature("dap.terminate_threads"),
767 ),
768 ("supportsGotoTargetsRequest", crate::feature_catalog::has_feature("dap.core")),
769 ("supportsRestartFrame", crate::feature_catalog::has_feature("dap.restart_frame")),
770 (
771 "supportsStepInTargetsRequest",
772 crate::feature_catalog::has_feature("dap.step_in_targets"),
773 ),
774 ];
775
776 for (capability, expected) in expectations {
777 let actual = capability_map
778 .get(capability)
779 .and_then(Value::as_bool)
780 .ok_or_else(|| format!("Capability `{capability}` must be present as boolean"))?;
781 assert_eq!(
782 actual, expected,
783 "Capability `{capability}` must mirror features.toml advertisement"
784 );
785 }
786
787 let exception_filters = capability_map
788 .get("exceptionBreakpointFilters")
789 .and_then(Value::as_array)
790 .ok_or("exceptionBreakpointFilters must be present as an array")?;
791
792 let has_filter = |id: &str| -> bool {
793 exception_filters.iter().any(|f| f.get("filter").and_then(Value::as_str) == Some(id))
794 };
795
796 let die_enabled = crate::feature_catalog::has_feature("dap.exceptions.die");
797 let warn_enabled = crate::feature_catalog::has_feature("dap.exceptions.warn");
798
799 assert_eq!(
800 has_filter("die"),
801 die_enabled,
802 "die filter presence must match dap.exceptions.die"
803 );
804 assert_eq!(
805 has_filter("all"),
806 die_enabled,
807 "all filter presence must match dap.exceptions.die"
808 );
809 assert_eq!(
810 has_filter("warn"),
811 warn_enabled,
812 "warn filter presence must match dap.exceptions.warn"
813 );
814
815 if !die_enabled && !warn_enabled {
816 assert!(
817 exception_filters.is_empty(),
818 "exceptionBreakpointFilters must be empty when no exception features are enabled"
819 );
820 }
821
822 Ok(())
823 }
824
825 #[test]
826 fn test_initialize_capabilities_are_backed_by_handlers()
827 -> Result<(), Box<dyn std::error::Error>> {
828 let mut adapter = DebugAdapter::new();
829 let init = adapter.handle_request(1, "initialize", None);
830
831 let capabilities = match init {
832 DapMessage::Response { success: true, command, body: Some(body), .. }
833 if command == "initialize" =>
834 {
835 body
836 }
837 _ => return Err("Expected successful initialize response".into()),
838 };
839
840 let capability_map =
841 capabilities.as_object().ok_or("Initialize response body must be a JSON object")?;
842
843 let capability_to_command = [
844 ("supportsConfigurationDoneRequest", "configurationDone"),
845 ("supportsFunctionBreakpoints", "setFunctionBreakpoints"),
846 ("supportsConditionalBreakpoints", "setBreakpoints"),
847 ("supportsHitConditionalBreakpoints", "setBreakpoints"),
848 ("supportsEvaluateForHovers", "evaluate"),
849 ("supportsSetVariable", "setVariable"),
850 ("supportsValueFormattingOptions", "variables"),
851 ("supportsLogPoints", "setBreakpoints"),
852 ("supportsExceptionOptions", "setExceptionBreakpoints"),
853 ("supportsExceptionFilterOptions", "setExceptionBreakpoints"),
854 ("supportsInlineValues", "inlineValues"),
855 ("supportsTerminateRequest", "terminate"),
856 ("supportTerminateDebuggee", "terminate"),
857 ("supportsCompletionsRequest", "completions"),
858 ("supportsModulesRequest", "modules"),
859 ("supportsRestartRequest", "restart"),
860 ("supportsExceptionInfoRequest", "exceptionInfo"),
861 ("supportsBreakpointLocationsRequest", "breakpointLocations"),
862 ("supportsSetExpression", "setExpression"),
863 ("supportsDataBreakpoints", "setDataBreakpoints"),
864 ("supportsLoadedSourcesRequest", "loadedSources"),
865 ("supportsCancelRequest", "cancel"),
866 ("supportsRestartFrame", "restartFrame"),
867 ("supportsStepInTargetsRequest", "stepInTargets"),
868 ("supportsGotoTargetsRequest", "gotoTargets"),
869 ("supportsTerminateThreadsRequest", "terminateThreads"),
870 ];
871
872 let mut mapped_commands = HashSet::new();
873 for (capability, raw_value) in capability_map {
874 let is_support_flag =
875 capability.starts_with("supports") || capability == "supportTerminateDebuggee";
876 if !is_support_flag || !raw_value.as_bool().unwrap_or(false) {
877 continue;
878 }
879
880 let command = capability_to_command
881 .iter()
882 .find_map(|(supported, command)| (*supported == capability).then_some(*command))
883 .ok_or_else(|| {
884 format!(
885 "Capability `{capability}` is true but has no handler mapping in this invariant test"
886 )
887 })?;
888
889 let _ = mapped_commands.insert(command);
890 }
891
892 let mut request_seq = 2;
893 for command in mapped_commands {
894 let arguments = match command {
895 "configurationDone" => Some(json!({})),
896 "setFunctionBreakpoints" => {
897 Some(json!({"breakpoints": [{ "name": "main::noop" }]}))
898 }
899 "setBreakpoints" => Some(json!({
900 "source": { "path": "/tmp/capability_honesty.pl" },
901 "breakpoints": [{ "line": 1, "hitCondition": ">= 1", "logMessage": "breakpoint hit" }]
902 })),
903 "setExceptionBreakpoints" => Some(json!({"filters": ["die"]})),
904 "evaluate" => Some(json!({"expression": "$x", "allowSideEffects": true})),
905 "setVariable" => {
906 Some(json!({"variablesReference": 11, "name": "$x", "value": "1"}))
907 }
908 "variables" => Some(json!({"variablesReference": 11})),
909 "inlineValues" => Some(json!({
910 "source": { "path": "/tmp/capability_honesty.pl" },
911 "startLine": 1,
912 "endLine": 1
913 })),
914 "terminate" => Some(json!({"restart": false})),
915 "completions" => Some(json!({"text": "pr", "column": 2})),
916 "modules" => Some(json!({})),
917 "restart" => Some(json!({})),
918 "exceptionInfo" => Some(json!({"threadId": 1})),
919 "breakpointLocations" => Some(json!({
920 "source": { "path": "/tmp/capability_honesty.pl" },
921 "line": 1
922 })),
923 "setExpression" => Some(json!({"expression": "$x", "value": "1"})),
924 "setDataBreakpoints" => Some(json!({"breakpoints": []})),
925 "loadedSources" => Some(json!({})),
926 "cancel" => Some(json!({})),
927 "stepInTargets" => Some(json!({"frameId": 1})),
928 "gotoTargets" => Some(json!({
929 "source": { "path": "/tmp/capability_honesty.pl" },
930 "line": 1
931 })),
932 "terminateThreads" => Some(json!({})),
933 _ => None,
934 };
935
936 let response = adapter.handle_request(request_seq, command, arguments);
937 request_seq += 1;
938
939 match response {
940 DapMessage::Response { command: actual, message, .. } => {
941 assert_eq!(
942 actual, command,
943 "Capability-mapped command `{command}` must route to its handler"
944 );
945 let message_text = message.unwrap_or_default();
946 assert!(
947 !message_text.contains("Unknown command"),
948 "Capability-mapped command `{command}` must not hit unknown-command path"
949 );
950 }
951 _ => return Err(format!("Expected response for `{command}`").into()),
952 }
953 }
954
955 let terminate_threads_expected =
957 crate::feature_catalog::has_feature("dap.terminate_threads");
958 assert_eq!(
959 capability_map.get("supportsTerminateThreadsRequest").and_then(|v| v.as_bool()),
960 Some(terminate_threads_expected),
961 "supportsTerminateThreadsRequest must match dap.terminate_threads feature setting"
962 );
963
964 Ok(())
965 }
966
967 #[test]
968 fn test_set_exception_breakpoints_toggles_die_filter() -> Result<(), Box<dyn std::error::Error>>
969 {
970 let mut adapter = DebugAdapter::new();
971
972 assert!(
973 !*lock_or_recover(
974 &adapter.exception_break_on_die,
975 "test_set_exception_breakpoints.initial"
976 ),
977 "die filter should default to disabled"
978 );
979
980 let response = adapter.handle_request(
981 1,
982 "setExceptionBreakpoints",
983 Some(json!({
984 "filters": ["die"]
985 })),
986 );
987 match response {
988 DapMessage::Response { success: true, command, .. } => {
989 assert_eq!(command, "setExceptionBreakpoints");
990 }
991 _ => return Err("Expected successful setExceptionBreakpoints response".into()),
992 }
993
994 assert!(
995 *lock_or_recover(
996 &adapter.exception_break_on_die,
997 "test_set_exception_breakpoints.enabled"
998 ),
999 "die filter should be enabled after request"
1000 );
1001
1002 let disable = adapter.handle_request(
1003 2,
1004 "setExceptionBreakpoints",
1005 Some(json!({
1006 "filters": []
1007 })),
1008 );
1009 match disable {
1010 DapMessage::Response { success: true, command, .. } => {
1011 assert_eq!(command, "setExceptionBreakpoints");
1012 }
1013 _ => return Err("Expected successful setExceptionBreakpoints response".into()),
1014 }
1015
1016 assert!(
1017 !*lock_or_recover(
1018 &adapter.exception_break_on_die,
1019 "test_set_exception_breakpoints.disabled"
1020 ),
1021 "die filter should be disabled when no matching filters are configured"
1022 );
1023
1024 Ok(())
1025 }
1026
1027 #[test]
1028 fn test_attach_missing_arguments() -> Result<(), Box<dyn std::error::Error>> {
1029 let mut adapter = DebugAdapter::new();
1030 let response = adapter.handle_request(1, "attach", None);
1031
1032 match response {
1033 DapMessage::Response { success, command, message, .. } => {
1034 assert!(!success);
1035 assert_eq!(command, "attach");
1036 assert!(message.is_some());
1037 let msg = message.ok_or("Expected message")?;
1038 assert!(msg.contains("Missing attach arguments"));
1039 }
1040 _ => return Err("Expected response".into()),
1041 }
1042 Ok(())
1043 }
1044
1045 #[test]
1046 fn test_attach_tcp_valid_arguments() -> Result<(), Box<dyn std::error::Error>> {
1047 let mut adapter = DebugAdapter::new();
1048 let args = json!({
1049 "host": "localhost",
1050 "port": 13603,
1051 "timeout": 5000
1052 });
1053 let response = adapter.handle_request(1, "attach", Some(args));
1054
1055 match response {
1056 DapMessage::Response { success, command, message, .. } => {
1057 assert!(!success); assert_eq!(command, "attach");
1059 assert!(message.is_some());
1060 let msg = message.ok_or("Expected message")?;
1061 assert!(msg.contains("localhost:13603"));
1062 assert!(msg.contains("5000ms timeout"));
1063 }
1064 _ => return Err("Expected response".into()),
1065 }
1066 Ok(())
1067 }
1068
1069 #[test]
1070 fn test_attach_process_id_mode() -> Result<(), Box<dyn std::error::Error>> {
1071 let mut adapter = DebugAdapter::new();
1072 let args = json!({
1073 "processId": 12345
1074 });
1075 let response = adapter.handle_request(1, "attach", Some(args));
1076
1077 match response {
1078 DapMessage::Response { success, command, body, message, .. } => {
1079 assert!(success);
1080 assert_eq!(command, "attach");
1081 assert!(body.is_some());
1082 let body = body.ok_or("Expected body")?;
1083 assert_eq!(body.get("processId").and_then(|v| v.as_u64()), Some(12345));
1084 assert!(message.is_some());
1085 let msg = message.ok_or("Expected message")?;
1086 assert!(msg.contains("signal-control mode"));
1087 }
1088 _ => return Err("Expected response".into()),
1089 }
1090 Ok(())
1091 }
1092
1093 #[test]
1094 fn test_attach_empty_host() -> Result<(), Box<dyn std::error::Error>> {
1095 let mut adapter = DebugAdapter::new();
1096 let args = json!({
1097 "host": "",
1098 "port": 13603
1099 });
1100 let response = adapter.handle_request(1, "attach", Some(args));
1101
1102 match response {
1103 DapMessage::Response { success, command, message, .. } => {
1104 assert!(!success);
1105 assert_eq!(command, "attach");
1106 assert!(message.is_some());
1107 let msg = message.ok_or("Expected message")?;
1108 assert!(msg.contains("Host cannot be empty"));
1109 }
1110 _ => return Err("Expected response".into()),
1111 }
1112 Ok(())
1113 }
1114
1115 #[test]
1116 fn test_attach_whitespace_host() -> Result<(), Box<dyn std::error::Error>> {
1117 let mut adapter = DebugAdapter::new();
1118 let args = json!({
1119 "host": " ",
1120 "port": 13603
1121 });
1122 let response = adapter.handle_request(1, "attach", Some(args));
1123
1124 match response {
1125 DapMessage::Response { success, command, message, .. } => {
1126 assert!(!success);
1127 assert_eq!(command, "attach");
1128 assert!(message.is_some());
1129 let msg = message.ok_or("Expected message")?;
1130 assert!(msg.contains("Host cannot be empty"));
1131 }
1132 _ => return Err("Expected response".into()),
1133 }
1134 Ok(())
1135 }
1136
1137 #[test]
1138 fn test_attach_zero_port() -> Result<(), Box<dyn std::error::Error>> {
1139 let mut adapter = DebugAdapter::new();
1140 let args = json!({
1141 "host": "localhost",
1142 "port": 0
1143 });
1144 let response = adapter.handle_request(1, "attach", Some(args));
1145
1146 match response {
1147 DapMessage::Response { success, command, message, .. } => {
1148 assert!(!success);
1149 assert_eq!(command, "attach");
1150 assert!(message.is_some());
1151 let msg = message.ok_or("Expected message")?;
1152 assert!(msg.contains("Port must be in range"));
1153 }
1154 _ => return Err("Expected response".into()),
1155 }
1156 Ok(())
1157 }
1158
1159 #[test]
1160 fn test_attach_zero_timeout() -> Result<(), Box<dyn std::error::Error>> {
1161 let mut adapter = DebugAdapter::new();
1162 let args = json!({
1163 "host": "localhost",
1164 "port": 13603,
1165 "timeout": 0
1166 });
1167 let response = adapter.handle_request(1, "attach", Some(args));
1168
1169 match response {
1170 DapMessage::Response { success, command, message, .. } => {
1171 assert!(!success);
1172 assert_eq!(command, "attach");
1173 assert!(message.is_some());
1174 let msg = message.ok_or("Expected message")?;
1175 assert!(msg.contains("Timeout must be greater than 0"));
1176 }
1177 _ => return Err("Expected response".into()),
1178 }
1179 Ok(())
1180 }
1181
1182 #[test]
1183 fn test_attach_excessive_timeout() -> Result<(), Box<dyn std::error::Error>> {
1184 let mut adapter = DebugAdapter::new();
1185 let args = json!({
1186 "host": "localhost",
1187 "port": 13603,
1188 "timeout": 400000
1189 });
1190 let response = adapter.handle_request(1, "attach", Some(args));
1191
1192 match response {
1193 DapMessage::Response { success, command, message, .. } => {
1194 assert!(!success);
1195 assert_eq!(command, "attach");
1196 assert!(message.is_some());
1197 let msg = message.ok_or("Expected message")?;
1198 assert!(msg.contains("Timeout cannot exceed"));
1199 }
1200 _ => return Err("Expected response".into()),
1201 }
1202 Ok(())
1203 }
1204
1205 #[test]
1206 fn test_attach_default_values() -> Result<(), Box<dyn std::error::Error>> {
1207 let mut adapter = DebugAdapter::new();
1208 let args = json!({});
1210 let response = adapter.handle_request(1, "attach", Some(args));
1211
1212 match response {
1213 DapMessage::Response { success, command, message, .. } => {
1214 assert!(!success);
1215 assert_eq!(command, "attach");
1216 assert!(message.is_some());
1217 let msg = message.ok_or("Expected message")?;
1219 assert!(msg.contains("localhost:13603"));
1220 }
1221 _ => return Err("Expected response".into()),
1222 }
1223 Ok(())
1224 }
1225
1226 #[test]
1227 fn test_attach_custom_port() -> Result<(), Box<dyn std::error::Error>> {
1228 let mut adapter = DebugAdapter::new();
1229 let args = json!({
1230 "host": "192.168.1.100",
1231 "port": 9000
1232 });
1233 let response = adapter.handle_request(1, "attach", Some(args));
1234
1235 match response {
1236 DapMessage::Response { success, command, message, .. } => {
1237 assert!(!success); assert_eq!(command, "attach");
1239 assert!(message.is_some());
1240 let msg = message.ok_or("Expected message")?;
1241 assert!(msg.contains("192.168.1.100:9000"));
1242 }
1243 _ => return Err("Expected response".into()),
1244 }
1245 Ok(())
1246 }
1247
1248 #[test]
1249 fn test_attach_trims_host_for_tcp_target() -> Result<(), Box<dyn std::error::Error>> {
1250 let mut adapter = DebugAdapter::new();
1251 let args = json!({
1252 "host": " 192.168.1.100 ",
1253 "port": 9000
1254 });
1255 let response = adapter.handle_request(1, "attach", Some(args));
1256
1257 match response {
1258 DapMessage::Response { success, command, message, .. } => {
1259 assert!(!success);
1260 assert_eq!(command, "attach");
1261 assert!(message.is_some());
1262 let msg = message.ok_or("Expected message")?;
1263 assert!(msg.contains("192.168.1.100:9000"));
1264 }
1265 _ => return Err("Expected response".into()),
1266 }
1267 Ok(())
1268 }
1269
1270 #[test]
1271 fn test_attach_accepts_timeout_ms_alias() -> Result<(), Box<dyn std::error::Error>> {
1272 let mut adapter = DebugAdapter::new();
1273 let args = json!({
1274 "host": "localhost",
1275 "port": 13603,
1276 "timeoutMs": 0
1277 });
1278 let response = adapter.handle_request(1, "attach", Some(args));
1279
1280 match response {
1281 DapMessage::Response { success, command, message, .. } => {
1282 assert!(!success);
1283 assert_eq!(command, "attach");
1284 assert!(message.is_some());
1285 let msg = message.ok_or("Expected message")?;
1286 assert!(msg.contains("Timeout must be greater than 0"));
1287 }
1288 _ => return Err("Expected response".into()),
1289 }
1290 Ok(())
1291 }
1292
1293 #[test]
1294 fn test_tcp_session_threads_non_empty() -> Result<(), Box<dyn std::error::Error>> {
1295 let adapter = DebugAdapter::new();
1296 {
1298 let mut guard = lock_or_recover(&adapter.tcp_session, "test.tcp_session");
1299 *guard = Some(TcpAttachSession::new());
1300 }
1301 let response = adapter.handle_threads(1, 1);
1302 match response {
1303 DapMessage::Response { success, body: Some(body), .. } => {
1304 assert!(success);
1305 let threads = body["threads"].as_array().ok_or("threads must be array")?;
1306 assert!(!threads.is_empty(), "TCP attach should return non-empty threads");
1307 assert_eq!(threads[0]["id"], 1);
1308 assert_eq!(threads[0]["name"], "TCP Attached Thread");
1309 }
1310 _ => return Err("Expected successful response with body".into()),
1311 }
1312 Ok(())
1313 }
1314
1315 #[test]
1316 fn test_attach_port_out_of_range() -> Result<(), Box<dyn std::error::Error>> {
1317 let mut adapter = DebugAdapter::new();
1318 let _ = adapter.handle_request(1, "initialize", None);
1320
1321 for port in [65536_u64, 70000, u64::MAX] {
1322 let args = json!({ "port": port });
1323 let response = adapter.handle_request(2, "attach", Some(args));
1324 match response {
1325 DapMessage::Response { success, message, .. } => {
1326 assert!(!success, "port {port} should be rejected");
1327 assert!(
1328 message.as_ref().is_some_and(|m| m.contains("out of range")),
1329 "expected 'out of range' error for port {port}, got: {message:?}"
1330 );
1331 }
1332 _ => return Err(format!("Expected error response for port {port}").into()),
1333 }
1334 }
1335 Ok(())
1336 }
1337
1338 #[test]
1339 fn test_attach_port_valid_boundary() {
1340 let mut adapter = DebugAdapter::new();
1341 let _ = adapter.handle_request(1, "initialize", None);
1342
1343 for port in [1_u64, 65535] {
1345 let args = json!({ "port": port });
1346 let response = adapter.handle_request(2, "attach", Some(args));
1347 if let DapMessage::Response { message, .. } = response {
1348 assert!(
1350 !message.as_ref().is_some_and(|m| m.contains("out of range")),
1351 "port {port} should pass range validation, got: {message:?}"
1352 );
1353 }
1354 }
1355 }
1356
1357 #[test]
1358 fn test_goto_missing_arguments() -> Result<(), Box<dyn std::error::Error>> {
1359 let mut adapter = DebugAdapter::new();
1360 let response = adapter.handle_request(1, "goto", None);
1361 match response {
1362 DapMessage::Response { success, command, message, .. } => {
1363 assert!(!success);
1364 assert_eq!(command, "goto");
1365 assert_eq!(message.as_deref(), Some("Missing or invalid arguments"));
1366 }
1367 _ => return Err("Expected response".into()),
1368 }
1369 Ok(())
1370 }
1371
1372 #[test]
1373 fn test_goto_invalid_target() -> Result<(), Box<dyn std::error::Error>> {
1374 let mut adapter = DebugAdapter::new();
1375 let response =
1376 adapter.handle_request(1, "goto", Some(json!({"threadId": 1, "targetId": -1})));
1377 match response {
1378 DapMessage::Response { success, command, message, .. } => {
1379 assert!(!success);
1380 assert_eq!(command, "goto");
1381 let msg = message.as_deref().unwrap_or("");
1383 assert!(
1384 msg.contains("Unknown goto target"),
1385 "expected unknown target message, got: {msg}"
1386 );
1387 }
1388 _ => return Err("Expected response".into()),
1389 }
1390 Ok(())
1391 }
1392
1393 #[test]
1394 fn test_goto_no_session() -> Result<(), Box<dyn std::error::Error>> {
1395 let mut adapter = DebugAdapter::new();
1396 {
1398 let mut goto_map = lock_or_recover(&adapter.goto_targets, "test.goto_targets");
1399 goto_map.insert(10, ("/test/file.pl".to_string(), 10));
1400 }
1401 let response =
1402 adapter.handle_request(1, "goto", Some(json!({"threadId": 1, "targetId": 10})));
1403 match response {
1404 DapMessage::Response { success, command, message, .. } => {
1405 assert!(!success);
1406 assert_eq!(command, "goto");
1407 assert_eq!(message.as_deref(), Some("No active debug session"));
1408 }
1409 _ => return Err("Expected response".into()),
1410 }
1411 Ok(())
1412 }
1413
1414 #[test]
1415 fn test_terminate_threads_capability_is_advertised_when_feature_enabled()
1416 -> Result<(), Box<dyn std::error::Error>> {
1417 let mut adapter = DebugAdapter::new();
1418 let init = adapter.handle_request(1, "initialize", None);
1419 let capabilities = match init {
1420 DapMessage::Response { success: true, body: Some(body), .. } => body,
1421 _ => return Err("Expected successful initialize response".into()),
1422 };
1423 let cap_map = capabilities.as_object().ok_or("body must be object")?;
1424 let expected = crate::feature_catalog::has_feature("dap.terminate_threads");
1425 assert_eq!(
1426 cap_map.get("supportsTerminateThreadsRequest").and_then(|v| v.as_bool()),
1427 Some(expected),
1428 "supportsTerminateThreadsRequest must match dap.terminate_threads feature setting"
1429 );
1430 Ok(())
1431 }
1432
1433 #[test]
1434 fn test_goto_targets_then_goto_flow() -> Result<(), Box<dyn std::error::Error>> {
1435 let mut adapter = DebugAdapter::new();
1436
1437 let gt_response = adapter.handle_request(
1439 1,
1440 "gotoTargets",
1441 Some(json!({"source": {"path": "/tmp/nonexistent.pl"}, "line": 1})),
1442 );
1443 match gt_response {
1444 DapMessage::Response { success, command, message, .. } => {
1445 assert!(success, "gotoTargets should succeed");
1446 assert_eq!(command, "gotoTargets");
1447 assert!(
1449 !message.as_deref().unwrap_or("").contains("does not support"),
1450 "gotoTargets must not claim lack of support"
1451 );
1452 }
1453 _ => return Err("Expected response".into()),
1454 }
1455
1456 let goto_response =
1458 adapter.handle_request(2, "goto", Some(json!({"threadId": 1, "targetId": 999})));
1459 match goto_response {
1460 DapMessage::Response { success, command, message, .. } => {
1461 assert!(!success, "goto with unknown target should fail");
1462 assert_eq!(command, "goto");
1463 let msg = message.as_deref().unwrap_or("");
1464 assert!(
1465 msg.contains("Unknown goto target"),
1466 "goto must report unknown target, got: {msg}"
1467 );
1468 }
1469 _ => return Err("Expected response".into()),
1470 }
1471 Ok(())
1472 }
1473
1474 #[test]
1475 fn test_goto_targets_stores_mapping() -> Result<(), Box<dyn std::error::Error>> {
1476 use std::io::Write;
1477
1478 let mut adapter = DebugAdapter::new();
1479 adapter.handle_request(1, "initialize", None);
1480
1481 let dir = tempfile::tempdir()?;
1483 let file_path = dir.path().join("test_goto.pl");
1484 {
1485 let mut f = std::fs::File::create(&file_path)?;
1486 writeln!(f, "my $x = 1;")?;
1487 writeln!(f, "my $y = 2;")?;
1488 writeln!(f, "print $x + $y;")?;
1489 }
1490
1491 let path_str = file_path.to_string_lossy().to_string();
1492 let response = adapter.handle_request(
1493 2,
1494 "gotoTargets",
1495 Some(json!({
1496 "source": {"path": path_str},
1497 "line": 2
1498 })),
1499 );
1500
1501 match response {
1503 DapMessage::Response { success, body: Some(body), .. } => {
1504 assert!(success, "gotoTargets should succeed");
1505 let targets = body
1506 .get("targets")
1507 .and_then(|t| t.as_array())
1508 .ok_or("should have targets array")?;
1509 assert!(!targets.is_empty(), "should find executable lines");
1510
1511 let first_id = targets[0].get("id").and_then(|v| v.as_i64()).unwrap_or(0);
1513 assert!(first_id >= 1, "IDs should start at 1 or higher");
1514
1515 let goto_map = lock_or_recover(&adapter.goto_targets, "test.goto_targets");
1517 assert!(!goto_map.is_empty(), "goto_targets map should be populated");
1518 for (_id, (stored_path, _line)) in goto_map.iter() {
1520 assert_eq!(stored_path, &path_str, "stored path should match source");
1521 }
1522 }
1523 _ => return Err("Expected successful response".into()),
1524 }
1525
1526 let _ = std::fs::remove_file(&file_path);
1527 Ok(())
1528 }
1529
1530 #[test]
1531 fn test_goto_uses_stored_mapping() -> Result<(), Box<dyn std::error::Error>> {
1532 let mut adapter = DebugAdapter::new();
1533 adapter.handle_request(1, "initialize", None);
1534
1535 {
1537 let mut goto_map = lock_or_recover(&adapter.goto_targets, "test.goto_targets");
1538 goto_map.insert(42, ("/some/file.pl".to_string(), 10));
1539 }
1540
1541 let response =
1544 adapter.handle_request(2, "goto", Some(json!({"threadId": 1, "targetId": 42})));
1545 match response {
1546 DapMessage::Response { success, command, message, .. } => {
1547 assert!(!success, "goto without session should fail");
1548 assert_eq!(command, "goto");
1549 let msg = message.as_deref().unwrap_or("");
1551 assert!(
1552 msg.contains("No active debug session"),
1553 "goto should report no session, got: {msg}"
1554 );
1555 }
1556 _ => return Err("Expected response".into()),
1557 }
1558
1559 let goto_map = lock_or_recover(&adapter.goto_targets, "test.goto_targets");
1561 assert!(!goto_map.contains_key(&42), "consumed goto target should be removed from map");
1562 Ok(())
1563 }
1564
1565 #[test]
1566 fn test_inline_values_rejects_traversal() -> Result<(), Box<dyn std::error::Error>> {
1567 let mut adapter = DebugAdapter::new();
1568 adapter.handle_request(1, "initialize", None);
1569
1570 let dir = tempfile::tempdir()?;
1571 *lock_or_recover(&adapter.workspace_root, "test.workspace_root") =
1572 Some(dir.path().to_path_buf());
1573
1574 let response = adapter.handle_request(
1575 2,
1576 "inlineValues",
1577 Some(json!({
1578 "source": {"path": "../../../etc/passwd"},
1579 "startLine": 1,
1580 "endLine": 1
1581 })),
1582 );
1583 match response {
1584 DapMessage::Response { success, command, message, .. } => {
1585 assert!(!success, "inlineValues with traversal path should fail");
1586 assert_eq!(command, "inlineValues");
1587 let msg = message.as_deref().unwrap_or("");
1588 assert!(
1589 msg.contains("Path validation failed"),
1590 "should report path validation failure, got: {msg}"
1591 );
1592 }
1593 _ => return Err("Expected response".into()),
1594 }
1595 Ok(())
1596 }
1597
1598 #[test]
1599 fn test_source_rejects_traversal() -> Result<(), Box<dyn std::error::Error>> {
1600 let mut adapter = DebugAdapter::new();
1601 adapter.handle_request(1, "initialize", None);
1602
1603 let dir = tempfile::tempdir()?;
1605 *lock_or_recover(&adapter.workspace_root, "test.workspace_root") =
1606 Some(dir.path().to_path_buf());
1607
1608 let response = adapter.handle_request(
1609 2,
1610 "source",
1611 Some(json!({
1612 "source": {"path": "../../../etc/passwd"},
1613 "sourceReference": 0
1614 })),
1615 );
1616 match response {
1617 DapMessage::Response { success, command, message, .. } => {
1618 assert!(!success, "source with traversal path should fail");
1619 assert_eq!(command, "source");
1620 let msg = message.as_deref().unwrap_or("");
1621 assert!(
1622 msg.contains("Path validation failed"),
1623 "should report path validation failure, got: {msg}"
1624 );
1625 }
1626 _ => return Err("Expected response".into()),
1627 }
1628 Ok(())
1629 }
1630
1631 #[test]
1632 fn test_breakpoint_locations_rejects_traversal() -> Result<(), Box<dyn std::error::Error>> {
1633 let mut adapter = DebugAdapter::new();
1634 adapter.handle_request(1, "initialize", None);
1635
1636 let dir = tempfile::tempdir()?;
1637 *lock_or_recover(&adapter.workspace_root, "test.workspace_root") =
1638 Some(dir.path().to_path_buf());
1639
1640 let response = adapter.handle_request(
1641 2,
1642 "breakpointLocations",
1643 Some(json!({
1644 "source": {"path": "../../../etc/passwd"},
1645 "line": 1
1646 })),
1647 );
1648 match response {
1649 DapMessage::Response { success, command, message, .. } => {
1650 assert!(!success, "breakpointLocations with traversal path should fail");
1651 assert_eq!(command, "breakpointLocations");
1652 let msg = message.as_deref().unwrap_or("");
1653 assert!(
1654 msg.contains("Path validation failed"),
1655 "should report path validation failure, got: {msg}"
1656 );
1657 }
1658 _ => return Err("Expected response".into()),
1659 }
1660 Ok(())
1661 }
1662
1663 #[test]
1664 fn test_goto_targets_rejects_traversal() -> Result<(), Box<dyn std::error::Error>> {
1665 let mut adapter = DebugAdapter::new();
1666 adapter.handle_request(1, "initialize", None);
1667
1668 let dir = tempfile::tempdir()?;
1669 *lock_or_recover(&adapter.workspace_root, "test.workspace_root") =
1670 Some(dir.path().to_path_buf());
1671
1672 let response = adapter.handle_request(
1673 2,
1674 "gotoTargets",
1675 Some(json!({
1676 "source": {"path": "../../../etc/passwd"},
1677 "line": 1
1678 })),
1679 );
1680 match response {
1681 DapMessage::Response { success, command, message, .. } => {
1682 assert!(!success, "gotoTargets with traversal path should fail");
1683 assert_eq!(command, "gotoTargets");
1684 let msg = message.as_deref().unwrap_or("");
1685 assert!(
1686 msg.contains("Path validation failed"),
1687 "should report path validation failure, got: {msg}"
1688 );
1689 }
1690 _ => return Err("Expected response".into()),
1691 }
1692 Ok(())
1693 }
1694
1695 #[test]
1698 fn test_send_continue_signal_does_not_panic_on_pid_1() {
1699 let adapter = DebugAdapter::new();
1702 let _ = adapter.send_continue_signal(1);
1703 }
1704
1705 #[test]
1706 fn test_send_interrupt_signal_does_not_panic_on_pid_1() {
1707 let adapter = DebugAdapter::new();
1708 let _ = adapter.send_interrupt_signal(1);
1709 }
1710
1711 #[test]
1712 fn test_send_continue_signal_pid_zero_returns_false() {
1713 let adapter = DebugAdapter::new();
1714 assert!(!adapter.send_continue_signal(0));
1715 }
1716
1717 #[test]
1718 fn test_send_interrupt_signal_pid_zero_returns_false() {
1719 let adapter = DebugAdapter::new();
1720 assert!(!adapter.send_interrupt_signal(0));
1721 }
1722
1723 #[test]
1724 #[cfg(unix)]
1725 fn test_send_continue_signal_nonexistent_pid_returns_false() {
1726 let adapter = DebugAdapter::new();
1727 assert!(!adapter.send_continue_signal(999_999));
1728 }
1729
1730 #[test]
1731 #[cfg(unix)]
1732 fn test_send_interrupt_signal_nonexistent_pid_returns_false() {
1733 let adapter = DebugAdapter::new();
1734 assert!(!adapter.send_interrupt_signal(999_999));
1735 }
1736
1737 fn apply_context_re(line: &str) -> Option<(String, String)> {
1741 let re = context_re()?;
1742 let caps = re.captures(line)?;
1743 let file =
1744 caps.name("file").or_else(|| caps.name("file2")).map(|m| m.as_str().to_string())?;
1745 let line_num =
1746 caps.name("line").or_else(|| caps.name("line2")).map(|m| m.as_str().to_string())?;
1747 Some((file, line_num))
1748 }
1749
1750 #[test]
1751 fn test_context_re_unix_path() {
1752 let result = apply_context_re("main::(/path/to/file.pl:42):");
1753 assert_eq!(result, Some(("/path/to/file.pl".to_string(), "42".to_string())));
1754 }
1755
1756 #[test]
1757 fn test_context_re_windows_drive_letter_backslash() {
1758 let result = apply_context_re(r"main::(C:\Users\name\file.pl:42):");
1761 assert_eq!(result, Some((r"C:\Users\name\file.pl".to_string(), "42".to_string())));
1762 }
1763
1764 #[test]
1765 fn test_context_re_windows_drive_letter_forward_slash() {
1766 let result = apply_context_re("main::(C:/Users/file.pl:7):");
1768 assert_eq!(result, Some(("C:/Users/file.pl".to_string(), "7".to_string())));
1769 }
1770
1771 #[test]
1772 fn test_context_re_unc_path() {
1773 let result = apply_context_re(r"main::(\\server\share\file.pl:5):");
1775 assert_eq!(result, Some((r"\\server\share\file.pl".to_string(), "5".to_string())));
1776 }
1777
1778 #[test]
1779 fn test_context_re_named_function() {
1780 let result = apply_context_re("Foo::Bar::(/path/script.pl:10):");
1782 assert_eq!(result, Some(("/path/script.pl".to_string(), "10".to_string())));
1783 }
1784
1785 #[test]
1786 fn test_context_re_windows_path_named_function() {
1787 let result = apply_context_re(r"Foo::Bar::(C:\path\script.pl:10):");
1789 assert_eq!(result, Some((r"C:\path\script.pl".to_string(), "10".to_string())));
1790 }
1791
1792 #[test]
1793 fn test_context_re_no_match_path_with_spaces() {
1794 let result = apply_context_re("main::(/path with spaces/file.pl:5):");
1796 assert!(result.is_none(), "paths with spaces should not match");
1797 }
1798
1799 #[test]
1800 fn test_context_re_colon_digit_is_line_separator() {
1801 let result = apply_context_re("main::(/path/file.pl:42):");
1804 assert_eq!(result, Some(("/path/file.pl".to_string(), "42".to_string())));
1805 }
1806
1807 fn make_test_frame(line_num: i32) -> StackFrame {
1809 StackFrame::new(
1810 line_num,
1811 format!("test_func::{}", line_num),
1812 Source::new(format!("/test/file{}.pl", line_num)),
1813 line_num,
1814 )
1815 }
1816
1817 #[test]
1818 fn test_handle_continue_clears_stack_frames() -> Result<(), Box<dyn std::error::Error>> {
1819 let adapter = DebugAdapter::new();
1820 adapter.seed_session_for_test();
1821 adapter.inject_stack_frames_for_test(vec![make_test_frame(1), make_test_frame(2)]);
1822
1823 assert_eq!(
1825 adapter.stack_frames_snapshot_for_test().len(),
1826 2,
1827 "precondition: should have 2 frames before continue"
1828 );
1829
1830 let _response = adapter.handle_continue(1, 1, None);
1832
1833 assert_eq!(
1835 adapter.stack_frames_snapshot_for_test().len(),
1836 0,
1837 "handle_continue must clear stack_frames after resume"
1838 );
1839 Ok(())
1840 }
1841
1842 #[test]
1843 fn test_handle_next_clears_stack_frames() -> Result<(), Box<dyn std::error::Error>> {
1844 let adapter = DebugAdapter::new();
1845 adapter.seed_session_for_test();
1846 adapter.inject_stack_frames_for_test(vec![make_test_frame(1), make_test_frame(2)]);
1847
1848 assert_eq!(adapter.stack_frames_snapshot_for_test().len(), 2);
1849 let _response = adapter.handle_next(1, 1, None);
1850 assert_eq!(
1851 adapter.stack_frames_snapshot_for_test().len(),
1852 0,
1853 "handle_next must clear stack_frames after resume"
1854 );
1855 Ok(())
1856 }
1857
1858 #[test]
1859 fn test_handle_step_in_clears_stack_frames() -> Result<(), Box<dyn std::error::Error>> {
1860 let adapter = DebugAdapter::new();
1861 adapter.seed_session_for_test();
1862 adapter.inject_stack_frames_for_test(vec![make_test_frame(1), make_test_frame(2)]);
1863
1864 assert_eq!(adapter.stack_frames_snapshot_for_test().len(), 2);
1865 let _response = adapter.handle_step_in(1, 1, None);
1866 assert_eq!(
1867 adapter.stack_frames_snapshot_for_test().len(),
1868 0,
1869 "handle_step_in must clear stack_frames after resume"
1870 );
1871 Ok(())
1872 }
1873
1874 #[test]
1875 fn test_handle_step_out_clears_stack_frames() -> Result<(), Box<dyn std::error::Error>> {
1876 let adapter = DebugAdapter::new();
1877 adapter.seed_session_for_test();
1878 adapter.inject_stack_frames_for_test(vec![make_test_frame(1), make_test_frame(2)]);
1879
1880 assert_eq!(adapter.stack_frames_snapshot_for_test().len(), 2);
1881 let _response = adapter.handle_step_out(1, 1, None);
1882 assert_eq!(
1883 adapter.stack_frames_snapshot_for_test().len(),
1884 0,
1885 "handle_step_out must clear stack_frames after resume"
1886 );
1887 Ok(())
1888 }
1889
1890 #[test]
1891 fn test_handle_pause_clears_stack_frames() -> Result<(), Box<dyn std::error::Error>> {
1892 let adapter = DebugAdapter::new();
1893 adapter.seed_session_for_test();
1894 adapter.inject_stack_frames_for_test(vec![make_test_frame(1), make_test_frame(2)]);
1895
1896 assert_eq!(adapter.stack_frames_snapshot_for_test().len(), 2);
1897 let _response = adapter.handle_pause(1, 1, None);
1898 assert_eq!(
1899 adapter.stack_frames_snapshot_for_test().len(),
1900 0,
1901 "handle_pause must clear stack_frames after pause"
1902 );
1903 Ok(())
1904 }
1905
1906 #[test]
1907 fn test_handle_goto_clears_stack_frames() -> Result<(), Box<dyn std::error::Error>> {
1908 let adapter = DebugAdapter::new();
1912 adapter.seed_session_for_test();
1913 adapter.inject_stack_frames_for_test(vec![make_test_frame(1), make_test_frame(2)]);
1914
1915 assert_eq!(
1917 adapter.stack_frames_snapshot_for_test().len(),
1918 2,
1919 "precondition: should have 2 frames before goto"
1920 );
1921
1922 {
1925 let mut goto_map =
1926 lock_or_recover(&adapter.goto_targets, "test.handle_goto_clears_stack_frames");
1927 goto_map.insert(1, ("/tmp/test_goto.pl".to_string(), 5));
1928 }
1929
1930 let _response = adapter.handle_goto(1, 1, Some(json!({"threadId": 1, "targetId": 1})));
1933
1934 assert_eq!(
1936 adapter.stack_frames_snapshot_for_test().len(),
1937 0,
1938 "handle_goto must clear stack_frames after resume"
1939 );
1940 Ok(())
1941 }
1942
1943 #[test]
1944 fn test_configuration_done_without_launch_should_fail() -> Result<(), Box<dyn std::error::Error>>
1945 {
1946 let mut adapter = DebugAdapter::new();
1947
1948 let init_response = adapter.handle_request(1, "initialize", None);
1950 match init_response {
1951 DapMessage::Response { success: true, command, .. } => {
1952 assert_eq!(command, "initialize");
1953 }
1954 _ => return Err("Initialize should succeed".into()),
1955 }
1956
1957 let config_response = adapter.handle_request(2, "configurationDone", None);
1959
1960 match config_response {
1962 DapMessage::Response { success: false, command, message, .. } => {
1963 assert_eq!(command, "configurationDone");
1964 assert!(message.is_some(), "should provide error message");
1965 let msg = message.ok_or("Expected error message")?;
1966 assert!(
1967 msg.contains("No active debug session")
1968 || msg.contains("launch")
1969 || msg.contains("session"),
1970 "Error message should explain that launch was not called, got: {msg}"
1971 );
1972 Ok(())
1973 }
1974 DapMessage::Response { success: true, .. } => {
1975 Err("configurationDone should FAIL when no launch has been called".into())
1976 }
1977 _ => Err("Expected response".into()),
1978 }
1979 }
1980
1981 #[test]
1982 fn test_launch_before_initialize_should_fail() -> Result<(), Box<dyn std::error::Error>> {
1983 let mut adapter = DebugAdapter::new();
1984
1985 let launch_response = adapter.handle_request(
1987 1,
1988 "launch",
1989 Some(json!({
1990 "program": "/tmp/test.pl"
1991 })),
1992 );
1993
1994 match launch_response {
1996 DapMessage::Response { success: false, command, message, .. } => {
1997 assert_eq!(command, "launch");
1998 assert!(message.is_some(), "should provide error message");
1999 let msg = message.ok_or("Expected error message")?;
2000 assert!(
2001 msg.contains("initialize")
2002 || msg.contains("Initialize")
2003 || msg.contains("session"),
2004 "Error message should explain that initialize is required, got: {msg}"
2005 );
2006 Ok(())
2007 }
2008 DapMessage::Response { success: true, .. } => {
2009 Err("launch should FAIL when initialize has not been called".into())
2010 }
2011 _ => Err("Expected response".into()),
2012 }
2013 }
2014}