use crate::engine::{CompiledPolicy, Hit};
use crate::input::Prepared;
use crate::views::NormView;
use crate::Config;
pub const HANDLED: &[&str] = &["SLOP-C007"];
fn param_str_list(rule: &crate::policy::Rule, key: &str) -> Vec<String> {
rule.params
.as_table()
.and_then(|t| t.get(key))
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_ascii_lowercase()))
.collect()
})
.unwrap_or_default()
}
fn first_token(clause: &str) -> String {
let mut out = String::new();
for c in clause.chars() {
let c = if c == '\u{2019}' { '\'' } else { c };
if c.is_alphanumeric() || c == '\'' {
out.push(c.to_ascii_lowercase());
} else if out.is_empty() {
continue;
} else {
break;
}
}
out
}
fn token_at(clause_lower: &str, at: usize) -> String {
first_token(&clause_lower[at..])
}
fn contains_word(hay_lower: &str, needle: &str) -> bool {
let mut at = 0usize;
while let Some(pos) = hay_lower[at..].find(needle) {
let s = at + pos;
let e = s + needle.len();
let before_ok = hay_lower[..s]
.chars()
.next_back()
.map(|c| !c.is_alphanumeric())
.unwrap_or(true);
let after_ok = hay_lower[e..]
.chars()
.next()
.map(|c| !c.is_alphanumeric())
.unwrap_or(true);
if before_ok && after_ok {
return true;
}
at = s + 1;
}
false
}
pub(crate) fn period_is_terminal(text: &str, dot_end: usize) -> bool {
let mut chars = text[dot_end..].chars();
let Some(first) = chars.next() else {
return true; };
if first.is_alphanumeric() {
return false; }
if first != ' ' && first != '\t' {
return true;
}
let mut seen = 1usize;
loop {
match chars.next() {
Some(' ') | Some('\t') => {
seen += 1;
if seen > 8 {
return true;
}
}
Some('\n') | Some('\r') => return true, Some(c) => return !c.is_lowercase(),
None => return true,
}
}
}
fn parse_tail(text: &str, comma: usize, np_max: usize) -> Option<usize> {
let rest = text.get(comma + 1..)?;
let mut i = 0usize;
for c in rest.chars().take(8) {
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
i += c.len_utf8();
} else {
break;
}
}
let after_ws = &rest[i..];
let kw_len = if after_ws
.get(..5)
.is_some_and(|s| s.eq_ignore_ascii_case("never"))
{
5
} else if after_ws
.get(..3)
.is_some_and(|s| s.eq_ignore_ascii_case("not"))
{
3
} else {
return None;
};
let mut j = i + kw_len;
let mut ws = 0usize;
for c in rest[j..].chars().take(8) {
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
ws += 1;
j += c.len_utf8();
} else {
break;
}
}
if ws == 0 {
return None;
}
let np_start = j;
let mut k = j;
let mut np_has_content = false;
for c in rest[np_start..].chars() {
match c {
'.' if !period_is_terminal(text, comma + 1 + k + 1) => {
np_has_content = true;
k += 1;
if k - np_start > np_max {
return None;
}
}
'.' | '!' | '?' => {
if !np_has_content {
return None; }
let np_lower = rest[np_start..k].to_ascii_lowercase();
if contains_word(&np_lower, "but") {
return None;
}
return Some(comma + 1 + k + c.len_utf8());
}
';' | ':' | ',' | '\n' | '\u{FFFD}' => return None,
_ => {
if !c.is_whitespace() {
np_has_content = true;
}
k += c.len_utf8();
if k - np_start > np_max {
return None;
}
}
}
}
None
}
fn clause_start(text: &str, comma: usize, window: usize) -> Option<usize> {
let lo = crate::widen_to_char_boundaries(text, comma.saturating_sub(window)..comma).start;
let region = &text[lo..comma];
for (off, c) in region.char_indices().rev() {
let abs = lo + off;
let boundary_end = match c {
'\n' => Some(abs + 1),
'.' | '!' | '?' | ':' => {
let next = text[abs + c.len_utf8()..].chars().next();
if matches!(next, Some(w) if w.is_whitespace())
&& (c != '.' || period_is_terminal(text, abs + 1))
{
Some(abs + c.len_utf8())
} else {
None
}
}
_ => None,
};
if let Some(mut p) = boundary_end {
for w in text[p..comma].chars() {
if w.is_whitespace() {
p += w.len_utf8();
} else {
break;
}
}
return Some(p);
}
}
if lo == 0 {
return Some(0);
}
None
}
fn suppressed(clause: &str, openers: &[String], second_person: &[String]) -> bool {
let lower = clause.to_lowercase();
let head = first_token(&lower);
if !head.is_empty() && openers.contains(&head) {
return true;
}
if second_person.iter().any(|t| contains_word(&lower, t)) {
return true;
}
let mut at = 0usize;
while let Some(pos) = lower[at..].find(", ") {
let s = at + pos + 2;
let tok = token_at(&lower, s);
if !tok.is_empty() && openers.contains(&tok) {
return true;
}
at = s;
}
let mut at = 0usize;
while let Some(pos) = lower[at..].find("then ") {
let s = at + pos;
let before_ok = lower[..s]
.chars()
.next_back()
.map(|c| !c.is_alphanumeric())
.unwrap_or(true);
if before_ok {
let tok = token_at(&lower, s + 5);
if !tok.is_empty() && openers.contains(&tok) {
return true;
}
}
at = s + 5;
}
false
}
pub fn evaluate(
cp: &CompiledPolicy,
prepared: &Prepared,
norm: &NormView,
config: &Config,
hits: &mut Vec<Hit>,
) {
let Some(idx) = super::active(cp, config, "SLOP-C007") else {
return;
};
let rule = &cp.pkg.rules[idx];
let np_max = super::param_i64(rule, "tail_np_max_bytes").unwrap_or(60) as usize;
let window = super::param_i64(rule, "clause_window_bytes").unwrap_or(240) as usize;
let openers = param_str_list(rule, "imperative_openers");
let second_person = param_str_list(rule, "second_person");
let text = norm.text.as_str();
let src = prepared.text.as_str();
for (comma, _) in text.char_indices().filter(|(_, c)| *c == ',') {
let Some(tail_end) = parse_tail(text, comma, np_max) else {
continue;
};
if let Some(cs) = clause_start(text, comma, window) {
if suppressed(&text[cs..comma], &openers, &second_person) {
continue;
}
}
let span = comma..tail_end;
let Some(source_span) = norm.to_source(span.clone()) else {
continue;
};
let source_span = crate::widen_to_char_boundaries(src, source_span);
if source_span.start >= source_span.end {
continue;
}
let mut hit = Hit::new(idx, source_span);
hit.quoted = norm.all_quoted(&span);
hit.trigger = Some(text[span].to_string());
hits.push(hit);
}
}