use std::collections::{HashMap, HashSet};
mod code_mask;
mod regex_support;
use code_mask::code_byte_mask;
use regex_support::{BRACED_PERL_VAR_RE, PERL_VAR_RE, SCALAR_VAR_RE, is_special_variable_name};
use crate::protocol::InlineValueText;
fn normalize_line_bounds(
start_line: i64,
end_line: i64,
line_count: usize,
) -> Option<(usize, usize)> {
if line_count == 0 {
return None;
}
let start_1_based = start_line.max(1) as usize;
if start_1_based > line_count {
return None;
}
let end_1_based = (end_line.max(1) as usize).min(line_count);
let start_idx = start_1_based.saturating_sub(1);
let end_idx = end_1_based.saturating_sub(1);
(start_idx <= end_idx).then_some((start_idx, end_idx))
}
fn collect_line_variables(line: &str, include_non_scalars: bool) -> Vec<(usize, usize, String)> {
let mut matches = Vec::new();
let base_re = if include_non_scalars { PERL_VAR_RE.as_ref() } else { SCALAR_VAR_RE.as_ref() };
if let Some(re) = base_re {
for cap in re.captures_iter(line) {
if let Some(m) = cap.iter().flatten().next() {
matches.push((m.start(), m.end(), m.as_str().to_string()));
}
}
}
if let Some(re) = BRACED_PERL_VAR_RE.as_ref() {
for cap in re.captures_iter(line) {
let (Some(full_match), Some(sigil_match), Some(name_match)) =
(cap.iter().flatten().next(), cap.get(1), cap.get(2))
else {
continue;
};
if !include_non_scalars && sigil_match.as_str() != "$" {
continue;
}
matches.push((
full_match.start(),
full_match.end(),
format!("{}{}", sigil_match.as_str(), name_match.as_str()),
));
}
}
matches.sort_by(|a, b| (a.0, a.1, &a.2).cmp(&(b.0, b.1, &b.2)));
matches.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1 && a.2 == b.2);
matches
}
pub fn extract_variable_names(source: &str, start_line: i64, end_line: i64) -> Vec<String> {
if PERL_VAR_RE.is_none() && BRACED_PERL_VAR_RE.is_none() {
return Vec::new();
}
let lines: Vec<&str> = source.lines().collect();
if lines.is_empty() {
return Vec::new();
}
let Some((start_idx, end_idx)) = normalize_line_bounds(start_line, end_line, lines.len())
else {
return Vec::new();
};
let mut seen = HashSet::new();
let mut names = Vec::new();
for line in lines.iter().skip(start_idx).take(end_idx - start_idx + 1) {
let code_mask = code_byte_mask(line);
for (start, end, name) in collect_line_variables(line, true) {
if !code_mask[start..end].iter().all(|is_code| *is_code) {
continue;
}
if !is_special_variable_name(&name) && seen.insert(name.clone()) {
names.push(name);
}
}
}
names
}
pub fn format_inline_value(name: &str, raw_value: &str) -> String {
let sigil = name.chars().next().unwrap_or('$');
match sigil {
'@' => {
let count = parse_array_element_count(raw_value);
format!("{name} = ({count} elements)")
}
'%' => {
let count = parse_hash_key_count(raw_value);
format!("{name} = ({count} keys)")
}
_ => {
let trimmed = raw_value.trim();
if trimmed.len() > 60 {
let preview: String = trimmed.chars().take(57).collect();
format!("{name} = {}...", preview)
} else {
format!("{name} = {trimmed}")
}
}
}
}
fn parse_array_element_count(raw: &str) -> &str {
let trimmed = raw.trim();
if trimmed.chars().all(|c| c.is_ascii_digit()) {
return trimmed;
}
"?"
}
fn parse_hash_key_count(raw: &str) -> &str {
let trimmed = raw.trim();
if trimmed.chars().all(|c| c.is_ascii_digit()) {
return trimmed;
}
"?"
}
pub fn collect_inline_values_with_runtime(
source: &str,
start_line: i64,
end_line: i64,
runtime_values: Option<&HashMap<String, String>>,
) -> Vec<InlineValueText> {
if PERL_VAR_RE.is_none() && BRACED_PERL_VAR_RE.is_none() {
return Vec::new();
}
let lines: Vec<&str> = source.lines().collect();
if lines.is_empty() {
return Vec::new();
}
let Some((start_idx, end_idx)) = normalize_line_bounds(start_line, end_line, lines.len())
else {
return Vec::new();
};
let mut inline_values = Vec::new();
let mut seen_on_line: HashSet<(usize, String)> = HashSet::new();
for (idx, line) in lines.iter().enumerate().skip(start_idx).take(end_idx - start_idx + 1) {
let code_mask = code_byte_mask(line);
for (start, end, var_name) in collect_line_variables(line, true) {
if !code_mask[start..end].iter().all(|is_code| *is_code) {
continue;
}
if is_special_variable_name(&var_name) {
continue;
}
if !seen_on_line.insert((idx, var_name.clone())) {
continue;
}
let column = (start + 1) as i64;
let text = match runtime_values.and_then(|rv| rv.get(&var_name)) {
Some(rv) => format_inline_value(&var_name, rv),
None => format!("{} = ?", var_name),
};
inline_values.push(InlineValueText { line: (idx + 1) as i64, column, text });
}
}
inline_values
}
pub fn collect_inline_values(source: &str, start_line: i64, end_line: i64) -> Vec<InlineValueText> {
let lines: Vec<&str> = source.lines().collect();
if lines.is_empty() {
return Vec::new();
}
let Some((start_idx, end_idx)) = normalize_line_bounds(start_line, end_line, lines.len())
else {
return Vec::new();
};
if SCALAR_VAR_RE.is_none() && BRACED_PERL_VAR_RE.is_none() {
return Vec::new();
}
let mut inline_values = Vec::new();
let mut seen_on_line: HashSet<(usize, String)> = HashSet::new();
for (idx, line) in lines.iter().enumerate().skip(start_idx).take(end_idx - start_idx + 1) {
let code_mask = code_byte_mask(line);
for (start, end, var_name) in collect_line_variables(line, false) {
if !code_mask[start..end].iter().all(|is_code| *is_code) {
continue;
}
if is_special_variable_name(&var_name) {
continue;
}
if !seen_on_line.insert((idx, var_name.clone())) {
continue;
}
let column = (start + 1) as i64;
inline_values.push(InlineValueText {
line: (idx + 1) as i64,
column,
text: format!("{} = ?", var_name),
});
}
}
inline_values
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inline_value_regexes_compile() {
assert!(PERL_VAR_RE.is_some());
assert!(BRACED_PERL_VAR_RE.is_some());
assert!(SCALAR_VAR_RE.is_some());
}
#[test]
fn test_collect_line_variables_keeps_code_scalars_visible() {
let line = "my $x = 1;";
let matches = collect_line_variables(line, false);
assert_eq!(matches, vec![(3, 5, "$x".to_string())]);
let code_mask = code_byte_mask(line);
assert!(code_mask[3..5].iter().all(|is_code| *is_code));
}
#[test]
fn test_collect_inline_values_legacy() {
let source = "my $x = 1;\nmy $y = $x + 2;";
let values = collect_inline_values(source, 1, 2);
assert!(values.iter().any(|v| v.text.contains("$x")));
assert!(values.iter().any(|v| v.text.contains("$y")));
}
#[test]
fn test_scalar_inline_value() {
let source = "my $name = \"Hello\";";
let mut rv = HashMap::new();
rv.insert("$name".to_string(), "'Hello'".to_string());
let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
assert_eq!(values.len(), 1);
assert_eq!(values[0].text, "$name = 'Hello'");
}
#[test]
fn test_array_inline_value() {
let source = "my @items = (1, 2, 3);";
let mut rv = HashMap::new();
rv.insert("@items".to_string(), "3".to_string());
let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
assert_eq!(values[0].text, "@items = (3 elements)");
}
#[test]
fn test_hash_inline_value() {
let source = "my %config = (a => 1);";
let mut rv = HashMap::new();
rv.insert("%config".to_string(), "5".to_string());
let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
assert_eq!(values[0].text, "%config = (5 keys)");
}
#[test]
fn test_blessed_ref_inline_value() {
let source = "my $obj = Foo->new();";
let mut rv = HashMap::new();
rv.insert("$obj".to_string(), "Foo=HASH(0xdeadbeef)".to_string());
let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
assert_eq!(values[0].text, "$obj = Foo=HASH(0xdeadbeef)");
}
#[test]
fn test_empty_collections() {
let mut rv = HashMap::new();
rv.insert("@empty".to_string(), "0".to_string());
rv.insert("%none".to_string(), "0".to_string());
let source = "my @empty; my %none;";
let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
assert!(values.iter().any(|v| v.text == "@empty = (0 elements)"));
assert!(values.iter().any(|v| v.text == "%none = (0 keys)"));
}
#[test]
fn test_deduplication_per_line() {
let source = "$x = $x + $x;";
let values = collect_inline_values_with_runtime(source, 1, 2, None);
assert_eq!(values.len(), 1);
}
#[test]
fn test_special_vars_excluded() {
let source = "print $_; warn $!; my $val = 1;";
let values = collect_inline_values_with_runtime(source, 1, 1, None);
assert_eq!(values.len(), 1);
assert!(values[0].text.contains("$val"));
}
#[test]
fn test_extract_variable_names() {
let source = "my $x = 1;\nmy @arr = (1,2,3);\nmy %h = (a => 1);";
let names = extract_variable_names(source, 1, 3);
assert!(names.contains(&"$x".to_string()));
assert!(names.contains(&"@arr".to_string()));
assert!(names.contains(&"%h".to_string()));
}
#[test]
fn test_extract_variable_names_with_namespace_qualifiers() {
let source = "our $Foo::bar = 1;\nour @My::Pkg::items = (1);\nour %App::Config::opts = ();";
let names = extract_variable_names(source, 1, 3);
assert!(names.contains(&"$Foo::bar".to_string()));
assert!(names.contains(&"@My::Pkg::items".to_string()));
assert!(names.contains(&"%App::Config::opts".to_string()));
}
#[test]
fn test_extract_variable_names_with_legacy_namespace_qualifiers() {
let source = "our $Foo'bar = 1;\nour @My'Pkg'items = (1);\nour %App'Config'opts = ();";
let names = extract_variable_names(source, 1, 3);
assert!(names.contains(&"$Foo'bar".to_string()));
assert!(names.contains(&"@My'Pkg'items".to_string()));
assert!(names.contains(&"%App'Config'opts".to_string()));
}
#[test]
fn test_no_runtime_fallback() {
let source = "my $x = 1;";
let values = collect_inline_values_with_runtime(source, 1, 1, None);
assert_eq!(values.len(), 1);
assert_eq!(values[0].text, "$x = ?");
}
#[test]
fn test_scalar_truncation_uses_char_boundaries() {
let source = "my $name = 1;";
let long_value = "é".repeat(80);
let mut rv = HashMap::new();
rv.insert("$name".to_string(), long_value);
let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
assert_eq!(values.len(), 1);
assert!(values[0].text.starts_with("$name = "));
assert!(values[0].text.ends_with("..."));
}
#[test]
fn test_non_positive_line_bounds_are_clamped() {
let source = "my $x = 1;\nmy $y = 2;";
let names = extract_variable_names(source, 0, 1);
assert_eq!(names, vec!["$x".to_string()]);
let values = collect_inline_values_with_runtime(source, 0, 0, None);
assert_eq!(values.len(), 1);
assert_eq!(values[0].text, "$x = ?");
}
#[test]
fn test_inverted_line_bounds_return_empty() {
let source = "my $x = 1;\nmy $y = 2;";
assert!(extract_variable_names(source, 2, 1).is_empty());
assert!(collect_inline_values_with_runtime(source, 2, 1, None).is_empty());
assert!(collect_inline_values(source, 2, 1).is_empty());
}
#[test]
fn test_out_of_range_line_bounds_return_empty() {
let source = "my $x = 1;\nmy $y = 2;";
assert!(extract_variable_names(source, 3, 3).is_empty());
assert!(collect_inline_values_with_runtime(source, 3, 3, None).is_empty());
assert!(collect_inline_values(source, 3, 3).is_empty());
}
#[test]
fn test_end_line_past_file_is_clamped() {
let source = "my $x = 1;\nmy $y = 2;";
let names = extract_variable_names(source, 2, 999);
assert_eq!(names, vec!["$y".to_string()]);
let values = collect_inline_values_with_runtime(source, 2, 999, None);
assert_eq!(values.len(), 1);
assert_eq!(values[0].text, "$y = ?");
let legacy_values = collect_inline_values(source, 2, 999);
assert_eq!(legacy_values.len(), 1);
assert_eq!(legacy_values[0].text, "$y = ?");
}
#[test]
fn test_runtime_inline_value_for_namespaced_scalar() {
let source = "our $Foo::bar = 1;";
let mut rv = HashMap::new();
rv.insert("$Foo::bar".to_string(), "42".to_string());
let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
assert_eq!(values.len(), 1);
assert_eq!(values[0].text, "$Foo::bar = 42");
}
#[test]
fn test_runtime_inline_value_for_legacy_namespaced_scalar() {
let source = "our $Foo'bar = 1;";
let mut rv = HashMap::new();
rv.insert("$Foo'bar".to_string(), "42".to_string());
let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
assert_eq!(values.len(), 1);
assert_eq!(values[0].text, "$Foo'bar = 42");
}
#[test]
fn test_runtime_inline_value_for_main_namespaced_scalar() {
let source = "our $::bar = 1;";
let mut rv = HashMap::new();
rv.insert("$::bar".to_string(), "42".to_string());
let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
assert_eq!(values.len(), 1);
assert_eq!(values[0].text, "$::bar = 42");
}
#[test]
fn test_variable_like_tokens_in_strings_and_comments_are_ignored() {
let source = "my $real = 1; my $msg = \"$fake\"; # $commented";
let names = extract_variable_names(source, 1, 1);
assert!(names.contains(&"$real".to_string()));
assert!(names.contains(&"$msg".to_string()));
assert!(!names.contains(&"$fake".to_string()));
assert!(!names.contains(&"$commented".to_string()));
}
#[test]
fn test_inline_values_ignore_strings_and_comments() {
let source = "my $real = 1; print \"$ignored\"; # and $commented";
let values = collect_inline_values_with_runtime(source, 1, 1, None);
assert_eq!(values.len(), 1);
assert_eq!(values[0].text, "$real = ?");
}
#[test]
fn test_array_length_marker_does_not_start_comment() {
let source = "my $len = $#arr; my $next = 1;";
let names = extract_variable_names(source, 1, 1);
assert!(names.contains(&"$len".to_string()));
assert!(names.contains(&"$next".to_string()));
let values = collect_inline_values_with_runtime(source, 1, 1, None);
assert!(values.iter().any(|v| v.text == "$len = ?"));
assert!(values.iter().any(|v| v.text == "$next = ?"));
}
#[test]
fn test_braced_array_length_marker_does_not_start_comment() {
let source = "my $len = ${#arr}; my $next = 1;";
let names = extract_variable_names(source, 1, 1);
assert!(names.contains(&"$len".to_string()));
assert!(names.contains(&"$next".to_string()));
}
#[test]
fn test_variable_like_tokens_in_quote_like_operators_are_ignored() {
let source = "my $real = 1; my $str = qq{$fake}; my $lit = q[$ghost];";
let names = extract_variable_names(source, 1, 1);
assert!(names.contains(&"$real".to_string()));
assert!(names.contains(&"$str".to_string()));
assert!(names.contains(&"$lit".to_string()));
assert!(!names.contains(&"$fake".to_string()));
assert!(!names.contains(&"$ghost".to_string()));
}
#[test]
fn test_inline_values_ignore_quote_like_operators() {
let source = "my $real = 1; my $str = qq($fake); my $lit = q/$ghost/;";
let values = collect_inline_values_with_runtime(source, 1, 1, None);
assert_eq!(values.len(), 3);
assert!(values.iter().any(|v| v.text == "$real = ?"));
assert!(values.iter().any(|v| v.text == "$str = ?"));
assert!(values.iter().any(|v| v.text == "$lit = ?"));
assert!(!values.iter().any(|v| v.text.contains("$fake")));
assert!(!values.iter().any(|v| v.text.contains("$ghost")));
}
#[test]
fn test_unclosed_quote_like_operator_masks_to_end_of_line() {
let source = "my $real = 1; my $str = qq($fake";
let values = collect_inline_values_with_runtime(source, 1, 1, None);
assert_eq!(values.len(), 2);
assert!(values.iter().any(|v| v.text == "$real = ?"));
assert!(values.iter().any(|v| v.text == "$str = ?"));
assert!(!values.iter().any(|v| v.text.contains("$fake")));
}
#[test]
fn test_regex_and_translation_operators_are_ignored() {
let source =
"my $real = 1; $text =~ m/$ghost/; $text =~ s/$from/$to/; $text =~ tr/$src/$dst/;";
let names = extract_variable_names(source, 1, 1);
assert!(names.contains(&"$real".to_string()));
assert!(!names.contains(&"$ghost".to_string()));
assert!(!names.contains(&"$from".to_string()));
assert!(!names.contains(&"$to".to_string()));
assert!(!names.contains(&"$src".to_string()));
assert!(!names.contains(&"$dst".to_string()));
}
#[test]
fn test_quote_like_parser_requires_identifier_boundary() {
let source = "my $keep = 1; my $weird = qqx$also_keep;";
let names = extract_variable_names(source, 1, 1);
assert!(names.contains(&"$keep".to_string()));
assert!(names.contains(&"$weird".to_string()));
assert!(names.contains(&"$also_keep".to_string()));
}
#[test]
fn test_extract_variable_names_deduplicates_repeated_mentions() {
let source = "my $foo = ${foo};\n";
let names = extract_variable_names(source, 1, 1);
assert_eq!(names, vec!["$foo".to_string()]);
}
#[test]
fn test_extract_variable_names_supports_braced_variables() {
let source = "my ${scalar_name} = 1;\nmy @{Pkg::items};\nmy %{App::Config::opts};";
let names = extract_variable_names(source, 1, 3);
assert!(names.contains(&"$scalar_name".to_string()));
assert!(names.contains(&"@Pkg::items".to_string()));
assert!(names.contains(&"%App::Config::opts".to_string()));
}
#[test]
fn test_inline_values_support_braced_scalars_with_runtime_values() {
let source = "my ${scalar_name} = 1;";
let mut rv = HashMap::new();
rv.insert("$scalar_name".to_string(), "7".to_string());
let values = collect_inline_values_with_runtime(source, 1, 1, Some(&rv));
assert_eq!(values.len(), 1);
assert_eq!(values[0].text, "$scalar_name = 7");
}
#[test]
fn test_legacy_inline_values_support_braced_scalars() {
let source = "my ${scalar_name} = 1;";
let values = collect_inline_values(source, 1, 1);
assert_eq!(values.len(), 1);
assert_eq!(values[0].text, "$scalar_name = ?");
}
#[test]
fn test_legacy_inline_values_deduplicate_per_line() {
let source = "$x = $x + $x;";
let values = collect_inline_values(source, 1, 1);
assert_eq!(values.len(), 1);
assert_eq!(values[0].text, "$x = ?");
}
#[test]
fn test_legacy_inline_values_exclude_special_variables() {
let source = "print $_; warn $!; my $ok = 1;";
let values = collect_inline_values(source, 1, 1);
assert_eq!(values.len(), 1);
assert_eq!(values[0].text, "$ok = ?");
}
#[test]
fn test_inline_values_ignore_regex_match_operator_body() {
let source = r"my $target = 1; if ($line =~ m/(\$regex_capture)/) { my $ok = 1; }";
let values = collect_inline_values_with_runtime(source, 1, 1, None);
assert!(values.iter().any(|v| v.text == "$target = ?"));
assert!(values.iter().any(|v| v.text == "$line = ?"));
assert!(values.iter().any(|v| v.text == "$ok = ?"));
assert!(!values.iter().any(|v| v.text.contains("$regex_capture")));
}
#[test]
fn test_inline_values_ignore_substitution_and_transliteration_bodies() {
let source = "my $line = 1; $line =~ s/$find/$replace/g; $line =~ tr/$from/$to/;";
let values = collect_inline_values_with_runtime(source, 1, 1, None);
assert!(values.iter().any(|v| v.text == "$line = ?"));
assert!(!values.iter().any(|v| v.text.contains("$find")));
assert!(!values.iter().any(|v| v.text.contains("$replace")));
assert!(!values.iter().any(|v| v.text.contains("$from")));
assert!(!values.iter().any(|v| v.text.contains("$to")));
}
}