use crate::engine::{CompiledPolicy, Hit};
use crate::input::Prepared;
use crate::views::NormView;
use crate::Config;
pub const HANDLED: &[&str] = &["SLOP-C007", "SLOP-C010"];
pub(crate) fn 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) fn temporal_while(matched: &str) -> bool {
const COPULAS: &[&str] = &["am", "is", "are", "was", "were", "be", "being"];
const FINITE: &[&str] = &[
"am", "is", "are", "was", "were", "has", "have", "had", "do", "does", "did", "can",
"could", "will", "would", "shall", "should", "may", "might", "must",
];
const CONCESSION_PARTICIPLES: &[&str] = &[
"acknowledging",
"recognizing",
"granting",
"accepting",
"conceding",
"admitting",
"noting",
"allowing",
];
let clause = matched.trim_start();
if !clause
.get(..5)
.is_some_and(|s| s.eq_ignore_ascii_case("while"))
{
return false;
}
if clause[5..]
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '\'')
{
return false;
}
let clause = match clause.find(',') {
Some(at) => &clause[..at],
None => clause,
};
let toks = tokenize(clause, 0..clause.len());
let finite = toks
.iter()
.skip(1)
.any(|t| FINITE.contains(&t.word.as_str()));
if let Some(next) = toks.get(1) {
let w = next.word.as_str();
if !finite
&& verb_form(w) == VerbForm::Participle
&& !CONCESSION_PARTICIPLES.contains(&w)
&& !NOT_PARTICIPLES.contains(&w)
{
return true;
}
}
const ADVERBS: &[&str] = &[
"still",
"already",
"yet",
"now",
"just",
"also",
"again",
"always",
"currently",
"never",
];
let adjacent = |a: &Tok, b: &Tok| {
let gap = &clause[a.end..b.start];
!gap.is_empty() && gap.len() <= 8 && gap.chars().all(char::is_whitespace)
};
let participle =
|t: &Tok| t.word.len() >= 4 && t.word.get(1..).is_some_and(|s| s.contains("ing"));
let skippable = |t: &Tok| t.word.ends_with("ly") || ADVERBS.contains(&t.word.as_str());
toks.iter().enumerate().any(|(i, cop)| {
if !COPULAS.contains(&cop.word.as_str()) {
return false;
}
let Some(next) = toks.get(i + 1) else {
return false;
};
if !adjacent(cop, next) {
return false;
}
if participle(next) {
return true;
}
if !skippable(next) {
return false;
}
toks.get(i + 2)
.is_some_and(|t| adjacent(next, t) && participle(t))
})
}
const NOT_PARTICIPLES: &[&str] = &["nothing", "anything", "something", "everything", "during"];
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 head: String = rest[j..]
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '\'' || *c == '-')
.flat_map(char::to_lowercase)
.collect();
if verb_form(&head) == VerbForm::Participle && !NOT_PARTICIPLES.contains(&head.as_str()) {
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>,
) {
evaluate_c007(cp, prepared, norm, config, hits);
evaluate_c010(cp, prepared, norm, config, hits);
}
fn evaluate_c007(
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 = str_list(rule, "imperative_openers");
let second_person = 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) struct Tok {
pub start: usize,
pub end: usize,
pub word: String,
}
pub(crate) fn tokenize(text: &str, range: std::ops::Range<usize>) -> Vec<Tok> {
let mut out: Vec<Tok> = Vec::new();
let mut cur = String::new();
let mut cur_start = range.start;
for (off, raw) in text[range.clone()].char_indices() {
let abs = range.start + off;
let c = if raw == '\u{2019}' { '\'' } else { raw };
if c.is_alphanumeric() || c == '\'' || c == '-' {
if cur.is_empty() {
cur_start = abs;
}
cur.push(c.to_ascii_lowercase());
} else if !cur.is_empty() {
out.push(Tok {
start: cur_start,
end: abs,
word: std::mem::take(&mut cur),
});
}
}
if !cur.is_empty() {
out.push(Tok {
start: cur_start,
end: range.end,
word: cur,
});
}
out
}
pub(crate) fn phrase_at(toks: &[Tok], i: usize, phrase: &str) -> bool {
phrase_match(toks, i, phrase).is_some()
}
pub(crate) struct PhraseMatch {
pub len: usize,
pub head: Option<usize>,
}
pub(crate) fn phrase_match(toks: &[Tok], i: usize, phrase: &str) -> Option<PhraseMatch> {
const WILDCARD_MAX: usize = 3;
let parts: Vec<&str> = phrase.split_whitespace().collect();
if parts.is_empty() {
return None;
}
fn walk(
toks: &[Tok],
k: usize,
parts: &[&str],
head: Option<usize>,
) -> Option<(usize, Option<usize>)> {
let Some((part, rest)) = parts.split_first() else {
return Some((k, head));
};
if *part == "*" {
for take in (1..=WILDCARD_MAX).rev() {
if toks.len() < k + take {
continue;
}
if let Some(done) = walk(toks, k + take, rest, Some(k + take - 1)) {
return Some(done);
}
}
return None;
}
match toks.get(k) {
Some(t) if &t.word == part => walk(toks, k + 1, rest, head),
_ => None,
}
}
let (end, head) = walk(toks, i, &parts, None)?;
if end > i {
Some(PhraseMatch { len: end - i, head })
} else {
None
}
}
pub(crate) fn phrase_at_any<'a>(
toks: &[Tok],
i: usize,
phrases: &'a [String],
) -> Option<(&'a str, PhraseMatch)> {
phrases
.iter()
.filter_map(|p| phrase_match(toks, i, p).map(|m| (p.as_str(), m)))
.max_by_key(|(_, m)| m.len)
}
pub(crate) fn phrase_in(toks: &[Tok], from: usize, to: usize, phrases: &[String]) -> Option<usize> {
(from..to.min(toks.len())).find(|&i| phrases.iter().any(|p| phrase_at(toks, i, p)))
}
pub(crate) struct Subject {
pub span: usize,
pub key: String,
pub negative: bool,
}
fn lemma(noun: &str, tool_nouns: &[String]) -> String {
match noun.strip_suffix('s') {
Some(base) if tool_nouns.iter().any(|n| n == base) => base.to_string(),
_ => noun.to_string(),
}
}
pub(crate) fn subject_at(toks: &[Tok], i: usize, tool_nouns: &[String]) -> Option<Subject> {
let w = &toks.get(i)?.word;
let sub = |span, key: String, negative| {
Some(Subject {
span,
key,
negative,
})
};
if w == "it" || w == "this" {
return sub(1, "pronoun".to_string(), false);
}
if w == "nothing" {
return sub(1, "pronoun".to_string(), true);
}
if w == "none"
&& toks.get(i + 1).map(|t| t.word.as_str()) == Some("of")
&& toks.get(i + 2).map(|t| t.word.as_str()) == Some("the")
{
let noun = &toks.get(i + 3)?.word;
if tool_nouns.contains(noun) {
return sub(4, format!("noun:{}", lemma(noun, tool_nouns)), true);
}
return None;
}
if w == "the" || w == "no" {
let noun = &toks.get(i + 1)?.word;
if tool_nouns.contains(noun) {
return sub(2, format!("noun:{}", lemma(noun, tool_nouns)), w == "no");
}
}
None
}
fn same_referent(a: &str, b: &str) -> bool {
a == b || a == "pronoun" || b == "pronoun"
}
struct ClauseRead {
segment: std::ops::Range<usize>,
clause: std::ops::Range<usize>,
qualifies: bool,
open_complement: bool,
subject: Option<String>,
affirmative: Option<String>,
has_subject: bool,
joined_denial: bool,
}
impl ClauseRead {
fn report_range(&self) -> std::ops::Range<usize> {
if self.open_complement {
self.clause.clone()
} else {
self.segment.clone()
}
}
}
struct C010Sets {
tool_nouns: Vec<String>,
products: Vec<String>,
imperative_negations: Vec<String>,
finite_negations: Vec<String>,
negations: Vec<String>,
capability_verbs: Vec<String>,
hedges: Vec<String>,
negation_window: usize,
verb_window: usize,
min_clauses: usize,
}
impl C010Sets {
fn subject(&self, toks: &[Tok], i: usize) -> Option<Subject> {
if let Some(w) = toks.get(i) {
if self.products.contains(&w.word) {
return Some(Subject {
span: 1,
key: format!("noun:{}", w.word),
negative: false,
});
}
}
subject_at(toks, i, &self.tool_nouns)
}
fn subject_from_noun(&self, toks: &[Tok], i: usize) -> Option<String> {
let w = &toks.get(i)?.word;
if self.products.contains(w) {
return Some(format!("noun:{w}"));
}
if self.tool_nouns.contains(w) {
return Some(format!("noun:{}", lemma(w, &self.tool_nouns)));
}
None
}
fn negation_end(&self, toks: &[Tok], i: usize, set: &[String]) -> Option<usize> {
set.iter()
.filter(|n| phrase_at(toks, i, n))
.map(|n| i + n.split_whitespace().count())
.max()
}
}
#[derive(PartialEq)]
enum VerbForm {
Base,
Inflected,
Participle,
}
fn verb_form(word: &str) -> VerbForm {
if word.len() > 4 && word.ends_with("ing") {
VerbForm::Participle
} else if is_base_form(word) {
VerbForm::Base
} else {
VerbForm::Inflected
}
}
fn is_base_form(word: &str) -> bool {
if word.len() > 4 && word.ends_with("ing") {
return false;
}
if word.len() > 3 && word.ends_with("ed") {
return false;
}
if word.ends_with('s')
&& !word.ends_with("ss")
&& !word.ends_with("us")
&& !word.ends_with("is")
{
return false;
}
true
}
fn governed_token(toks: &[Tok], from: usize) -> Option<&Tok> {
let at = match toks.get(from) {
Some(t) if t.word.ends_with("ly") => from + 1,
_ => from,
};
toks.get(at)
}
fn capability_verb_in(
toks: &[Tok],
from: usize,
window: usize,
verbs: &[String],
accept: impl Fn(VerbForm) -> bool,
) -> bool {
(from..(from + window).min(toks.len())).any(|i| {
toks.get(i)
.is_some_and(|t| verbs.iter().any(|v| v == &t.word) && accept(verb_form(&t.word)))
})
}
fn read_clause(
text: &str,
segment: std::ops::Range<usize>,
clause: std::ops::Range<usize>,
sets: &C010Sets,
) -> ClauseRead {
let toks = tokenize(text, segment.clone());
let mut out = ClauseRead {
segment: clause_content(text, segment),
clause: clause_content(text, clause),
qualifies: false,
open_complement: false,
subject: None,
affirmative: None,
has_subject: false,
joined_denial: false,
};
let lead = match toks.first().map(|t| t.word.as_str()) {
Some("and" | "or" | "but" | "yet" | "so" | "nor") => 1,
Some(_) => 0,
None => return out,
};
if lead >= toks.len() {
return out;
}
let has_negation = phrase_in(&toks, lead, toks.len(), &sets.negations).is_some();
let has_hedge = phrase_in(&toks, lead, toks.len(), &sets.hedges).is_some();
if let Some(end) = sets.negation_end(&toks, lead, &sets.imperative_negations) {
if governed_token(&toks, end).is_some_and(|t| is_base_form(&t.word)) {
if toks.first().is_some_and(|t| t.word == "and")
&& capability_verb_in(&toks, end, sets.verb_window, &sets.capability_verbs, |f| {
f == VerbForm::Base
})
{
out.joined_denial = true;
}
return out;
}
}
if let Some(subject) = sets.subject(&toks, lead) {
out.has_subject = true;
out.subject = Some(subject.key.clone());
if !subject.negative && !has_negation && !has_hedge {
out.affirmative = Some(subject.key.clone());
}
let after_subject = lead + subject.span;
let (verb_from, window) = if subject.negative {
(Some(after_subject), sets.negation_window)
} else {
(
phrase_in(
&toks,
after_subject,
after_subject + sets.negation_window,
&sets.negations,
)
.and_then(|at| sets.negation_end(&toks, at, &sets.negations)),
sets.verb_window,
)
};
if let Some(from) = verb_from {
if capability_verb_in(&toks, from, window, &sets.capability_verbs, |_| true) {
out.qualifies = true;
return out;
}
}
} else if let Some(end) = sets.negation_end(&toks, lead, &sets.finite_negations) {
if capability_verb_in(&toks, end, sets.verb_window, &sets.capability_verbs, |f| {
f != VerbForm::Participle
}) {
out.qualifies = true;
out.subject = Some("pronoun".to_string());
return out;
}
} else if let Some(end) = sets.negation_end(&toks, lead, &sets.imperative_negations) {
if capability_verb_in(&toks, end, sets.verb_window, &sets.capability_verbs, |f| {
f == VerbForm::Inflected
}) {
out.qualifies = true;
out.subject = Some("pronoun".to_string());
return out;
}
}
if let Some(at) = phrase_in(&toks, lead, toks.len(), &sets.hedges) {
out.qualifies = true;
out.open_complement = true;
if out.subject.is_none() {
let head = phrase_at_any(&toks, at, &sets.hedges).and_then(|(_, m)| m.head);
out.subject = match head {
Some(h) => sets.subject_from_noun(&toks, h),
None => (lead..toks.len())
.find_map(|i| sets.subject(&toks, i))
.map(|s| s.key),
};
}
}
out
}
fn affirmative_subject(
text: &str,
sentence: std::ops::Range<usize>,
sets: &C010Sets,
) -> Option<String> {
let toks = tokenize(text, sentence);
let subject = sets.subject(&toks, 0)?;
if subject.negative {
return None;
}
if phrase_in(&toks, 0, toks.len(), &sets.negations).is_some() {
return None;
}
if phrase_in(&toks, 0, toks.len(), &sets.hedges).is_some() {
return None;
}
Some(subject.key)
}
fn trim_range(text: &str, range: std::ops::Range<usize>) -> std::ops::Range<usize> {
let slice = &text[range.clone()];
let start = range.start + (slice.len() - slice.trim_start().len());
let end = range.start + slice.trim_end().len();
start..end.max(start)
}
pub(crate) fn block_ranges(text: &str) -> Vec<std::ops::Range<usize>> {
let mut out = Vec::new();
let mut start = 0usize;
for (off, c) in text.char_indices() {
if c == '\n' {
out.push(start..off);
start = off + 1;
}
}
out.push(start..text.len());
out
}
fn opens_with_word(text: &str, at: usize, words: &[String]) -> bool {
if words.is_empty() || at >= text.len() {
return false;
}
let rest = &text[at..];
let trimmed = rest.trim_start_matches([' ', '\t']);
if rest.len() - trimmed.len() > 8 {
return false;
}
let end = crate::widen_to_char_boundaries(trimmed, 0..trimmed.len().min(64)).end;
let toks = tokenize(trimmed, 0..end);
toks.first()
.is_some_and(|t| t.start == 0 && words.iter().any(|w| w == &t.word))
}
pub(crate) fn sentence_ranges(
text: &str,
block: std::ops::Range<usize>,
lowercase_openers: &[String],
) -> Vec<std::ops::Range<usize>> {
let mut out = Vec::new();
let mut start = block.start;
for (off, c) in text[block.clone()].char_indices() {
let abs = block.start + off;
let terminal = match c {
'.' => {
period_is_terminal(text, abs + 1)
|| opens_with_word(text, abs + 1, lowercase_openers)
}
'!' | '?' => true,
_ => false,
};
if terminal {
let r = trim_range(text, start..abs + c.len_utf8());
if r.start < r.end {
out.push(r);
}
start = abs + c.len_utf8();
}
}
let r = trim_range(text, start..block.end);
if r.start < r.end {
out.push(r);
}
out
}
fn clause_ranges(text: &str, sentence: std::ops::Range<usize>) -> Vec<std::ops::Range<usize>> {
let mut out = Vec::new();
let mut start = sentence.start;
for (off, c) in text[sentence.clone()].char_indices() {
if c == ',' || c == ';' {
let abs = sentence.start + off;
let r = trim_range(text, start..abs);
if r.start < r.end {
out.push(r);
}
start = abs + c.len_utf8();
}
}
let r = trim_range(text, start..sentence.end);
if r.start < r.end {
out.push(r);
}
out
}
fn clause_content(text: &str, range: std::ops::Range<usize>) -> std::ops::Range<usize> {
const COORDINATORS: &[&str] = &["and", "or", "but", "yet", "so", "nor"];
let toks = tokenize(text, range.clone());
let start = match (toks.first(), toks.get(1)) {
(Some(head), Some(next)) if COORDINATORS.contains(&head.word.as_str()) => next.start,
_ => range.start,
};
let mut end = range.end;
while end > start {
let Some(c) = text[start..end].chars().next_back() else {
break;
};
if c.is_whitespace() || matches!(c, ',' | ';' | '.' | '!' | '?') {
end -= c.len_utf8();
} else {
break;
}
}
start..end.max(start)
}
fn segment_ranges(text: &str, clause: std::ops::Range<usize>) -> Vec<std::ops::Range<usize>> {
const COORDINATORS: &[&str] = &["and", "or", "but", "yet", "so", "nor"];
let toks = tokenize(text, clause.clone());
let mut out = Vec::new();
let mut start = clause.start;
for (i, t) in toks.iter().enumerate() {
if i > 0 && COORDINATORS.contains(&t.word.as_str()) && t.start > start {
let r = trim_range(text, start..t.start);
if r.start < r.end {
out.push(r);
}
start = t.start;
}
}
let r = trim_range(text, start..clause.end);
if r.start < r.end {
out.push(r);
}
out
}
fn evaluate_c010(
cp: &CompiledPolicy,
prepared: &Prepared,
norm: &NormView,
config: &Config,
hits: &mut Vec<Hit>,
) {
let Some(idx) = super::active(cp, config, "SLOP-C010") else {
return;
};
let rule = &cp.pkg.rules[idx];
let sets = C010Sets {
tool_nouns: str_list(rule, "tool_nouns"),
products: str_list(rule, "product_names"),
imperative_negations: str_list(rule, "imperative_negations"),
finite_negations: str_list(rule, "finite_negations"),
negations: {
let mut all = str_list(rule, "imperative_negations");
all.extend(str_list(rule, "finite_negations"));
all
},
capability_verbs: str_list(rule, "capability_verbs"),
hedges: str_list(rule, "hedge_markers"),
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,
min_clauses: super::param_i64(rule, "min_clauses").unwrap_or(2) as usize,
};
let text = norm.text.as_str();
let src = prepared.text.as_str();
for block in block_ranges(text) {
if block.start >= block.end {
continue;
}
let sentences = sentence_ranges(text, block, &sets.products);
let mut reads: Vec<Vec<ClauseRead>> = Vec::with_capacity(sentences.len());
for s in &sentences {
let mut segs = Vec::new();
for clause in clause_ranges(text, s.clone()) {
for segment in segment_ranges(text, clause.clone()) {
segs.push(read_clause(text, segment, clause.clone(), &sets));
}
}
let mut named = false;
for seg in &mut segs {
if seg.joined_denial && named {
seg.qualifies = true;
seg.subject = Some("pronoun".to_string());
}
named |= seg.has_subject;
}
reads.push(segs);
}
let qualifying: Vec<(usize, &ClauseRead)> = reads
.iter()
.enumerate()
.flat_map(|(si, cs)| cs.iter().filter(|c| c.qualifies).map(move |c| (si, c)))
.collect();
let mut reported: Vec<(std::ops::Range<usize>, String)> = Vec::new();
if qualifying.len() >= sets.min_clauses {
for (_, clause) in &qualifying {
reported.push((
clause.report_range(),
format!(
"arm A, one of {} denied capabilities in this block",
qualifying.len()
),
));
}
} else if qualifying.len() == 1 {
let (si, clause) = qualifying[0];
let Some(subject) = clause.subject.clone() else {
continue;
};
let within = reads[si].iter().any(|other| {
!std::ptr::eq(other, clause)
&& other
.affirmative
.as_deref()
.is_some_and(|k| same_referent(k, &subject))
});
let mut partner =
within.then_some("arm B, beside an affirmative clause of its own sentence");
if partner.is_none() {
for n in [si.checked_sub(1), si.checked_add(1)].into_iter().flatten() {
let Some(s) = sentences.get(n) else {
continue;
};
if affirmative_subject(text, s.clone(), &sets)
.is_some_and(|k| same_referent(&k, &subject))
{
partner =
Some("arm B, beside an affirmative description of the same subject");
break;
}
}
}
let Some(detail) = partner else {
continue;
};
reported.push((clause.report_range(), detail.to_string()));
}
for (span, detail) in reported {
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());
hit.detail = Some(detail);
hits.push(hit);
}
}
}