use super::*;
impl DebugAdapter {
pub(super) fn normalize_debugger_output_line(line: &str) -> String {
let mut normalized = if let Some(re) = ansi_escape_re() {
re.replace_all(line, "").into_owned()
} else {
line.to_string()
};
while let Some(prompt_start) = normalized.find("DB<")
&& let Some(prompt_end) = normalized[prompt_start..].find('>')
{
let content_start = prompt_start + prompt_end + 1;
normalized = normalized[content_start..].to_string();
}
normalized.trim().to_string()
}
pub(super) fn infer_debugger_value_type(text: &str) -> String {
if text == "undef" {
"undef".to_string()
} else if text.parse::<i64>().is_ok() {
"integer".to_string()
} else if text.parse::<f64>().is_ok() {
"number".to_string()
} else if text.starts_with('[') && text.ends_with(']') {
"array".to_string()
} else if text.starts_with('{') && text.ends_with('}') {
"hash".to_string()
} else {
"string".to_string()
}
}
pub(super) fn rendered_to_variable(rendered: RenderedVariable) -> Variable {
Variable {
name: rendered.name,
value: rendered.value,
type_: rendered.type_name,
variables_reference: Self::i64_to_i32_saturating(rendered.variables_reference),
named_variables: rendered.named_variables.map(Self::i64_to_i32_saturating),
indexed_variables: rendered.indexed_variables.map(Self::i64_to_i32_saturating),
}
}
pub(super) fn scope_allows_variable_name(scope_type: i32, name: &str) -> bool {
match scope_type {
1 => !name.contains("::"),
2 => name.contains("::"),
3 => {
matches!(name, "$_" | "@ARGV" | "%ENV" | "$!" | "$@" | "$/" | "$|" | "$0" | "$^W")
|| name.starts_with("$^")
}
_ => true,
}
}
pub(super) fn parse_stack_frames_from_text(output: &str) -> Vec<StackFrame> {
let mut parser = PerlStackParser::new();
parser
.parse_stack_trace(output)
.into_iter()
.map(|frame| {
let source = frame.source.unwrap_or_default();
let path = source.path.unwrap_or_else(|| "<unknown>".to_string());
let name = source.name.or_else(|| {
std::path::Path::new(&path)
.file_name()
.and_then(|n| n.to_str())
.map(ToString::to_string)
});
StackFrame {
id: Self::i64_to_i32_saturating(frame.id),
name: frame.name,
source: Source { name, path, source_reference: None },
line: Self::i64_to_i32_saturating(frame.line),
column: Self::i64_to_i32_saturating(frame.column),
end_line: frame.end_line.map(Self::i64_to_i32_saturating),
end_column: frame.end_column.map(Self::i64_to_i32_saturating),
}
})
.collect()
}
pub(super) fn filter_user_visible_frames(frames: Vec<StackFrame>) -> Vec<StackFrame> {
frames
.into_iter()
.filter(|frame| {
!is_internal_frame_name_and_path(&frame.name, Some(frame.source.path.as_str()))
})
.collect()
}
pub(super) fn parse_scope_variables_from_lines(
lines: &[String],
variables_ref: i32,
start: usize,
count: usize,
) -> (Vec<Variable>, HashMap<i32, Vec<Variable>>) {
let parser = VariableParser::new();
let renderer = PerlVariableRenderer::new();
let scope_type = variables_ref % 10;
let mut seen = HashSet::new();
let mut parsed = Vec::new();
for line in lines.iter().rev() {
let normalized = Self::normalize_debugger_output_line(line);
let text = normalized.trim();
if text.is_empty() {
continue;
}
if let Ok((name, value)) = parser.parse_assignment(text) {
if !Self::scope_allows_variable_name(scope_type, &name) {
continue;
}
if seen.insert(name.clone()) {
parsed.push((name, value));
}
if parsed.len() >= 256 {
break;
}
}
}
parsed.reverse();
parsed.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
let mut top_level = Vec::new();
let mut child_cache = HashMap::new();
for (idx, (name, value)) in parsed.into_iter().skip(start).take(count).enumerate() {
let absolute_index = start.saturating_add(idx).saturating_add(1);
let child_ref =
variables_ref.saturating_mul(1000).saturating_add(Self::i64_to_i32_saturating(
i64::try_from(absolute_index).unwrap_or(i64::from(i32::MAX)),
));
let rendered = if value.is_expandable() {
renderer.render_with_reference(&name, &value, i64::from(child_ref))
} else {
renderer.render(&name, &value)
};
top_level.push(Self::rendered_to_variable(rendered));
if value.is_expandable() {
let children = renderer
.render_children(&value, 0, 256)
.into_iter()
.map(Self::rendered_to_variable)
.collect::<Vec<_>>();
if !children.is_empty() {
child_cache.insert(child_ref, children);
}
}
}
(top_level, child_cache)
}
pub(super) fn parse_scope_variables_from_output(
&self,
variables_ref: i32,
start: usize,
count: usize,
) -> (Vec<Variable>, HashMap<i32, Vec<Variable>>) {
let lines = self.snapshot_recent_output_lines();
Self::parse_scope_variables_from_lines(&lines, variables_ref, start, count)
}
pub(super) fn parse_evaluate_result_from_lines(
lines: &[String],
expression: &str,
allow_fallback_line: bool,
) -> Option<(String, String)> {
if lines.is_empty() {
return None;
}
let parser = VariableParser::new();
let renderer = PerlVariableRenderer::new();
for line in lines.iter().rev() {
let normalized = Self::normalize_debugger_output_line(line);
let text = normalized.trim();
if text.is_empty() || prompt_re().is_some_and(|re| re.is_match(text)) {
continue;
}
if let Ok((name, value)) = parser.parse_assignment(text) {
let rendered = renderer.render(&name, &value);
let type_name = rendered.type_name.unwrap_or_else(|| "string".to_string());
if name == expression || text.starts_with(expression) || text.contains(expression) {
return Some((rendered.value, type_name));
}
if !allow_fallback_line {
continue;
}
return Some((rendered.value, type_name));
}
if allow_fallback_line {
return Some((text.to_string(), Self::infer_debugger_value_type(text)));
}
}
None
}
pub(super) fn parse_evaluate_error_from_lines(lines: &[String]) -> Option<String> {
const ERROR_PREFIXES: &[&str] =
&["Undefined", "Can't ", "syntax error", "Execution of ", "Use of uninitialized"];
for line in lines.iter().rev() {
let normalized = Self::normalize_debugger_output_line(line);
let text = normalized.trim();
if text.is_empty() || prompt_re().is_some_and(|re| re.is_match(text)) {
continue;
}
if ERROR_PREFIXES.iter().any(|prefix| text.starts_with(prefix)) {
return Some(format!("evaluate failed: {text}"));
}
}
None
}
pub(super) fn parse_evaluate_result_from_output(
&self,
expression: &str,
) -> Option<(String, String)> {
let lines = self.snapshot_recent_output_lines();
Self::parse_evaluate_result_from_lines(&lines, expression, true)
}
pub(super) fn fallback_scope_variables(
variables_ref: i32,
start: usize,
count: usize,
) -> Vec<Variable> {
let variables = match variables_ref % 10 {
1 => vec![
Variable {
name: "$self".to_string(),
value: "blessed(My::Module)".to_string(),
type_: Some("hash".to_string()),
variables_reference: variables_ref.saturating_mul(100) + 2,
named_variables: Some(5),
indexed_variables: None,
},
Variable {
name: "@_".to_string(),
value: "array(size=0)".to_string(),
type_: Some("array".to_string()),
variables_reference: variables_ref.saturating_mul(100) + 1,
named_variables: None,
indexed_variables: Some(0),
},
],
2 => vec![Variable {
name: "$VERSION".to_string(),
value: "\"1.0.0\"".to_string(),
type_: Some("scalar".to_string()),
variables_reference: 0,
named_variables: None,
indexed_variables: None,
}],
3 => vec![Variable {
name: "$_".to_string(),
value: "undef".to_string(),
type_: Some("scalar".to_string()),
variables_reference: 0,
named_variables: None,
indexed_variables: None,
}],
_ => Vec::new(),
};
variables.into_iter().skip(start).take(count).collect()
}
#[cfg(test)]
pub(super) fn parse_stack_trace(output: &str) -> Vec<StackFrame> {
let mut frames = Vec::new();
let mut frame_id = 1;
for line in output.lines() {
if let Some(re) = stack_frame_re() {
if let Some(caps) = re.captures(line) {
let func = caps.name("func").map(|m| m.as_str()).unwrap_or("main");
let file = caps.name("file").map(|m| m.as_str()).unwrap_or("<unknown>");
let line_num =
caps.name("line").and_then(|m| m.as_str().parse::<i32>().ok()).unwrap_or(1);
let file_name = file.split(['/', '\\'].as_ref()).next_back().unwrap_or(file);
frames.push(StackFrame {
id: frame_id,
name: func.to_string(),
source: Source {
name: Some(file_name.to_string()),
path: file.to_string(),
source_reference: None,
},
line: line_num,
column: 1, end_line: None,
end_column: None,
});
frame_id += 1;
}
}
}
frames
}
}
#[cfg(test)]
mod tests {
use super::super::*;
use std::thread;
use std::time::{Duration, Instant};
#[test]
pub(super) fn test_parse_scope_variables_from_recent_output()
-> Result<(), Box<dyn std::error::Error>> {
let adapter = DebugAdapter::new();
adapter.push_recent_output_line_for_test("$foo = 42");
adapter.push_recent_output_line_for_test("@arr = (1, 2, 3)");
adapter.push_recent_output_line_for_test("%hash = {a => 1}");
let (vars, child_cache) = adapter.parse_scope_variables_from_output(11, 0, 20);
let names: Vec<&str> = vars.iter().map(|v| v.name.as_str()).collect();
assert!(names.contains(&"$foo"));
assert!(names.contains(&"@arr"));
assert!(names.contains(&"%hash"));
assert!(!child_cache.is_empty(), "expected child cache entries for expandable values");
Ok(())
}
#[test]
pub(super) fn test_parse_scope_variables_are_sorted_for_stability()
-> Result<(), Box<dyn std::error::Error>> {
let lines = vec!["$zeta = 1".to_string(), "$alpha = 2".to_string(), "$mid = 3".to_string()];
let (vars, _child_cache) =
DebugAdapter::parse_scope_variables_from_lines(&lines, 11, 0, 20);
let names = vars.iter().map(|v| v.name.as_str()).collect::<Vec<_>>();
assert_eq!(names, vec!["$alpha", "$mid", "$zeta"]);
Ok(())
}
#[test]
pub(super) fn test_parse_scope_variables_child_refs_stable_across_pages()
-> Result<(), Box<dyn std::error::Error>> {
let lines = vec![
"@alpha = (1, 2)".to_string(),
"@beta = (3, 4)".to_string(),
"@gamma = (5, 6)".to_string(),
];
let (page_one, page_one_children) =
DebugAdapter::parse_scope_variables_from_lines(&lines, 11, 0, 1);
let (page_two, page_two_children) =
DebugAdapter::parse_scope_variables_from_lines(&lines, 11, 1, 1);
let first_ref = page_one
.first()
.map(|variable| variable.variables_reference)
.ok_or("expected first page variable")?;
let second_ref = page_two
.first()
.map(|variable| variable.variables_reference)
.ok_or("expected second page variable")?;
assert_ne!(first_ref, second_ref, "paged variables must not reuse child references");
assert!(
page_one_children.contains_key(&first_ref),
"expected first page child cache for first reference"
);
assert!(
page_two_children.contains_key(&second_ref),
"expected second page child cache for second reference"
);
Ok(())
}
#[test]
pub(super) fn test_capture_framed_debugger_output_isolated_by_marker()
-> Result<(), Box<dyn std::error::Error>> {
let adapter = DebugAdapter::new();
adapter.push_recent_output_line_for_test("noise");
adapter.push_recent_output_line_for_test(r#""DAP_BEGIN_100""#);
adapter.push_recent_output_line_for_test("$a = 1");
adapter.push_recent_output_line_for_test(r#""DAP_END_100""#);
adapter.push_recent_output_line_for_test(r#""DAP_BEGIN_200""#);
adapter.push_recent_output_line_for_test("$b = 2");
adapter.push_recent_output_line_for_test(r#""DAP_END_200""#);
let lines = adapter
.capture_framed_debugger_output("DAP_BEGIN_200", "DAP_END_200", 200)
.ok_or("expected framed output for marker 200")?;
assert_eq!(lines, vec!["$b = 2".to_string()]);
Ok(())
}
#[test]
pub(super) fn test_capture_framed_debugger_output_handles_partial_marker_arrival()
-> Result<(), Box<dyn std::error::Error>> {
let adapter = DebugAdapter::new();
let recent_output = adapter.recent_output.clone();
let producer = thread::spawn(move || {
thread::sleep(Duration::from_millis(DEBUGGER_FRAME_POLL_MS * 2));
let mut output = lock_or_recover(&recent_output, "test_partial_marker.recent_output");
DebugAdapter::append_recent_output_line_locked(&mut output, r#""DAP_BEGIN_300""#);
DebugAdapter::append_recent_output_line_locked(&mut output, "interleaved noise");
DebugAdapter::append_recent_output_line_locked(&mut output, "$captured = 42");
DebugAdapter::append_recent_output_line_locked(&mut output, r#""DAP_END_300""#);
});
let lines = adapter
.capture_framed_debugger_output("DAP_BEGIN_300", "DAP_END_300", 500)
.ok_or("expected framed output for delayed markers")?;
producer.join().map_err(|_| "producer thread panicked")?;
assert_eq!(lines, vec!["interleaved noise".to_string(), "$captured = 42".to_string()]);
Ok(())
}
#[test]
pub(super) fn test_capture_framed_debugger_output_respects_cancellation() {
let adapter = DebugAdapter::new();
adapter.cancel_requested.store(true, Ordering::Release);
let capture = adapter.capture_framed_debugger_output("DAP_BEGIN_400", "DAP_END_400", 200);
assert!(capture.is_none(), "capture should stop when request is cancelled");
assert!(
!adapter.cancel_requested.load(Ordering::Acquire),
"cancellation flag should be reset after capture returns"
);
}
#[test]
pub(super) fn test_capture_framed_debugger_output_timeout_without_end_marker() {
let adapter = DebugAdapter::new();
adapter.push_recent_output_line_for_test(r#""DAP_BEGIN_500""#);
adapter.push_recent_output_line_for_test("$value = 1");
let start = Instant::now();
let capture = adapter.capture_framed_debugger_output("DAP_BEGIN_500", "DAP_END_500", 1);
assert!(capture.is_none(), "capture should timeout without end marker");
assert!(
start.elapsed() >= Duration::from_millis(DEBUGGER_QUERY_WAIT_MS),
"timeout should honor minimum debugger query budget"
);
}
#[test]
pub(super) fn test_framed_capture_marker_scan_microbenchmark() {
let mut lines = Vec::with_capacity(RECENT_OUTPUT_MAX_LINES);
for idx in 0..(RECENT_OUTPUT_MAX_LINES - 4) {
let raw = format!("DB<1> noise line {idx}");
lines.push(RecentOutputLine {
id: idx as u64 + 1,
normalized: DebugAdapter::normalize_debugger_output_line(&raw),
raw,
});
}
let begin_id = RECENT_OUTPUT_MAX_LINES as u64 - 3;
lines.push(RecentOutputLine {
id: begin_id,
raw: r#""DAP_BEGIN_900""#.to_string(),
normalized: r#""DAP_BEGIN_900""#.to_string(),
});
lines.push(RecentOutputLine {
id: begin_id + 1,
raw: "$x = 1".to_string(),
normalized: "$x = 1".to_string(),
});
lines.push(RecentOutputLine {
id: begin_id + 2,
raw: "$y = 2".to_string(),
normalized: "$y = 2".to_string(),
});
lines.push(RecentOutputLine {
id: begin_id + 3,
raw: r#""DAP_END_900""#.to_string(),
normalized: r#""DAP_END_900""#.to_string(),
});
let iterations = 300;
let full_scan_start = Instant::now();
for _ in 0..iterations {
let normalized = lines
.iter()
.map(|line| DebugAdapter::normalize_debugger_output_line(&line.raw))
.collect::<Vec<_>>();
let _ = normalized.iter().rposition(|line| line.contains("DAP_BEGIN_900")).and_then(
|begin_idx| {
normalized[begin_idx + 1..]
.iter()
.position(|line| line.contains("DAP_END_900"))
.map(|end_rel| normalized[begin_idx + 1..begin_idx + 1 + end_rel].len())
},
);
}
let full_scan_elapsed = full_scan_start.elapsed();
let incremental_start = Instant::now();
for _ in 0..iterations {
let mut saw_begin = false;
for line in &lines {
if !saw_begin {
if DebugAdapter::line_contains_full_marker(&line.normalized, "DAP_BEGIN_900") {
saw_begin = true;
}
} else if DebugAdapter::line_contains_full_marker(&line.normalized, "DAP_END_900") {
break;
}
}
}
let incremental_elapsed = incremental_start.elapsed();
assert!(
incremental_elapsed < full_scan_elapsed,
"expected incremental scan ({incremental_elapsed:?}) to be faster than full scan ({full_scan_elapsed:?})"
);
}
#[test]
pub(super) fn test_stack_trace_uses_recent_output_when_available()
-> Result<(), Box<dyn std::error::Error>> {
let mut adapter = DebugAdapter::new();
adapter.push_recent_output_line_for_test("# 0 main::compute at /tmp/script.pl line 20");
adapter.push_recent_output_line_for_test("# 1 Foo::process called at /tmp/Foo.pm line 15");
let response = adapter.handle_request(1, "stackTrace", Some(json!({"threadId": 1})));
match response {
DapMessage::Response { success, body, .. } => {
assert!(success);
let body = body.ok_or("missing stackTrace body")?;
let frames = body
.get("stackFrames")
.and_then(|v| v.as_array())
.ok_or("missing stackFrames")?;
assert!(
frames.len() >= 2,
"expected parsed frames from recent output, got {}",
frames.len()
);
}
_ => return Err("expected stackTrace response".into()),
}
Ok(())
}
#[test]
pub(super) fn test_parse_evaluate_result_from_recent_output()
-> Result<(), Box<dyn std::error::Error>> {
let adapter = DebugAdapter::new();
adapter.push_recent_output_line_for_test("$result = 123");
let parsed = adapter.parse_evaluate_result_from_output("$result");
let (value, ty) = parsed.ok_or("expected parsed evaluate result")?;
assert_eq!(value, "123");
assert_eq!(ty, "SCALAR");
Ok(())
}
pub(super) fn make_test_frame(id: i32, name: &str, path: &str, line: i32) -> StackFrame {
StackFrame {
id,
name: name.to_string(),
source: Source {
name: Some(path.split('/').next_back().unwrap_or(path).to_string()),
path: path.to_string(),
source_reference: None,
},
line,
column: 1,
end_line: None,
end_column: None,
}
}
pub(super) fn filter_internal_frames(frames: Vec<StackFrame>) -> Vec<StackFrame> {
DebugAdapter::filter_user_visible_frames(frames)
}
#[test]
pub(super) fn test_stack_frame_filtering_removes_db_frames() {
let frames = vec![
make_test_frame(1, "main::hello", "/app/hello.pl", 10),
make_test_frame(2, "DB::DB", "/usr/share/perl/5.34/perl5db.pl", 100),
make_test_frame(3, "Foo::bar", "/app/lib/Foo.pm", 25),
];
let filtered = filter_internal_frames(frames);
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].name, "main::hello");
assert_eq!(filtered[1].name, "Foo::bar");
}
#[test]
pub(super) fn test_stack_frame_filtering_removes_shim_frames() {
let frames = vec![
make_test_frame(1, "Devel::TSPerlDAP::init", "/shim/TSPerlDAP.pm", 50),
make_test_frame(2, "main::run", "/app/script.pl", 5),
make_test_frame(3, "Devel::TSPerlDAP::handle_break", "/shim/TSPerlDAP.pm", 200),
make_test_frame(4, "Utils::process", "/app/lib/Utils.pm", 42),
];
let filtered = filter_internal_frames(frames);
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].name, "main::run");
assert_eq!(filtered[1].name, "Utils::process");
}
#[test]
pub(super) fn test_stack_frame_filtering_removes_perl5db_source() {
let frames = vec![
make_test_frame(1, "main::start", "/app/main.pl", 1),
make_test_frame(2, "some_internal", "/usr/lib/perl5/perl5db.pl", 999),
make_test_frame(3, "App::process", "/app/lib/App.pm", 100),
];
let filtered = filter_internal_frames(frames);
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].name, "main::start");
assert_eq!(filtered[1].name, "App::process");
}
#[test]
pub(super) fn test_stack_frame_filtering_mixed_internal_frames() {
let frames = vec![
make_test_frame(1, "main::hello", "/app/hello.pl", 10),
make_test_frame(2, "DB::sub", "/usr/share/perl/5.34/perl5db.pl", 2000),
make_test_frame(3, "Foo::bar", "/app/lib/Foo.pm", 25),
make_test_frame(4, "Devel::TSPerlDAP::step", "/shim/TSPerlDAP.pm", 150),
make_test_frame(5, "DB::breakpoint", "/some/other/path.pm", 50),
make_test_frame(6, "Baz::qux", "/app/lib/Baz.pm", 75),
make_test_frame(7, "custom_handler", "/usr/lib/perl5/perl5db.pl", 1500),
];
let filtered = filter_internal_frames(frames);
assert_eq!(filtered.len(), 3, "Expected 3 user frames, got {}", filtered.len());
assert_eq!(filtered[0].name, "main::hello");
assert_eq!(filtered[1].name, "Foo::bar");
assert_eq!(filtered[2].name, "Baz::qux");
}
#[test]
pub(super) fn test_stack_frame_filtering_preserves_order() {
let frames = vec![
make_test_frame(1, "A::first", "/a.pm", 1),
make_test_frame(2, "DB::internal", "/perl5db.pl", 100),
make_test_frame(3, "B::second", "/b.pm", 2),
make_test_frame(4, "Devel::TSPerlDAP::shim", "/shim.pm", 50),
make_test_frame(5, "C::third", "/c.pm", 3),
];
let filtered = filter_internal_frames(frames);
assert_eq!(filtered.len(), 3);
assert_eq!(filtered[0].name, "A::first");
assert_eq!(filtered[1].name, "B::second");
assert_eq!(filtered[2].name, "C::third");
}
#[test]
pub(super) fn test_stack_frame_filtering_all_internal() {
let frames = vec![
make_test_frame(1, "DB::main", "/perl5db.pl", 1),
make_test_frame(2, "Devel::TSPerlDAP::init", "/shim.pm", 10),
make_test_frame(3, "DB::sub", "/perl5db.pl", 50),
];
let filtered = filter_internal_frames(frames);
assert!(filtered.is_empty(), "Expected empty stack after filtering all internal frames");
}
#[test]
pub(super) fn test_stack_frame_filtering_no_internal() {
let frames = vec![
make_test_frame(1, "main::start", "/app/main.pl", 1),
make_test_frame(2, "Lib::helper", "/app/lib/Lib.pm", 50),
make_test_frame(3, "Utils::format", "/app/lib/Utils.pm", 100),
];
let filtered = filter_internal_frames(frames);
assert_eq!(filtered.len(), 3);
assert_eq!(filtered[0].name, "main::start");
assert_eq!(filtered[1].name, "Lib::helper");
assert_eq!(filtered[2].name, "Utils::format");
}
#[test]
pub(super) fn test_stack_frame_filtering_empty_input() {
let frames: Vec<StackFrame> = vec![];
let filtered = filter_internal_frames(frames);
assert!(filtered.is_empty());
}
#[test]
pub(super) fn test_parse_stack_trace_simple_call_chain() {
let output = r#"# 0 main::compute_sum at /app/script.pl line 20
# 1 Foo::process called at /app/lib/Foo.pm line 15
# 2 main::start at /app/script.pl line 5"#;
let frames = DebugAdapter::parse_stack_trace(output);
assert_eq!(frames.len(), 3);
assert_eq!(frames[0].id, 1);
assert_eq!(frames[0].name, "main::compute_sum");
assert_eq!(frames[0].source.path, "/app/script.pl");
assert_eq!(frames[0].line, 20);
assert_eq!(frames[0].source.name, Some("script.pl".to_string()));
assert_eq!(frames[1].id, 2);
assert_eq!(frames[1].name, "Foo::process");
assert_eq!(frames[1].source.path, "/app/lib/Foo.pm");
assert_eq!(frames[1].line, 15);
assert_eq!(frames[2].id, 3);
assert_eq!(frames[2].name, "main::start");
assert_eq!(frames[2].source.path, "/app/script.pl");
assert_eq!(frames[2].line, 5);
}
#[test]
pub(super) fn test_parse_stack_trace_multi_file_packages() {
let output = r#"# 0 Utils::Helper::validate at /app/lib/Utils/Helper.pm line 42
# 1 Data::Processor::transform called at /app/lib/Data/Processor.pm line 120
# 2 Controller::API::handle_request at /app/controller/API.pm line 78
# 3 main::dispatch called at /app/app.pl line 10"#;
let frames = DebugAdapter::parse_stack_trace(output);
assert_eq!(frames.len(), 4);
assert_eq!(frames[0].name, "Utils::Helper::validate");
assert_eq!(frames[1].name, "Data::Processor::transform");
assert_eq!(frames[2].name, "Controller::API::handle_request");
assert_eq!(frames[3].name, "main::dispatch");
assert!(frames[0].source.path.contains("Utils/Helper.pm"));
assert!(frames[1].source.path.contains("Data/Processor.pm"));
assert!(frames[2].source.path.contains("controller/API.pm"));
assert!(frames[3].source.path.contains("app.pl"));
}
#[test]
pub(super) fn test_parse_stack_trace_recursive_calls() {
let output = r#"# 0 main::factorial at /app/math.pl line 5
# 1 main::factorial called at /app/math.pl line 6
# 2 main::factorial called at /app/math.pl line 6
# 3 main::factorial called at /app/math.pl line 6
# 4 main::compute at /app/math.pl line 10"#;
let frames = DebugAdapter::parse_stack_trace(output);
assert_eq!(frames.len(), 5);
assert_eq!(frames[0].name, "main::factorial");
assert_eq!(frames[1].name, "main::factorial");
assert_eq!(frames[2].name, "main::factorial");
assert_eq!(frames[3].name, "main::factorial");
assert_eq!(frames[4].name, "main::compute");
assert_eq!(frames[0].id, 1);
assert_eq!(frames[1].id, 2);
assert_eq!(frames[2].id, 3);
assert_eq!(frames[3].id, 4);
assert_eq!(frames[4].id, 5);
}
#[test]
pub(super) fn test_parse_stack_trace_anonymous_subs() {
let output = r#"# 0 main::__ANON__ at /app/callback.pl line 15
# 1 Utils::map called at /app/lib/Utils.pm line 42
# 2 main::process_items at /app/callback.pl line 10"#;
let frames = DebugAdapter::parse_stack_trace(output);
assert_eq!(frames.len(), 3);
assert_eq!(frames[0].name, "main::__ANON__");
assert_eq!(frames[1].name, "Utils::map");
assert_eq!(frames[2].name, "main::process_items");
}
#[test]
pub(super) fn test_parse_stack_trace_windows_paths() {
let output = r#"# 0 main::test at C:\workspace\script.pl line 10
# 1 Foo::bar called at C:\workspace\lib\Foo.pm line 25"#;
let frames = DebugAdapter::parse_stack_trace(output);
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].source.path, r"C:\workspace\script.pl");
assert_eq!(frames[0].source.name, Some("script.pl".to_string()));
assert_eq!(frames[1].source.path, r"C:\workspace\lib\Foo.pm");
assert_eq!(frames[1].source.name, Some("Foo.pm".to_string()));
}
#[test]
pub(super) fn test_parse_stack_trace_with_space_in_paths() {
let output = r#"# 0 main::test at /tmp/My Project/script.pl line 10
# 1 Foo::bar called at C:\Work Files\lib\Foo.pm line 25"#;
let frames = DebugAdapter::parse_stack_trace(output);
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].source.path, "/tmp/My Project/script.pl");
assert_eq!(frames[0].source.name, Some("script.pl".to_string()));
assert_eq!(frames[1].source.path, r"C:\Work Files\lib\Foo.pm");
assert_eq!(frames[1].source.name, Some("Foo.pm".to_string()));
}
#[test]
pub(super) fn test_parse_stack_trace_empty_output() {
let output = "";
let frames = DebugAdapter::parse_stack_trace(output);
assert!(frames.is_empty());
}
#[test]
pub(super) fn test_parse_stack_trace_malformed_output() {
let output = r#"Random output that doesn't match
Some error message
DB<1>"#;
let frames = DebugAdapter::parse_stack_trace(output);
assert!(frames.is_empty());
}
#[test]
pub(super) fn test_parse_and_filter_stack_trace() {
let output = r#"# 0 main::user_func at /app/script.pl line 10
# 1 DB::DB called at /usr/share/perl/5.34/perl5db.pl line 100
# 2 Foo::process at /app/lib/Foo.pm line 25
# 3 Devel::TSPerlDAP::handle_break called at /shim/TSPerlDAP.pm line 50
# 4 main::start at /app/script.pl line 5"#;
let frames = DebugAdapter::parse_stack_trace(output);
assert_eq!(frames.len(), 5);
let filtered = filter_internal_frames(frames);
assert_eq!(filtered.len(), 3);
assert_eq!(filtered[0].name, "main::user_func");
assert_eq!(filtered[1].name, "Foo::process");
assert_eq!(filtered[2].name, "main::start");
}
#[test]
pub(super) fn test_normalize_strips_single_db_prompt() {
let result = DebugAdapter::normalize_debugger_output_line("DB<1> main::(/path/file.pl:5):");
assert_eq!(result, "main::(/path/file.pl:5):");
}
#[test]
pub(super) fn test_normalize_strips_multiple_db_prompts() {
let result = DebugAdapter::normalize_debugger_output_line(
" DB<1> DB<2> main::(/path/file.pl:5):",
);
assert_eq!(result, "main::(/path/file.pl:5):");
}
#[test]
pub(super) fn test_normalize_strips_high_prompt_number() {
let result = DebugAdapter::normalize_debugger_output_line("DB<100> $x = 42");
assert_eq!(result, "$x = 42");
}
#[test]
pub(super) fn test_normalize_no_prompt_passthrough() {
let result = DebugAdapter::normalize_debugger_output_line(" main::(/path/file.pl:5):");
assert_eq!(result, "main::(/path/file.pl:5):");
}
#[test]
pub(super) fn test_normalize_unclosed_prompt_passthrough() {
let result = DebugAdapter::normalize_debugger_output_line("DB<incomplete");
assert_eq!(result, "DB<incomplete");
}
#[test]
pub(super) fn test_normalize_three_prompts_in_sequence() {
let result = DebugAdapter::normalize_debugger_output_line("DB<1> DB<2> DB<3> my $x = 10;");
assert_eq!(result, "my $x = 10;");
}
}