use super::{ConfigIssue, Severity};
use regex::Regex;
use std::collections::HashMap;
fn create_unquoted_var_pattern() -> Regex {
Regex::new(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?").unwrap()
}
pub fn analyze_unquoted_variables(source: &str) -> Vec<UnquotedVariable> {
let mut variables = Vec::new();
let var_pattern = create_unquoted_var_pattern();
for (line_num, line) in source.lines().enumerate() {
let line_num = line_num + 1;
if line.trim().starts_with('#') {
continue;
}
if !line.contains('$') {
continue;
}
for cap in var_pattern.captures_iter(line) {
let var_match = cap.get(0).unwrap();
let var_name = var_match.as_str();
let start = var_match.start();
if is_already_quoted(line, start) {
continue;
}
if is_special_context(line, start) {
continue;
}
variables.push(UnquotedVariable {
line: line_num,
column: start,
variable: var_name.to_string(),
context: line.to_string(),
});
}
}
variables
}
fn is_already_quoted(line: &str, pos: usize) -> bool {
let before = &line[..pos];
let quote_count = before.matches('"').count();
if quote_count % 2 == 1 {
return true;
}
if pos > 0 && line.chars().nth(pos - 1) == Some('"') {
return true;
}
false
}
fn is_special_context(line: &str, pos: usize) -> bool {
let line_trimmed = line.trim();
if line_trimmed.contains("$((") || line_trimmed.contains("((") {
return true;
}
if pos > 0 && line.chars().nth(pos - 1) == Some('[') {
return true;
}
if line_trimmed.starts_with("export ") && !line.contains('=') {
return true;
}
false
}
#[derive(Debug, Clone, PartialEq)]
pub struct UnquotedVariable {
pub line: usize,
pub column: usize,
pub variable: String,
pub context: String,
}
pub fn detect_unquoted_variables(variables: &[UnquotedVariable]) -> Vec<ConfigIssue> {
variables
.iter()
.map(|var| ConfigIssue {
rule_id: "CONFIG-002".to_string(),
severity: Severity::Warning,
message: format!(
"Unquoted variable expansion: '{}' can cause word splitting and glob expansion",
var.variable
),
line: var.line,
column: var.column,
suggestion: Some(format!("Quote the variable: \"{}\"", var.variable)),
})
.collect()
}
pub fn quote_variables(source: &str) -> String {
let variables = analyze_unquoted_variables(source);
if variables.is_empty() {
return source.to_string();
}
let mut lines_to_fix: HashMap<usize, Vec<&UnquotedVariable>> = HashMap::new();
for var in &variables {
lines_to_fix.entry(var.line).or_default().push(var);
}
let mut result = Vec::new();
for (line_num, line) in source.lines().enumerate() {
let line_num = line_num + 1;
if let Some(_vars_on_line) = lines_to_fix.get(&line_num) {
if line.contains('=')
&& (line.trim().starts_with("export ")
|| line.trim().starts_with("local ")
|| !line.trim().starts_with("if "))
{
let fixed_line = quote_assignment_line(line);
result.push(fixed_line);
} else {
let fixed_line = quote_command_line(line);
result.push(fixed_line);
}
} else {
result.push(line.to_string());
}
}
result.join("\n")
}
fn quote_assignment_line(line: &str) -> String {
if let Some(eq_pos) = line.find('=') {
let lhs = &line[..=eq_pos];
let rhs = &line[eq_pos + 1..];
if (rhs.starts_with('"') && rhs.ends_with('"'))
|| (rhs.starts_with('\'') && rhs.ends_with('\''))
{
return line.to_string();
}
let rhs_with_braces = add_braces_to_variables(rhs);
format!("{}\"{}\"", lhs, rhs_with_braces)
} else {
line.to_string()
}
}
fn add_braces_to_variables(text: &str) -> String {
let var_pattern = Regex::new(r"\$([A-Za-z_][A-Za-z0-9_]*)").unwrap();
var_pattern.replace_all(text, "$${$1}").to_string()
}
fn quote_command_line(line: &str) -> String {
let var_pattern = create_unquoted_var_pattern();
let mut result = line.to_string();
let matches: Vec<_> = var_pattern.find_iter(line).collect();
for mat in matches.iter().rev() {
let var = mat.as_str();
let start = mat.start();
let end = mat.end();
if is_already_quoted(line, start) {
continue;
}
let quoted = if var.starts_with("${") {
format!("\"{}\"", var)
} else {
let var_name = var.trim_start_matches('$');
format!("\"${{{}}}\"", var_name)
};
let before = &result[..start];
let after = &result[end..];
result = format!("{}{}{}", before, quoted, after);
}
result
}
#[cfg(test)]
#[path = "quoter_tests_config_002.rs"]
mod tests_extracted;