use super::sentence::{bare, blocks, param_words, push_norm_hit, sentences, word_tokens};
use crate::engine::{CompiledPolicy, Hit};
use crate::input::Prepared;
use crate::views::NormView;
use crate::Config;
use std::ops::Range;
pub const HANDLED: &[&str] = &["SLOP-C007", "SLOP-C011"];
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,
}
}
}
pub(crate) const NON_PARTICIPLE_ING: &[&str] =
&["nothing", "anything", "something", "everything", "during"];
pub(crate) fn reads_as_participle(word: &str) -> bool {
word.len() > 4 && word.ends_with("ing") && !NON_PARTICIPLE_ING.contains(&word)
}
pub(crate) fn leading_word(s: &str) -> String {
s.chars()
.take_while(|c| c.is_alphanumeric() || *c == '\'' || *c == '-')
.flat_map(char::to_lowercase)
.collect()
}
fn participial_adjunct(after_negation: &str) -> bool {
reads_as_participle(&leading_word(after_negation))
}
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;
}
if participial_adjunct(&rest[j..]) {
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>,
) {
apophatic_tail(cp, prepared, norm, config, hits);
capability_denial(cp, prepared, norm, config, hits);
}
fn apophatic_tail(
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);
}
}
pub(crate) fn shared_tool_nouns(cp: &CompiledPolicy) -> Vec<String> {
match super::rule_idx(cp, "SLOP-C011") {
Some(idx) => param_words(&cp.pkg.rules[idx], "tool_nouns"),
None => Vec::new(),
}
}
const COORDINATORS: &[&str] = &["and", "or", "but", "yet", "so", "nor"];
struct Clause {
range: Range<usize>,
segments: Vec<Range<usize>>,
}
fn clause_ranges(text: &str, sentence: &Range<usize>, terms: &DenialTerms) -> Vec<Clause> {
let mut out = Vec::new();
let mut push = |range: Range<usize>| {
if text[range.clone()].trim().is_empty() {
return;
}
let segments = segment_at_coordinators(text, &range);
out.push(Clause { range, segments });
};
let mut start = sentence.start;
for (off, c) in text[sentence.clone()].char_indices() {
let abs = sentence.start + off;
if c == '.' && starts_product_name(text, abs + 1, terms) {
push(start..abs);
start = abs + 1;
continue;
}
if c != ',' && c != ';' {
continue;
}
push(start..abs);
start = abs + c.len_utf8();
}
if start < sentence.end {
push(start..sentence.end);
}
out
}
fn starts_product_name(text: &str, at: usize, terms: &DenialTerms) -> bool {
text.get(at..)
.and_then(|rest| rest.split_whitespace().next())
.is_some_and(|word| terms.product_names.contains(&bare(word)))
}
fn segment_at_coordinators(text: &str, clause: &Range<usize>) -> Vec<Range<usize>> {
let lower = text[clause.clone()].to_ascii_lowercase();
let toks = word_tokens(&lower);
let mut cuts: Vec<usize> = toks
.iter()
.enumerate()
.filter(|(i, (_, tok))| *i > 0 && COORDINATORS.contains(&bare(tok).as_str()))
.map(|(_, (off, _))| clause.start + off)
.collect();
if cuts.is_empty() {
return vec![clause.clone()];
}
cuts.push(clause.end);
let mut out = Vec::new();
let mut start = clause.start;
for cut in cuts {
if !text[start..cut].trim().is_empty() {
out.push(start..cut);
}
start = cut;
}
out
}
pub(crate) fn phrase_words(list: &[String]) -> Vec<Vec<String>> {
list.iter()
.map(|p| p.split_whitespace().map(|w| w.to_string()).collect())
.collect()
}
pub(crate) fn phrase_at(toks: &[(usize, &str)], i: usize, phrase: &[String]) -> bool {
phrase
.iter()
.enumerate()
.all(|(k, w)| toks.get(i + k).is_some_and(|t| &bare(t.1) == w))
}
const WILDCARD_MAX_TOKENS: usize = 3;
pub(crate) fn phrase_match(
toks: &[(usize, &str)],
i: usize,
phrase: &[String],
) -> Option<Option<usize>> {
let Some(star) = phrase.iter().position(|w| w == "*") else {
return phrase_at(toks, i, phrase).then_some(None);
};
if !phrase_at(toks, i, &phrase[..star]) {
return None;
}
for width in 1..=WILDCARD_MAX_TOKENS {
let head = i + star + width - 1;
if toks.get(head).is_none() {
break;
}
if phrase_at(toks, head + 1, &phrase[star + 1..]) {
return Some(Some(head));
}
}
None
}
pub(crate) fn is_tool_noun(word: &str, tool_nouns: &[String]) -> bool {
tool_nouns.iter().any(|t| t == word)
}
pub(crate) struct DenialTerms {
pub pronouns: Vec<String>,
pub product_names: Vec<String>,
pub tool_nouns: Vec<String>,
pub imperative_negations: Vec<Vec<String>>,
pub finite_negations: Vec<Vec<String>>,
pub verbs_base: Vec<String>,
pub verbs_s: Vec<String>,
pub verbs_ing: Vec<String>,
pub negation_window: usize,
pub verb_window: usize,
pub negative_subject_window: usize,
}
impl DenialTerms {
fn any_verb(&self, word: &str) -> bool {
self.verbs_base.iter().any(|v| v == word)
|| self.verbs_s.iter().any(|v| v == word)
|| self.verbs_ing.iter().any(|v| v == word)
}
fn negation_end(&self, toks: &[(usize, &str)], at: usize) -> Option<usize> {
self.imperative_negations
.iter()
.chain(self.finite_negations.iter())
.find(|n| phrase_at(toks, at, n))
.map(|n| at + n.len() - 1)
}
}
fn clause_head(toks: &[(usize, &str)]) -> usize {
usize::from(
toks.first()
.is_some_and(|t| COORDINATORS.contains(&bare(t.1).as_str())),
)
}
fn is_base_form(word: &str) -> bool {
!(word.ends_with('s') && !word.ends_with("ss"))
&& !word.ends_with("ed")
&& !word.ends_with("ing")
}
fn governed_verb(toks: &[(usize, &str)], neg_end: usize) -> Option<String> {
let mut at = neg_end + 1;
if toks.get(at).is_some_and(|t| bare(t.1).ends_with("ly")) {
at += 1;
}
toks.get(at).map(|t| bare(t.1))
}
fn is_imperative(toks: &[(usize, &str)], head: usize, terms: &DenialTerms) -> bool {
let Some(neg) = terms
.imperative_negations
.iter()
.find(|n| phrase_at(toks, head, n))
else {
return false;
};
governed_verb(toks, head + neg.len() - 1).is_some_and(|w| is_base_form(&w))
}
fn and_led_denial(toks: &[(usize, &str)], head: usize, terms: &DenialTerms) -> bool {
if head == 0 || bare(toks[0].1) != "and" {
return false;
}
let Some(neg) = terms
.imperative_negations
.iter()
.find(|n| phrase_at(toks, head, n))
else {
return false;
};
let from = head + neg.len();
(from..from + terms.verb_window).any(|i| {
toks.get(i)
.is_some_and(|t| terms.verbs_base.contains(&bare(t.1)))
})
}
fn positive_subject(toks: &[(usize, &str)], at: usize, terms: &DenialTerms) -> Option<usize> {
let head = bare(toks.get(at)?.1);
if terms.pronouns.contains(&head) || terms.product_names.contains(&head) {
return Some(1);
}
if head == "the"
&& toks
.get(at + 1)
.is_some_and(|t| is_tool_noun(&bare(t.1), &terms.tool_nouns))
{
return Some(2);
}
None
}
fn negative_subject(toks: &[(usize, &str)], at: usize, terms: &DenialTerms) -> Option<usize> {
let head = bare(toks.get(at)?.1);
if head == "nothing" {
return Some(1);
}
if head == "no"
&& toks
.get(at + 1)
.is_some_and(|t| is_tool_noun(&bare(t.1), &terms.tool_nouns))
{
return Some(2);
}
if head == "none"
&& toks.get(at + 1).is_some_and(|t| bare(t.1) == "of")
&& toks.get(at + 2).is_some_and(|t| bare(t.1) == "the")
&& toks
.get(at + 3)
.is_some_and(|t| is_tool_noun(&bare(t.1), &terms.tool_nouns))
{
return Some(4);
}
None
}
#[derive(PartialEq)]
enum Spelling {
Subject,
Elided,
}
fn denied_capability(
toks: &[(usize, &str)],
start: usize,
terms: &DenialTerms,
) -> Option<Spelling> {
let verb_at = |i: usize| toks.get(i).is_some_and(|t| terms.any_verb(&bare(t.1)));
if let Some(len) = positive_subject(toks, start, terms) {
let from = start + len;
for i in from..(from + terms.negation_window).min(toks.len()) {
let Some(neg_end) = terms.negation_end(toks, i) else {
continue;
};
if (neg_end + 1..=neg_end + terms.verb_window).any(verb_at) {
return Some(Spelling::Subject);
}
}
}
if let Some(len) = negative_subject(toks, start, terms) {
let from = start + len;
if (from..from + terms.negative_subject_window).any(verb_at) {
return Some(Spelling::Subject);
}
}
let third_person_only = |i: usize| {
toks.get(i)
.is_some_and(|t| terms.verbs_s.iter().any(|v| *v == bare(t.1)))
};
let finite_form = |i: usize| {
toks.get(i).is_some_and(|t| {
let w = bare(t.1);
terms.verbs_base.contains(&w) || terms.verbs_s.contains(&w)
})
};
if let Some(neg) = terms
.finite_negations
.iter()
.find(|n| phrase_at(toks, start, n))
{
let from = start + neg.len();
if (from..from + terms.verb_window).any(finite_form) {
return Some(Spelling::Elided);
}
}
if let Some(neg) = terms
.imperative_negations
.iter()
.find(|n| phrase_at(toks, start, n))
{
let from = start + neg.len();
if (from..from + terms.verb_window).any(third_person_only) {
return Some(Spelling::Elided);
}
}
None
}
fn hedged(toks: &[(usize, &str)], hedges: &[Vec<String>]) -> Option<Option<usize>> {
(0..toks.len()).find_map(|i| hedges.iter().find_map(|h| phrase_match(toks, i, h)))
}
struct ClauseFacts {
segment: Range<usize>,
clause: Range<usize>,
qualifying: bool,
denied_capability: bool,
arm_b_eligible: bool,
elided_subject: bool,
closed_subject: bool,
and_led: bool,
partner: bool,
pronoun_subject: bool,
referents: Vec<String>,
}
fn clause_facts(
clause: Range<usize>,
segment: Range<usize>,
lower: &str,
terms: &DenialTerms,
hedges: &[Vec<String>],
) -> ClauseFacts {
let toks = word_tokens(&lower[segment.clone()]);
let head = clause_head(&toks);
let imperative = is_imperative(&toks, head, terms);
let spelling = if imperative {
None
} else {
denied_capability(&toks, head, terms)
};
let capability = spelling.is_some();
let hedge = if imperative || capability {
None
} else {
hedged(&toks, hedges)
};
let negated = (0..toks.len()).any(|i| terms.negation_end(&toks, i).is_some());
let subject = positive_subject(&toks, head, terms);
let head_noun_is_closed = hedge.flatten().is_some_and(|at| {
toks.get(at)
.is_some_and(|t| is_tool_noun(&bare(t.1), &terms.tool_nouns))
});
let closed_subject =
subject.is_some() || negative_subject(&toks, head, terms).is_some() || head_noun_is_closed;
ClauseFacts {
segment,
clause,
qualifying: capability || hedge.is_some(),
denied_capability: capability,
arm_b_eligible: capability || closed_subject,
elided_subject: spelling == Some(Spelling::Elided),
closed_subject,
and_led: !capability && and_led_denial(&toks, head, terms),
partner: subject.is_some() && !negated && !capability && hedge.is_none(),
pronoun_subject: toks
.get(head)
.is_some_and(|t| terms.pronouns.contains(&bare(t.1))),
referents: toks
.iter()
.filter_map(|t| {
let word = bare(t.1);
if terms.product_names.contains(&word) {
return Some(word);
}
tool_lemma(&word, &terms.tool_nouns)
})
.collect(),
}
}
fn tool_lemma(word: &str, tool_nouns: &[String]) -> Option<String> {
if !is_tool_noun(word, tool_nouns) {
return None;
}
match word.strip_suffix('s') {
Some(singular) if tool_nouns.iter().any(|t| t == singular) => Some(singular.to_string()),
_ => Some(word.to_string()),
}
}
fn corefer(denial: &ClauseFacts, partner: &ClauseFacts) -> bool {
denial.elided_subject
|| partner.pronoun_subject
|| denial.pronoun_subject
|| denial
.referents
.iter()
.any(|l| partner.referents.contains(l))
}
fn capability_denial(
cp: &CompiledPolicy,
prepared: &Prepared,
norm: &NormView,
config: &Config,
hits: &mut Vec<Hit>,
) {
let Some(idx) = super::active(cp, config, "SLOP-C011") else {
return;
};
let rule = &cp.pkg.rules[idx];
let terms = DenialTerms {
pronouns: param_words(rule, "subject_pronouns"),
product_names: param_words(rule, "product_names"),
tool_nouns: shared_tool_nouns(cp),
imperative_negations: phrase_words(¶m_words(rule, "imperative_negations")),
finite_negations: phrase_words(¶m_words(rule, "finite_negations")),
verbs_base: param_words(rule, "capability_verbs_base"),
verbs_s: param_words(rule, "capability_verbs_s"),
verbs_ing: param_words(rule, "capability_verbs_ing"),
negation_window: super::param_i64(rule, "negation_window_tokens").unwrap_or(4) as usize,
verb_window: super::param_i64(rule, "verb_window_tokens").unwrap_or(3) as usize,
negative_subject_window: super::param_i64(rule, "negative_subject_window_tokens")
.unwrap_or(4) as usize,
};
let hedges = phrase_words(¶m_words(rule, "hedges"));
let text = norm.text.as_str();
let src = prepared.text.as_str();
let lower = text.to_ascii_lowercase();
for block in blocks(norm) {
let mut per_sentence: Vec<Vec<ClauseFacts>> = sentences(text, &block)
.iter()
.map(|sentence| {
clause_ranges(text, sentence, &terms)
.into_iter()
.flat_map(|clause| {
clause
.segments
.into_iter()
.map(move |segment| (clause.range.clone(), segment))
})
.map(|(clause, segment)| clause_facts(clause, segment, &lower, &terms, &hedges))
.collect()
})
.collect();
for segments in per_sentence.iter_mut() {
let mut subject_seen = false;
for facts in segments.iter_mut() {
if facts.and_led && subject_seen {
facts.qualifying = true;
facts.denied_capability = true;
facts.elided_subject = true;
facts.arm_b_eligible = true;
}
subject_seen |= facts.closed_subject;
}
}
let qualifying: Vec<(usize, usize)> = per_sentence
.iter()
.enumerate()
.flat_map(|(si, cs)| {
cs.iter()
.enumerate()
.filter(|(_, c)| c.qualifying)
.map(move |(ci, _)| (si, ci))
})
.collect();
let fires = match qualifying.len() {
0 => false,
1 => {
let (si, ci) = qualifying[0];
let denial = &per_sentence[si][ci];
if !denial.arm_b_eligible {
false
} else {
let same_sentence = per_sentence[si]
.iter()
.enumerate()
.any(|(cj, c)| cj != ci && c.partner && corefer(denial, c));
same_sentence
|| [si.checked_sub(1), Some(si + 1)]
.into_iter()
.flatten()
.filter_map(|sj| per_sentence.get(sj))
.any(|cs| cs.iter().any(|c| c.partner && corefer(denial, c)))
}
}
_ => true,
};
if !fires {
continue;
}
let detail = match qualifying.len() {
1 => "arm B, one denial beside an affirmative partner".to_string(),
n => format!("arm A, {n} denials in this block"),
};
for (si, ci) in qualifying {
let facts = &per_sentence[si][ci];
let span = if facts.denied_capability {
facts.segment.clone()
} else {
facts.clause.clone()
};
let Some(span) = clause_content(text, &span) else {
continue;
};
let before = hits.len();
push_norm_hit(idx, norm, src, span, hits);
if let Some(hit) = hits.get_mut(before) {
hit.detail = Some(detail.clone());
}
}
}
}
fn clause_content(text: &str, span: &Range<usize>) -> Option<Range<usize>> {
let mut start = span.start;
let head = &text[span.clone()];
let first = head.trim_start();
start += head.len() - first.len();
if let Some(word) = first.split_whitespace().next() {
if COORDINATORS.contains(&bare(word).as_str()) {
let rest = &first[word.len()..];
start += word.len() + (rest.len() - rest.trim_start().len());
}
}
let end = span.start
+ text[span.clone()]
.trim_end_matches(|c: char| {
c.is_whitespace() || matches!(c, ',' | ';' | '.' | '!' | '?')
})
.len();
(start < end).then_some(start..end)
}