use crate::engine::{CompiledPolicy, Hit};
use crate::extract::Doc;
use crate::input::{line_ranges, FormatData, Prepared};
use crate::Config;
use std::ops::Range;
pub const HANDLED: &[&str] = &[
"SLOP-K001",
"SLOP-K002",
"SLOP-K003",
"SLOP-K004",
"SLOP-K005",
"SLOP-K006",
"SLOP-K007",
];
fn word_hit_in(hay: &str, base: usize, word: &str) -> Option<Range<usize>> {
let lower = hay.to_ascii_lowercase();
let mut at = 0usize;
while let Some(pos) = lower[at..].find(word) {
let s = at + pos;
let e = s + word.len();
let before_ok = !lower[..s]
.chars()
.next_back()
.map(unicode_ident::is_xid_continue)
.unwrap_or(false);
let after_ok = !lower[e..]
.chars()
.next()
.map(unicode_ident::is_xid_continue)
.unwrap_or(false);
let hyphenated = lower[..s].ends_with('-') || lower[e..].starts_with('-');
if before_ok && after_ok && !hyphenated {
return Some(base + s..base + e);
}
at = e;
}
None
}
pub fn evaluate(
cp: &CompiledPolicy,
prepared: &Prepared,
doc: &Doc,
config: &Config,
hits: &mut Vec<Hit>,
) {
let src = prepared.text.as_str();
if let Some(idx) = super::active(cp, config, "SLOP-K001") {
let rule = &cp.pkg.rules[idx];
let (title_range, title) = match doc.headings.first() {
Some(h) => (h.text_range.clone(), h.text.clone()),
None => {
let first = line_ranges(src).into_iter().next().unwrap_or(0..0);
let text = src[first.clone()].to_string();
(first, text)
}
};
let max_chars = super::param_i64(rule, "max_title_chars").unwrap_or(80) as usize;
if title.chars().count() > max_chars {
hits.push(Hit::new(idx, title_range.clone()));
}
let forbidden: Vec<String> = rule
.params
.as_table()
.and_then(|t| t.get("forbid_title_words"))
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
for word in &forbidden {
if let Some(span) = word_hit_in(&src[title_range.clone()], title_range.start, word) {
hits.push(Hit::new(idx, span));
}
}
}
if let Some(idx) = super::active(cp, config, "SLOP-K002") {
if let FormatData::Commit(split) = &prepared.format {
let rule = &cp.pkg.rules[idx];
let subject = &src[split.subject.clone()];
let max_chars = super::param_i64(rule, "max_subject_chars").unwrap_or(72) as usize;
if subject.chars().count() > max_chars {
hits.push(Hit::new(idx, split.subject.clone()));
}
if subject.trim_end().ends_with('.') {
hits.push(Hit::new(idx, split.subject.clone()));
}
let after_prefix = conventional_prefix(subject);
match after_prefix {
None => {
hits.push(Hit::new(idx, split.subject.clone()));
}
Some(rest_at) => {
let rest = &subject[rest_at..];
let first = rest.split_whitespace().next().unwrap_or("");
let lower = first.to_ascii_lowercase();
let non_imperative = lower.ends_with("ed")
|| (lower.ends_with("ing") && lower.len() > 4)
|| (lower.ends_with('s') && !lower.ends_with("ss") && lower.len() > 3);
if first
.chars()
.next()
.map(|c| c.is_uppercase())
.unwrap_or(false)
|| non_imperative
{
let s = split.subject.start + rest_at;
let mut h = Hit::new(idx, s..s + first.len());
h.force_candidate = true;
hits.push(h);
}
}
}
}
}
if let Some(idx) = super::active(cp, config, "SLOP-K003") {
let rule = &cp.pkg.rules[idx];
let whitelist: Vec<String> = rule
.params
.as_table()
.and_then(|t| t.get("section_whitelist"))
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
for h in doc.headings.iter().filter(|h| h.level >= 2) {
let name = h.text.to_ascii_lowercase();
let version_like = name.chars().any(|c| c.is_ascii_digit())
&& (name.contains('.') || name.contains('v') || name.contains('['));
if whitelist.iter().any(|w| &name == w) {
continue;
}
if version_like {
if !has_iso_date(&name) {
hits.push(Hit::new(idx, h.text_range.clone()));
}
continue;
}
hits.push(Hit::new(idx, h.text_range.clone()));
}
}
if let Some(idx) = super::active(cp, config, "SLOP-K004") {
match &config.deployment.expected_release_body {
Some(expected) => {
let got: Vec<&str> = src
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect();
let want: Vec<&str> = expected
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect();
let max_extra = super::param_i64(&cp.pkg.rules[idx], "max_extra_pointer_lines")
.unwrap_or(1) as usize;
let ok = got == want
|| (got.len() <= want.len() + max_extra
&& want.iter().all(|l| got.contains(l)));
if !ok {
hits.push(Hit::new(idx, super::document_anchor(src)));
}
}
None => {
let mut h = Hit::new(idx, 0..0);
h.force_hint = true;
hits.push(h);
}
}
}
if let Some(idx) = super::active(cp, config, "SLOP-K005") {
let license_heading = doc
.headings
.iter()
.enumerate()
.find(|(_, h)| h.text.eq_ignore_ascii_case("license"));
match license_heading {
None => {
hits.push(Hit::new(idx, 0..0));
}
Some((i, h)) => match &config.deployment.expected_license_wording {
Some(expected) => {
let section = doc
.sections
.get(i)
.map(|s| s.range.clone())
.unwrap_or(h.range.start..src.len());
let body = collapse_ws(&src[section.clone()]);
if !body.contains(&collapse_ws(expected)) {
hits.push(Hit::new(idx, h.range.clone()));
}
}
None => {
let mut hint = Hit::new(idx, h.range.clone());
hint.force_hint = true;
hits.push(hint);
}
},
}
}
if let Some(idx) = super::active(cp, config, "SLOP-K006") {
if let FormatData::Manifest(info) = &prepared.format {
let rule = &cp.pkg.rules[idx];
let max_desc = super::param_i64(rule, "max_description_chars").unwrap_or(160) as usize;
let kw_exact = super::param_i64(rule, "keywords_exact").unwrap_or(5) as usize;
let cat_min = super::param_i64(rule, "categories_min").unwrap_or(2) as usize;
let cat_max = super::param_i64(rule, "categories_max").unwrap_or(3) as usize;
match (&info.description, &info.description_span) {
(Some(d), Some(span)) => {
if d.chars().count() > max_desc {
hits.push(Hit::new(idx, span.clone()));
}
if let Some(name) = &info.name {
if d.to_ascii_lowercase()
.starts_with(&name.to_ascii_lowercase())
{
hits.push(Hit::new(idx, span.clone()));
}
}
}
_ => hits.push(Hit::new(idx, 0..0)),
}
match &info.keywords {
Some((n, span)) if *n != kw_exact => hits.push(Hit::new(idx, span.clone())),
None => hits.push(Hit::new(idx, 0..0)),
_ => {}
}
match &info.categories {
Some((n, span)) if *n < cat_min || *n > cat_max => {
hits.push(Hit::new(idx, span.clone()))
}
None => hits.push(Hit::new(idx, 0..0)),
_ => {}
}
}
}
if let Some(idx) = super::active(cp, config, "SLOP-K007") {
let mut found: Vec<(Range<usize>, String)> = Vec::new();
for code in &doc.code_regions {
let body = &src[code.range.clone()];
let mut at = 0usize;
while let Some(pos) = body[at..].find("= \"") {
let vstart = at + pos + 3;
if let Some(close) = body[vstart..].find('"') {
let val = &body[vstart..vstart + close];
if !val.is_empty()
&& val.chars().all(|c| c.is_ascii_digit() || c == '.')
&& val.contains('.')
{
found.push((
code.range.start + vstart..code.range.start + vstart + close,
val.to_string(),
));
}
at = vstart + close + 1;
} else {
break;
}
}
}
match &config.deployment.expected_version {
Some(expected) => {
for (span, val) in &found {
if val != expected && !val.starts_with(expected.as_str()) {
hits.push(Hit::new(idx, span.clone()));
}
}
}
None => {
if let Some((span, _)) = found.first() {
let mut h = Hit::new(idx, span.clone());
h.force_hint = true;
hits.push(h);
}
}
}
}
}
fn conventional_prefix(subject: &str) -> Option<usize> {
let colon = subject.find(':')?;
let head = &subject[..colon];
let (ty, scope_ok) = match head.find('(') {
Some(open) => {
let close = head.rfind(')')?;
if close + 1 != head.len() && !(head.ends_with('!') && close + 2 == head.len()) {
return None;
}
(&head[..open], close > open + 1)
}
None => (head.strip_suffix('!').unwrap_or(head), true),
};
let ty = ty.strip_suffix('!').unwrap_or(ty);
if ty.is_empty() || !ty.chars().all(|c| c.is_ascii_lowercase()) || !scope_ok {
return None;
}
if !subject[colon + 1..].starts_with(' ') {
return None;
}
Some(colon + 2)
}
fn has_iso_date(s: &str) -> bool {
let b = s.as_bytes();
if b.len() < 10 {
return false;
}
for i in 0..=b.len() - 10 {
let w = &b[i..i + 10];
if w[0].is_ascii_digit()
&& w[1].is_ascii_digit()
&& w[2].is_ascii_digit()
&& w[3].is_ascii_digit()
&& w[4] == b'-'
&& w[5].is_ascii_digit()
&& w[6].is_ascii_digit()
&& w[7] == b'-'
&& w[8].is_ascii_digit()
&& w[9].is_ascii_digit()
{
return true;
}
}
false
}
fn collapse_ws(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}