use crate::linter::{Diagnostic, LintResult, Severity, Span};
use regex::Regex;
static MULTIPLE_STDOUT_REDIRECTS: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r">\s*[^\s;&|]+[^2>]*>\s*[^\s;&|]+").unwrap()
});
static MULTIPLE_STDERR_REDIRECTS: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r"2>\s*[^\s;&|]+.*2>\s*[^\s;&|]+").unwrap()
});
static MULTIPLE_APPEND_REDIRECTS: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r">>\s*[^\s;&|]+.*>>\s*[^\s;&|]+").unwrap()
});
fn should_skip_line(line: &str) -> bool {
if line.trim_start().starts_with('#') {
return true;
}
line.contains("<<") || line.contains("<<<")
}
fn is_multi_char_operator(ch: char, next_ch: char) -> bool {
(ch == '&' && next_ch == '&') || (ch == '|' && next_ch == '|')
}
fn is_single_pipe(ch: char, prev_ch: Option<char>) -> bool {
ch == '|' && prev_ch != Some('|')
}
fn add_command_if_nonempty<'a>(commands: &mut Vec<&'a str>, cmd: &'a str) {
if !cmd.trim().is_empty() {
commands.push(cmd);
}
}
fn get_next_position<I>(chars: &mut std::iter::Peekable<I>, line_len: usize) -> usize
where
I: Iterator<Item = (usize, char)>,
{
if let Some(&(next_pos, _)) = chars.peek() {
next_pos
} else {
line_len
}
}
fn split_commands(line: &str) -> Vec<&str> {
let mut commands = Vec::new();
let mut current_start = 0;
let mut chars = line.char_indices().peekable();
let mut prev_ch: Option<char> = None;
while let Some((byte_pos, ch)) = chars.next() {
if let Some(&(_, next_ch)) = chars.peek() {
if is_multi_char_operator(ch, next_ch) {
if byte_pos > current_start {
add_command_if_nonempty(&mut commands, &line[current_start..byte_pos]);
}
chars.next();
current_start = get_next_position(&mut chars, line.len());
prev_ch = Some(next_ch);
continue;
}
}
if ch == ';' || is_single_pipe(ch, prev_ch) {
if byte_pos > current_start {
add_command_if_nonempty(&mut commands, &line[current_start..byte_pos]);
}
current_start = get_next_position(&mut chars, line.len());
}
prev_ch = Some(ch);
}
if current_start < line.len() {
add_command_if_nonempty(&mut commands, &line[current_start..]);
}
commands
}
fn count_stdout_redirects(line: &str) -> usize {
let parts: Vec<&str> = line.split('>').collect();
let mut stdout_count = 0;
for (i, part) in parts.iter().enumerate() {
if i > 0 && !parts[i - 1].ends_with('2') && !parts[i - 1].ends_with('&') {
stdout_count += 1;
}
}
stdout_count
}
fn create_diagnostic(message: String, line_num: usize, line_len: usize) -> Diagnostic {
Diagnostic::new(
"SC2096",
Severity::Warning,
message,
Span::new(line_num, 1, line_num, line_len),
)
}
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
for (line_num, line) in source.lines().enumerate() {
let line_num = line_num + 1;
if should_skip_line(line) {
continue;
}
let commands = split_commands(line);
for cmd in commands {
if MULTIPLE_STDOUT_REDIRECTS.is_match(cmd) && !cmd.contains(">>") {
let stdout_count = count_stdout_redirects(cmd);
if stdout_count > 1 {
let diagnostic = create_diagnostic(
"Multiple stdout redirections specified. Only the last one will be used, earlier ones are ignored".to_string(),
line_num,
line.len(),
);
result.add(diagnostic);
}
}
if MULTIPLE_STDERR_REDIRECTS.is_match(cmd) {
let diagnostic = create_diagnostic(
"Multiple stderr redirections specified. Only the last one will be used, earlier ones are ignored".to_string(),
line_num,
line.len(),
);
result.add(diagnostic);
}
if MULTIPLE_APPEND_REDIRECTS.is_match(cmd) {
let diagnostic = create_diagnostic(
"Multiple append redirections specified. Only the last one will be used, earlier ones are ignored".to_string(),
line_num,
line.len(),
);
result.add(diagnostic);
}
}
}
result
}
#[cfg(test)]
#[path = "sc2096_tests_prop_sc2096.rs"]
mod tests_extracted;