1use std::collections::{HashMap, HashSet};
8
9mod code_mask;
10mod regex_support;
11
12use code_mask::code_byte_mask;
13use regex_support::{BRACED_PERL_VAR_RE, PERL_VAR_RE, SCALAR_VAR_RE, is_special_variable_name};
14
15use crate::protocol::InlineValueText;
16
17fn normalize_line_bounds(
22 start_line: i64,
23 end_line: i64,
24 line_count: usize,
25) -> Option<(usize, usize)> {
26 if line_count == 0 {
27 return None;
28 }
29
30 let start_1_based = start_line.max(1) as usize;
31 if start_1_based > line_count {
32 return None;
33 }
34 let end_1_based = (end_line.max(1) as usize).min(line_count);
35
36 let start_idx = start_1_based.saturating_sub(1);
37 let end_idx = end_1_based.saturating_sub(1);
38
39 (start_idx <= end_idx).then_some((start_idx, end_idx))
40}
41
42fn collect_line_variables(line: &str, include_non_scalars: bool) -> Vec<(usize, usize, String)> {
43 let mut matches = Vec::new();
44
45 let base_re = if include_non_scalars { PERL_VAR_RE.as_ref() } else { SCALAR_VAR_RE.as_ref() };
46 if let Some(re) = base_re {
47 for cap in re.captures_iter(line) {
48 if let Some(m) = cap.iter().flatten().next() {
49 matches.push((m.start(), m.end(), m.as_str().to_string()));
50 }
51 }
52 }
53
54 if let Some(re) = BRACED_PERL_VAR_RE.as_ref() {
55 for cap in re.captures_iter(line) {
56 let (Some(full_match), Some(sigil_match), Some(name_match)) =
57 (cap.iter().flatten().next(), cap.get(1), cap.get(2))
58 else {
59 continue;
60 };
61 if !include_non_scalars && sigil_match.as_str() != "$" {
62 continue;
63 }
64 matches.push((
65 full_match.start(),
66 full_match.end(),
67 format!("{}{}", sigil_match.as_str(), name_match.as_str()),
68 ));
69 }
70 }
71
72 matches.sort_by(|a, b| (a.0, a.1, &a.2).cmp(&(b.0, b.1, &b.2)));
73 matches.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1 && a.2 == b.2);
74 matches
75}
76
77pub fn extract_variable_names(source: &str, start_line: i64, end_line: i64) -> Vec<String> {
81 if PERL_VAR_RE.is_none() && BRACED_PERL_VAR_RE.is_none() {
82 return Vec::new();
83 }
84 let lines: Vec<&str> = source.lines().collect();
85 if lines.is_empty() {
86 return Vec::new();
87 }
88
89 let Some((start_idx, end_idx)) = normalize_line_bounds(start_line, end_line, lines.len())
90 else {
91 return Vec::new();
92 };
93
94 let mut seen = HashSet::new();
95 let mut names = Vec::new();
96
97 for line in lines.iter().skip(start_idx).take(end_idx - start_idx + 1) {
98 let code_mask = code_byte_mask(line);
99 for (start, end, name) in collect_line_variables(line, true) {
100 if !code_mask[start..end].iter().all(|is_code| *is_code) {
101 continue;
102 }
103 if !is_special_variable_name(&name) && seen.insert(name.clone()) {
104 names.push(name);
105 }
106 }
107 }
108
109 names
110}
111
112pub fn format_inline_value(name: &str, raw_value: &str) -> String {
119 let sigil = name.chars().next().unwrap_or('$');
120 match sigil {
121 '@' => {
122 let count = parse_array_element_count(raw_value);
123 format!("{name} = ({count} elements)")
124 }
125 '%' => {
126 let count = parse_hash_key_count(raw_value);
127 format!("{name} = ({count} keys)")
128 }
129 _ => {
130 let trimmed = raw_value.trim();
131 if trimmed.len() > 60 {
132 let preview: String = trimmed.chars().take(57).collect();
133 format!("{name} = {}...", preview)
134 } else {
135 format!("{name} = {trimmed}")
136 }
137 }
138 }
139}
140
141fn parse_array_element_count(raw: &str) -> &str {
143 let trimmed = raw.trim();
144 if trimmed.chars().all(|c| c.is_ascii_digit()) {
145 return trimmed;
146 }
147 "?"
148}
149
150fn parse_hash_key_count(raw: &str) -> &str {
152 let trimmed = raw.trim();
153 if trimmed.chars().all(|c| c.is_ascii_digit()) {
154 return trimmed;
155 }
156 "?"
157}
158
159pub fn collect_inline_values_with_runtime(
166 source: &str,
167 start_line: i64,
168 end_line: i64,
169 runtime_values: Option<&HashMap<String, String>>,
170) -> Vec<InlineValueText> {
171 if PERL_VAR_RE.is_none() && BRACED_PERL_VAR_RE.is_none() {
172 return Vec::new();
173 }
174 let lines: Vec<&str> = source.lines().collect();
175 if lines.is_empty() {
176 return Vec::new();
177 }
178
179 let Some((start_idx, end_idx)) = normalize_line_bounds(start_line, end_line, lines.len())
180 else {
181 return Vec::new();
182 };
183
184 let mut inline_values = Vec::new();
185 let mut seen_on_line: HashSet<(usize, String)> = HashSet::new();
186
187 for (idx, line) in lines.iter().enumerate().skip(start_idx).take(end_idx - start_idx + 1) {
188 let code_mask = code_byte_mask(line);
189 for (start, end, var_name) in collect_line_variables(line, true) {
190 if !code_mask[start..end].iter().all(|is_code| *is_code) {
191 continue;
192 }
193 if is_special_variable_name(&var_name) {
194 continue;
195 }
196 if !seen_on_line.insert((idx, var_name.clone())) {
197 continue;
198 }
199 let column = (start + 1) as i64;
200 let text = match runtime_values.and_then(|rv| rv.get(&var_name)) {
201 Some(rv) => format_inline_value(&var_name, rv),
202 None => format!("{} = ?", var_name),
203 };
204 inline_values.push(InlineValueText { line: (idx + 1) as i64, column, text });
205 }
206 }
207
208 inline_values
209}
210
211pub fn collect_inline_values(source: &str, start_line: i64, end_line: i64) -> Vec<InlineValueText> {
217 let lines: Vec<&str> = source.lines().collect();
218 if lines.is_empty() {
219 return Vec::new();
220 }
221
222 let Some((start_idx, end_idx)) = normalize_line_bounds(start_line, end_line, lines.len())
223 else {
224 return Vec::new();
225 };
226
227 if SCALAR_VAR_RE.is_none() && BRACED_PERL_VAR_RE.is_none() {
228 return Vec::new();
229 }
230 let mut inline_values = Vec::new();
231 let mut seen_on_line: HashSet<(usize, String)> = HashSet::new();
232
233 for (idx, line) in lines.iter().enumerate().skip(start_idx).take(end_idx - start_idx + 1) {
234 let code_mask = code_byte_mask(line);
235 for (start, end, var_name) in collect_line_variables(line, false) {
236 if !code_mask[start..end].iter().all(|is_code| *is_code) {
237 continue;
238 }
239 if is_special_variable_name(&var_name) {
240 continue;
241 }
242 if !seen_on_line.insert((idx, var_name.clone())) {
243 continue;
244 }
245 let column = (start + 1) as i64;
246 inline_values.push(InlineValueText {
247 line: (idx + 1) as i64,
248 column,
249 text: format!("{} = ?", var_name),
250 });
251 }
252 }
253
254 inline_values
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn test_inline_value_regexes_compile() {
263 assert!(PERL_VAR_RE.is_some());
264 assert!(BRACED_PERL_VAR_RE.is_some());
265 assert!(SCALAR_VAR_RE.is_some());
266 }
267
268 #[test]
269 fn test_collect_line_variables_keeps_code_scalars_visible() {
270 let line = "my $x = 1;";
271 let matches = collect_line_variables(line, false);
272 assert_eq!(matches, vec![(3, 5, "$x".to_string())]);
273
274 let code_mask = code_byte_mask(line);
275 assert!(code_mask[3..5].iter().all(|is_code| *is_code));
276 }
277
278 #[test]
279 fn test_collect_inline_values_legacy() {
280 let source = "my $x = 1;\nmy $y = $x + 2;";
281 let values = collect_inline_values(source, 1, 2);
282 assert!(values.iter().any(|v| v.text.contains("$x")));
283 assert!(values.iter().any(|v| v.text.contains("$y")));
284 }
285
286 #[test]
287 fn test_scalar_inline_value() {
288 let source = "my $name = \"Hello\";";
289 let mut rv = HashMap::new();
290 rv.insert("$name".to_string(), "'Hello'".to_string());
291 let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
292 assert_eq!(values.len(), 1);
293 assert_eq!(values[0].text, "$name = 'Hello'");
294 }
295
296 #[test]
297 fn test_array_inline_value() {
298 let source = "my @items = (1, 2, 3);";
299 let mut rv = HashMap::new();
300 rv.insert("@items".to_string(), "3".to_string());
301 let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
302 assert_eq!(values[0].text, "@items = (3 elements)");
303 }
304
305 #[test]
306 fn test_hash_inline_value() {
307 let source = "my %config = (a => 1);";
308 let mut rv = HashMap::new();
309 rv.insert("%config".to_string(), "5".to_string());
310 let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
311 assert_eq!(values[0].text, "%config = (5 keys)");
312 }
313
314 #[test]
315 fn test_blessed_ref_inline_value() {
316 let source = "my $obj = Foo->new();";
317 let mut rv = HashMap::new();
318 rv.insert("$obj".to_string(), "Foo=HASH(0xdeadbeef)".to_string());
319 let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
320 assert_eq!(values[0].text, "$obj = Foo=HASH(0xdeadbeef)");
321 }
322
323 #[test]
324 fn test_empty_collections() {
325 let mut rv = HashMap::new();
326 rv.insert("@empty".to_string(), "0".to_string());
327 rv.insert("%none".to_string(), "0".to_string());
328 let source = "my @empty; my %none;";
329 let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
330 assert!(values.iter().any(|v| v.text == "@empty = (0 elements)"));
331 assert!(values.iter().any(|v| v.text == "%none = (0 keys)"));
332 }
333
334 #[test]
335 fn test_deduplication_per_line() {
336 let source = "$x = $x + $x;";
337 let values = collect_inline_values_with_runtime(source, 1, 2, None);
338 assert_eq!(values.len(), 1);
339 }
340
341 #[test]
342 fn test_special_vars_excluded() {
343 let source = "print $_; warn $!; my $val = 1;";
344 let values = collect_inline_values_with_runtime(source, 1, 1, None);
345 assert_eq!(values.len(), 1);
346 assert!(values[0].text.contains("$val"));
347 }
348
349 #[test]
350 fn test_extract_variable_names() {
351 let source = "my $x = 1;\nmy @arr = (1,2,3);\nmy %h = (a => 1);";
352 let names = extract_variable_names(source, 1, 3);
353 assert!(names.contains(&"$x".to_string()));
354 assert!(names.contains(&"@arr".to_string()));
355 assert!(names.contains(&"%h".to_string()));
356 }
357
358 #[test]
359 fn test_extract_variable_names_with_namespace_qualifiers() {
360 let source = "our $Foo::bar = 1;\nour @My::Pkg::items = (1);\nour %App::Config::opts = ();";
361 let names = extract_variable_names(source, 1, 3);
362 assert!(names.contains(&"$Foo::bar".to_string()));
363 assert!(names.contains(&"@My::Pkg::items".to_string()));
364 assert!(names.contains(&"%App::Config::opts".to_string()));
365 }
366
367 #[test]
368 fn test_extract_variable_names_with_legacy_namespace_qualifiers() {
369 let source = "our $Foo'bar = 1;\nour @My'Pkg'items = (1);\nour %App'Config'opts = ();";
370 let names = extract_variable_names(source, 1, 3);
371 assert!(names.contains(&"$Foo'bar".to_string()));
372 assert!(names.contains(&"@My'Pkg'items".to_string()));
373 assert!(names.contains(&"%App'Config'opts".to_string()));
374 }
375
376 #[test]
377 fn test_no_runtime_fallback() {
378 let source = "my $x = 1;";
379 let values = collect_inline_values_with_runtime(source, 1, 1, None);
380 assert_eq!(values.len(), 1);
381 assert_eq!(values[0].text, "$x = ?");
382 }
383
384 #[test]
385 fn test_scalar_truncation_uses_char_boundaries() {
386 let source = "my $name = 1;";
387 let long_value = "é".repeat(80);
388 let mut rv = HashMap::new();
389 rv.insert("$name".to_string(), long_value);
390
391 let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
392 assert_eq!(values.len(), 1);
393 assert!(values[0].text.starts_with("$name = "));
394 assert!(values[0].text.ends_with("..."));
395 }
396
397 #[test]
398 fn test_non_positive_line_bounds_are_clamped() {
399 let source = "my $x = 1;\nmy $y = 2;";
400 let names = extract_variable_names(source, 0, 1);
401 assert_eq!(names, vec!["$x".to_string()]);
402
403 let values = collect_inline_values_with_runtime(source, 0, 0, None);
404 assert_eq!(values.len(), 1);
405 assert_eq!(values[0].text, "$x = ?");
406 }
407
408 #[test]
409 fn test_inverted_line_bounds_return_empty() {
410 let source = "my $x = 1;\nmy $y = 2;";
411 assert!(extract_variable_names(source, 2, 1).is_empty());
412 assert!(collect_inline_values_with_runtime(source, 2, 1, None).is_empty());
413 assert!(collect_inline_values(source, 2, 1).is_empty());
414 }
415
416 #[test]
417 fn test_out_of_range_line_bounds_return_empty() {
418 let source = "my $x = 1;\nmy $y = 2;";
419 assert!(extract_variable_names(source, 3, 3).is_empty());
420 assert!(collect_inline_values_with_runtime(source, 3, 3, None).is_empty());
421 assert!(collect_inline_values(source, 3, 3).is_empty());
422 }
423
424 #[test]
425 fn test_end_line_past_file_is_clamped() {
426 let source = "my $x = 1;\nmy $y = 2;";
427
428 let names = extract_variable_names(source, 2, 999);
429 assert_eq!(names, vec!["$y".to_string()]);
430
431 let values = collect_inline_values_with_runtime(source, 2, 999, None);
432 assert_eq!(values.len(), 1);
433 assert_eq!(values[0].text, "$y = ?");
434
435 let legacy_values = collect_inline_values(source, 2, 999);
436 assert_eq!(legacy_values.len(), 1);
437 assert_eq!(legacy_values[0].text, "$y = ?");
438 }
439
440 #[test]
441 fn test_runtime_inline_value_for_namespaced_scalar() {
442 let source = "our $Foo::bar = 1;";
443 let mut rv = HashMap::new();
444 rv.insert("$Foo::bar".to_string(), "42".to_string());
445
446 let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
447 assert_eq!(values.len(), 1);
448 assert_eq!(values[0].text, "$Foo::bar = 42");
449 }
450
451 #[test]
452 fn test_runtime_inline_value_for_legacy_namespaced_scalar() {
453 let source = "our $Foo'bar = 1;";
454 let mut rv = HashMap::new();
455 rv.insert("$Foo'bar".to_string(), "42".to_string());
456
457 let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
458 assert_eq!(values.len(), 1);
459 assert_eq!(values[0].text, "$Foo'bar = 42");
460 }
461
462 #[test]
463 fn test_runtime_inline_value_for_main_namespaced_scalar() {
464 let source = "our $::bar = 1;";
465 let mut rv = HashMap::new();
466 rv.insert("$::bar".to_string(), "42".to_string());
467
468 let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
469 assert_eq!(values.len(), 1);
470 assert_eq!(values[0].text, "$::bar = 42");
471 }
472
473 #[test]
474 fn test_variable_like_tokens_in_strings_and_comments_are_ignored() {
475 let source = "my $real = 1; my $msg = \"$fake\"; # $commented";
476 let names = extract_variable_names(source, 1, 1);
477 assert!(names.contains(&"$real".to_string()));
478 assert!(names.contains(&"$msg".to_string()));
479 assert!(!names.contains(&"$fake".to_string()));
480 assert!(!names.contains(&"$commented".to_string()));
481 }
482
483 #[test]
484 fn test_inline_values_ignore_strings_and_comments() {
485 let source = "my $real = 1; print \"$ignored\"; # and $commented";
486 let values = collect_inline_values_with_runtime(source, 1, 1, None);
487 assert_eq!(values.len(), 1);
488 assert_eq!(values[0].text, "$real = ?");
489 }
490
491 #[test]
492 fn test_array_length_marker_does_not_start_comment() {
493 let source = "my $len = $#arr; my $next = 1;";
494
495 let names = extract_variable_names(source, 1, 1);
496 assert!(names.contains(&"$len".to_string()));
497 assert!(names.contains(&"$next".to_string()));
498
499 let values = collect_inline_values_with_runtime(source, 1, 1, None);
500 assert!(values.iter().any(|v| v.text == "$len = ?"));
501 assert!(values.iter().any(|v| v.text == "$next = ?"));
502 }
503
504 #[test]
505 fn test_braced_array_length_marker_does_not_start_comment() {
506 let source = "my $len = ${#arr}; my $next = 1;";
507
508 let names = extract_variable_names(source, 1, 1);
509 assert!(names.contains(&"$len".to_string()));
510 assert!(names.contains(&"$next".to_string()));
511 }
512
513 #[test]
514 fn test_variable_like_tokens_in_quote_like_operators_are_ignored() {
515 let source = "my $real = 1; my $str = qq{$fake}; my $lit = q[$ghost];";
516 let names = extract_variable_names(source, 1, 1);
517 assert!(names.contains(&"$real".to_string()));
518 assert!(names.contains(&"$str".to_string()));
519 assert!(names.contains(&"$lit".to_string()));
520 assert!(!names.contains(&"$fake".to_string()));
521 assert!(!names.contains(&"$ghost".to_string()));
522 }
523
524 #[test]
525 fn test_inline_values_ignore_quote_like_operators() {
526 let source = "my $real = 1; my $str = qq($fake); my $lit = q/$ghost/;";
527 let values = collect_inline_values_with_runtime(source, 1, 1, None);
528 assert_eq!(values.len(), 3);
529 assert!(values.iter().any(|v| v.text == "$real = ?"));
530 assert!(values.iter().any(|v| v.text == "$str = ?"));
531 assert!(values.iter().any(|v| v.text == "$lit = ?"));
532 assert!(!values.iter().any(|v| v.text.contains("$fake")));
533 assert!(!values.iter().any(|v| v.text.contains("$ghost")));
534 }
535
536 #[test]
537 fn test_unclosed_quote_like_operator_masks_to_end_of_line() {
538 let source = "my $real = 1; my $str = qq($fake";
539 let values = collect_inline_values_with_runtime(source, 1, 1, None);
540 assert_eq!(values.len(), 2);
541 assert!(values.iter().any(|v| v.text == "$real = ?"));
542 assert!(values.iter().any(|v| v.text == "$str = ?"));
543 assert!(!values.iter().any(|v| v.text.contains("$fake")));
544 }
545
546 #[test]
547 fn test_regex_and_translation_operators_are_ignored() {
548 let source =
549 "my $real = 1; $text =~ m/$ghost/; $text =~ s/$from/$to/; $text =~ tr/$src/$dst/;";
550 let names = extract_variable_names(source, 1, 1);
551 assert!(names.contains(&"$real".to_string()));
552 assert!(!names.contains(&"$ghost".to_string()));
553 assert!(!names.contains(&"$from".to_string()));
554 assert!(!names.contains(&"$to".to_string()));
555 assert!(!names.contains(&"$src".to_string()));
556 assert!(!names.contains(&"$dst".to_string()));
557 }
558
559 #[test]
560 fn test_quote_like_parser_requires_identifier_boundary() {
561 let source = "my $keep = 1; my $weird = qqx$also_keep;";
562 let names = extract_variable_names(source, 1, 1);
563 assert!(names.contains(&"$keep".to_string()));
564 assert!(names.contains(&"$weird".to_string()));
565 assert!(names.contains(&"$also_keep".to_string()));
566 }
567
568 #[test]
569 fn test_extract_variable_names_deduplicates_repeated_mentions() {
570 let source = "my $foo = ${foo};\n";
571 let names = extract_variable_names(source, 1, 1);
572
573 assert_eq!(names, vec!["$foo".to_string()]);
574 }
575
576 #[test]
577 fn test_extract_variable_names_supports_braced_variables() {
578 let source = "my ${scalar_name} = 1;\nmy @{Pkg::items};\nmy %{App::Config::opts};";
579 let names = extract_variable_names(source, 1, 3);
580 assert!(names.contains(&"$scalar_name".to_string()));
581 assert!(names.contains(&"@Pkg::items".to_string()));
582 assert!(names.contains(&"%App::Config::opts".to_string()));
583 }
584
585 #[test]
586 fn test_inline_values_support_braced_scalars_with_runtime_values() {
587 let source = "my ${scalar_name} = 1;";
588 let mut rv = HashMap::new();
589 rv.insert("$scalar_name".to_string(), "7".to_string());
590 let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
591 assert_eq!(values.len(), 1);
592 assert_eq!(values[0].text, "$scalar_name = 7");
593 }
594
595 #[test]
596 fn test_legacy_inline_values_support_braced_scalars() {
597 let source = "my ${scalar_name} = 1;";
598 let values = collect_inline_values(source, 1, 1);
599 assert_eq!(values.len(), 1);
600 assert_eq!(values[0].text, "$scalar_name = ?");
601 }
602
603 #[test]
604 fn test_legacy_inline_values_deduplicate_per_line() {
605 let source = "$x = $x + $x;";
606 let values = collect_inline_values(source, 1, 1);
607 assert_eq!(values.len(), 1);
608 assert_eq!(values[0].text, "$x = ?");
609 }
610
611 #[test]
612 fn test_legacy_inline_values_exclude_special_variables() {
613 let source = "print $_; warn $!; my $ok = 1;";
614 let values = collect_inline_values(source, 1, 1);
615 assert_eq!(values.len(), 1);
616 assert_eq!(values[0].text, "$ok = ?");
617 }
618
619 #[test]
620 fn test_inline_values_ignore_regex_match_operator_body() {
621 let source = r"my $target = 1; if ($line =~ m/(\$regex_capture)/) { my $ok = 1; }";
622 let values = collect_inline_values_with_runtime(source, 1, 1, None);
623 assert!(values.iter().any(|v| v.text == "$target = ?"));
624 assert!(values.iter().any(|v| v.text == "$line = ?"));
625 assert!(values.iter().any(|v| v.text == "$ok = ?"));
626 assert!(!values.iter().any(|v| v.text.contains("$regex_capture")));
627 }
628
629 #[test]
630 fn test_inline_values_ignore_substitution_and_transliteration_bodies() {
631 let source = "my $line = 1; $line =~ s/$find/$replace/g; $line =~ tr/$from/$to/;";
632 let values = collect_inline_values_with_runtime(source, 1, 1, None);
633 assert!(values.iter().any(|v| v.text == "$line = ?"));
634 assert!(!values.iter().any(|v| v.text.contains("$find")));
635 assert!(!values.iter().any(|v| v.text.contains("$replace")));
636 assert!(!values.iter().any(|v| v.text.contains("$from")));
637 assert!(!values.iter().any(|v| v.text.contains("$to")));
638 }
639}