use crate::extract::Doc;
use crate::input::Prepared;
use crate::policy::{self, MatchKindSpec, PolicyPackage, Scope, View};
use crate::views::NormView;
use crate::{AnalysisError, Config, Stance};
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind as AcMatchKind};
use regex_automata::hybrid::dfa::{Cache, OverlappingState, DFA};
use regex_automata::nfa::thompson;
use regex_automata::util::syntax;
use regex_automata::{Anchored, Input, MatchKind, PatternID};
use std::collections::HashSet;
use std::ops::Range;
use std::sync::OnceLock;
#[derive(Debug, Clone)]
pub struct Hit {
pub rule: usize,
pub span: Range<usize>,
pub quoted: bool,
pub force_candidate: bool,
pub force_hint: bool,
pub trigger: Option<String>,
pub decoded: Option<String>,
pub detail: Option<String>,
}
impl Hit {
pub fn new(rule: usize, span: Range<usize>) -> Hit {
Hit {
rule,
span,
quoted: false,
force_candidate: false,
force_hint: false,
trigger: None,
decoded: None,
detail: None,
}
}
}
struct RxMeta {
rule: usize,
trim_start: usize,
trim_end: usize,
bound_start: bool,
bound_end: bool,
max_width: Option<usize>,
}
pub struct CompiledPolicy {
pub pkg: PolicyPackage,
ac_ci: AhoCorasick,
ac_ci_meta: Vec<usize>,
ac_cs: AhoCorasick,
ac_cs_meta: Vec<usize>,
rx_fwd: DFA,
rx_rev: DFA,
rx_meta: Vec<RxMeta>,
}
pub struct RxCaches {
fwd: Cache,
rev: Cache,
}
impl CompiledPolicy {
pub fn caches(&self) -> RxCaches {
RxCaches {
fwd: self.rx_fwd.create_cache(),
rev: self.rx_rev.create_cache(),
}
}
}
static COMPILED: OnceLock<Result<CompiledPolicy, String>> = OnceLock::new();
pub fn compiled() -> Result<&'static CompiledPolicy, String> {
COMPILED.get_or_init(build).as_ref().map_err(|e| e.clone())
}
fn rewrite_pattern(p: &str) -> Result<(String, usize, usize, bool, bool), String> {
let mut pat = p.to_string();
let mut trim_start = 0usize;
let mut trim_end = 0usize;
if let Some(rest) = pat.strip_prefix(r"(?<=\w)") {
pat = format!(r"\w{rest}");
trim_start = 1;
}
if let Some(rest) = pat.strip_suffix(r"(?=\w)") {
pat = format!(r"{rest}\w");
trim_end = 1;
}
if pat.contains("(?<=") || pat.contains("(?<!") || pat.contains("(?!") {
return Err(format!("unsupported look-around in pattern {p}"));
}
if pat.contains("(?=") {
return Err(format!("unsupported look-ahead in pattern {p}"));
}
let bound_start = pat.strip_prefix("(?i)").unwrap_or(&pat).starts_with(r"\b");
let bound_end = pat.ends_with(r"\b");
let pat = pat.replace(r"\b", r"(?-u:\b)");
Ok((pat, trim_start, trim_end, bound_start, bound_end))
}
fn validate_bounded_width(pat: &str) -> Result<Option<usize>, String> {
let hir = regex_syntax::parse(pat)
.map_err(|e| format!("pattern {pat} failed width-validation parse: {e}"))?;
if unbounded_repetition(&hir) {
return Err(format!(
"pattern {pat} has an unbounded-width quantifier (*, +, or {{n,}}); \
bounded forms are required, whitespace included \
(e.g. {{0,120}} for text, \\s{{1,8}} for whitespace)"
));
}
Ok(hir.properties().maximum_len())
}
fn unbounded_repetition(hir: ®ex_syntax::hir::Hir) -> bool {
use regex_syntax::hir::HirKind;
match hir.kind() {
HirKind::Repetition(rep) => rep.max.is_none() || unbounded_repetition(&rep.sub),
HirKind::Capture(c) => unbounded_repetition(&c.sub),
HirKind::Concat(v) | HirKind::Alternation(v) => v.iter().any(unbounded_repetition),
_ => false,
}
}
fn build() -> Result<CompiledPolicy, String> {
let pkg = policy::load()?;
let mut ci_pats: Vec<String> = Vec::new();
let mut ci_meta: Vec<usize> = Vec::new();
let mut cs_pats: Vec<String> = Vec::new();
let mut cs_meta: Vec<usize> = Vec::new();
let mut rx_pats: Vec<String> = Vec::new();
let mut rx_meta: Vec<RxMeta> = Vec::new();
for (idx, rule) in pkg.rules.iter().enumerate() {
if rule.lifecycle == policy::Lifecycle::Deprecated {
continue;
}
if rule.kind == MatchKindSpec::WordSet {
for term in &rule.terms {
if term.is_empty() {
return Err(format!("rule {} has an empty term", rule.id));
}
if rule.case_sensitive {
cs_pats.push(term.clone());
cs_meta.push(idx);
} else {
ci_pats.push(term.clone());
ci_meta.push(idx);
}
}
}
for p in &rule.patterns {
let (pat, ts, te, bs, be) = rewrite_pattern(p)?;
let max_width = validate_bounded_width(&pat)?;
rx_pats.push(pat);
rx_meta.push(RxMeta {
rule: idx,
trim_start: ts,
trim_end: te,
bound_start: bs,
bound_end: be,
max_width,
});
}
}
let ac_ci = AhoCorasickBuilder::new()
.match_kind(AcMatchKind::Standard)
.ascii_case_insensitive(true)
.build(&ci_pats)
.map_err(|e| format!("case-insensitive automaton: {e}"))?;
let ac_cs = AhoCorasickBuilder::new()
.match_kind(AcMatchKind::Standard)
.build(&cs_pats)
.map_err(|e| format!("case-sensitive automaton: {e}"))?;
let syn = syntax::Config::new()
.unicode(true)
.utf8(true)
.multi_line(true);
let fwd = DFA::builder()
.configure(
DFA::config()
.match_kind(MatchKind::All)
.starts_for_each_pattern(true)
.cache_capacity(4 * 1024 * 1024),
)
.syntax(syn)
.build_many(&rx_pats)
.map_err(|e| format!("forward dfa: {e}"))?;
let rev = DFA::builder()
.configure(
DFA::config()
.match_kind(MatchKind::All)
.starts_for_each_pattern(true)
.cache_capacity(4 * 1024 * 1024),
)
.thompson(thompson::Config::new().reverse(true))
.syntax(syn)
.build_many(&rx_pats)
.map_err(|e| format!("reverse dfa: {e}"))?;
let mut probe_cache = fwd.create_cache();
for (pid, pat) in rx_pats.iter().enumerate() {
let input = Input::new("").anchored(Anchored::Pattern(PatternID::new_unchecked(pid)));
if let Ok(Some(_)) = fwd.try_search_fwd(&mut probe_cache, &input) {
return Err(format!("pattern {pat} can match empty"));
}
}
Ok(CompiledPolicy {
pkg,
ac_ci,
ac_ci_meta: ci_meta,
ac_cs,
ac_cs_meta: cs_meta,
rx_fwd: fwd,
rx_rev: rev,
rx_meta,
})
}
fn word_bounded(hay: &str, span: &Range<usize>) -> bool {
let before_ok = hay[..span.start]
.chars()
.next_back()
.map(|c| !unicode_ident::is_xid_continue(c))
.unwrap_or(true);
let after_ok = hay[span.end..]
.chars()
.next()
.map(|c| !unicode_ident::is_xid_continue(c))
.unwrap_or(true);
before_ok && after_ok
}
fn unicode_word_boundary(hay: &str, at: usize) -> bool {
let before = hay[..at]
.chars()
.next_back()
.map(unicode_ident::is_xid_continue)
.unwrap_or(false);
let after = hay[at..]
.chars()
.next()
.map(unicode_ident::is_xid_continue)
.unwrap_or(false);
before != after
}
fn exempted(hay: &str, span: &Range<usize>, phrases: &[String]) -> bool {
if phrases.is_empty() {
return false;
}
let win_start =
crate::widen_to_char_boundaries(hay, span.start.saturating_sub(60)..span.start).start;
let win_end =
crate::widen_to_char_boundaries(hay, span.end..(span.end + 60).min(hay.len())).end;
let window = hay[win_start..win_end].to_lowercase();
let rel_start = hay[win_start..span.start].to_lowercase().len();
let rel_end = rel_start + hay[span.start..span.end].to_lowercase().len();
for phrase in phrases {
let mut at = 0usize;
while let Some(pos) = window[at..].find(phrase.as_str()) {
let s = at + pos;
let e = s + phrase.len();
if s <= rel_start && e >= rel_end {
return true;
}
at = s + 1;
}
}
false
}
fn participial_adjective(hay: &str, span: &Range<usize>) -> bool {
if !hay[span.clone()].to_ascii_lowercase().ends_with("ed") {
return false;
}
let before = &hay[..span.start];
let mut rev = before.chars().rev();
if let (Some('-'), Some(c)) = (rev.next(), rev.next()) {
if unicode_ident::is_xid_continue(c) {
return true;
}
}
const DETERMINERS: &[&str] = &[
"the", "a", "an", "its", "our", "their", "his", "her", "my", "your", "this", "that",
"these", "those",
];
let prev = before
.split(|c: char| !c.is_alphanumeric() && c != '\'')
.rfind(|t| !t.is_empty())
.unwrap_or("")
.to_ascii_lowercase();
DETERMINERS.contains(&prev.as_str()) || (prev.len() > 3 && prev.ends_with("ly"))
}
fn while_clause(matched: &str) -> &str {
let after = matched.trim_start()[5..].trim_start();
match after.find(',') {
Some(at) => &after[..at],
None => after,
}
}
fn clause_words(clause: &str) -> Vec<String> {
clause
.split_whitespace()
.map(|w| {
w.trim_matches(|c: char| !c.is_alphanumeric())
.to_ascii_lowercase()
})
.collect()
}
fn carries_progressive(clause: &str) -> bool {
const BE: &[&str] = &["am", "is", "are", "was", "were", "be", "being"];
const SKIPPABLE: &[&str] = &[
"still",
"already",
"yet",
"now",
"just",
"also",
"again",
"always",
"currently",
"never",
];
let words = clause_words(clause);
let ing = |w: &String| w.len() > 3 && w.ends_with("ing");
let adverb = |w: &String| w.ends_with("ly") || SKIPPABLE.contains(&w.as_str());
words.iter().enumerate().any(|(i, w)| {
BE.contains(&w.as_str())
&& (words.get(i + 1).is_some_and(ing)
|| (words.get(i + 1).is_some_and(adverb) && words.get(i + 2).is_some_and(ing)))
})
}
const FINITE_FORMS: &[&str] = &[
"am", "is", "are", "was", "were", "has", "have", "had", "do", "does", "did", "can", "could",
"will", "would", "shall", "should", "may", "might", "must",
];
fn participial_while(clause: &str) -> bool {
const CONCESSION: &[&str] = &[
"acknowledging",
"recognizing",
"granting",
"accepting",
"conceding",
"admitting",
"noting",
"allowing",
];
let word = crate::rules::contrast::leading_word(clause);
if !crate::rules::contrast::reads_as_participle(&word) || CONCESSION.contains(&word.as_str()) {
return false;
}
!clause_words(clause)
.iter()
.any(|w| FINITE_FORMS.contains(&w.as_str()))
}
fn cjk_present(s: &str) -> bool {
s.chars().any(|c| {
let u = c as u32;
(0x3040..=0x30FF).contains(&u)
|| (0x3400..=0x4DBF).contains(&u)
|| (0x4E00..=0x9FFF).contains(&u)
|| (0xAC00..=0xD7AF).contains(&u)
|| (0xF900..=0xFAFF).contains(&u)
})
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ScanCtx {
Norm,
RawAll,
RawProse,
LinkUrl,
Code,
Heading,
Comment,
}
fn rule_in_ctx(rule: &policy::Rule, ctx: ScanCtx) -> bool {
match ctx {
ScanCtx::Norm => rule.view == View::Norm && rule.scope == Scope::None,
ScanCtx::RawAll => rule.view == View::Raw && rule.scope == Scope::None,
ScanCtx::RawProse => rule.view == View::Prose && rule.scope == Scope::None,
ScanCtx::LinkUrl => rule.scope == Scope::LinkUrl || rule.id == "SLOP-P002",
ScanCtx::Code => rule.scope == Scope::Code,
ScanCtx::Heading => rule.scope == Scope::Heading,
ScanCtx::Comment => rule.scope == Scope::Comment,
}
}
pub fn scan_all(
cp: &CompiledPolicy,
prepared: &Prepared,
doc: &Doc,
norm: &NormView,
config: &Config,
) -> Result<Vec<Hit>, AnalysisError> {
let src = prepared.text.as_str();
let mut hits: Vec<Hit> = Vec::new();
let mut caches = cp.caches();
scan_ac_haystack(
cp,
config,
norm.text.as_str(),
None,
ScanCtx::Norm,
Some(norm),
src,
&mut hits,
);
if !src.is_empty() {
scan_ac_haystack(cp, config, src, None, ScanCtx::RawAll, None, src, &mut hits);
}
for r in &doc.link_url_regions {
scan_ac_haystack(
cp,
config,
src,
Some(r.clone()),
ScanCtx::LinkUrl,
None,
src,
&mut hits,
);
}
for (r, _flags) in &doc.prose_regions {
scan_ac_cs(cp, config, src, r.clone(), &mut hits);
}
for r in &doc.link_url_regions {
scan_ac_cs(cp, config, src, r.clone(), &mut hits);
}
for c in &doc.html_comments {
scan_ac_comment(cp, config, src, c, &mut hits);
}
scan_rx(
cp,
&mut caches,
config,
norm.text.as_str(),
0..norm.text.len(),
ScanCtx::Norm,
Some(norm),
src,
&mut hits,
)?;
scan_rx(
cp,
&mut caches,
config,
src,
0..src.len(),
ScanCtx::RawAll,
None,
src,
&mut hits,
)?;
for (r, _flags) in &doc.prose_regions {
let mut sub = Vec::new();
scan_rx(
cp,
&mut caches,
config,
src,
r.clone(),
ScanCtx::RawProse,
None,
src,
&mut sub,
)?;
hits.extend(sub);
}
for r in &doc.link_url_regions {
scan_rx(
cp,
&mut caches,
config,
src,
r.clone(),
ScanCtx::LinkUrl,
None,
src,
&mut hits,
)?;
}
for (r, decoded) in &doc.link_url_decoded {
scan_link_url_decoded(cp, &mut caches, config, src, r, decoded, &mut hits)?;
}
for code in &doc.code_regions {
if code.fenced && is_text_info(&code.info) {
continue;
}
scan_rx(
cp,
&mut caches,
config,
src,
code.range.clone(),
ScanCtx::Code,
None,
src,
&mut hits,
)?;
}
for h in &doc.headings {
if h.text_range.start >= h.text_range.end {
continue;
}
let hay = &src[h.text_range.clone()];
let mut sub = Vec::new();
scan_rx(
cp,
&mut caches,
config,
hay,
0..hay.len(),
ScanCtx::Heading,
None,
src,
&mut sub,
)?;
for mut hit in sub {
hit.span.start += h.text_range.start;
hit.span.end += h.text_range.start;
hits.push(hit);
}
}
resolve_overlaps(&mut hits);
Ok(hits)
}
fn is_text_info(info: &str) -> bool {
let lang = info.split_whitespace().next().unwrap_or("");
matches!(
lang,
"text" | "txt" | "plain" | "plaintext" | "md" | "markdown" | "prose"
)
}
#[allow(clippy::too_many_arguments)]
fn scan_ac_haystack(
cp: &CompiledPolicy,
config: &Config,
hay: &str,
region: Option<Range<usize>>,
ctx: ScanCtx,
norm: Option<&NormView>,
src: &str,
hits: &mut Vec<Hit>,
) {
let input = match ®ion {
Some(r) => aho_corasick::Input::new(hay).range(r.clone()),
None => aho_corasick::Input::new(hay),
};
for m in cp.ac_ci.find_overlapping_iter(input) {
let rule_idx = cp.ac_ci_meta[m.pattern().as_usize()];
let rule = &cp.pkg.rules[rule_idx];
if !rule_in_ctx(rule, ctx) {
continue;
}
let span = m.start()..m.end();
accept_word_hit(cp, config, rule_idx, hay, span, ctx, norm, src, hits);
}
}
fn scan_ac_cs(
cp: &CompiledPolicy,
config: &Config,
src: &str,
region: Range<usize>,
hits: &mut Vec<Hit>,
) {
let input = aho_corasick::Input::new(src).range(region);
for m in cp.ac_cs.find_overlapping_iter(input) {
let rule_idx = cp.ac_cs_meta[m.pattern().as_usize()];
let rule = &cp.pkg.rules[rule_idx];
if rule.view != View::Prose && rule.scope != Scope::LinkUrl {
continue;
}
let span = m.start()..m.end();
if rule.boundary_word && !word_bounded(src, &span) {
continue;
}
if rule.stance(config.profile) == Stance::Off {
continue;
}
hits.push(Hit::new(rule_idx, span));
}
}
fn scan_ac_comment(
cp: &CompiledPolicy,
config: &Config,
src: &str,
comment: &crate::extract::HtmlComment,
hits: &mut Vec<Hit>,
) {
let hay = &src[comment.content_range.clone()];
for m in cp
.ac_ci
.find_overlapping_iter(aho_corasick::Input::new(hay))
{
let rule_idx = cp.ac_ci_meta[m.pattern().as_usize()];
let rule = &cp.pkg.rules[rule_idx];
if !rule_in_ctx(rule, ScanCtx::Comment) {
continue;
}
if rule.stance(config.profile) == Stance::Off {
continue;
}
let span = comment.content_range.start + m.start()..comment.content_range.start + m.end();
hits.push(Hit::new(rule_idx, span));
}
}
#[allow(clippy::too_many_arguments)]
fn accept_word_hit(
cp: &CompiledPolicy,
config: &Config,
rule_idx: usize,
hay: &str,
span: Range<usize>,
ctx: ScanCtx,
norm: Option<&NormView>,
src: &str,
hits: &mut Vec<Hit>,
) {
let rule = &cp.pkg.rules[rule_idx];
let quoted = match (ctx, norm) {
(ScanCtx::Norm, Some(n)) => n.all_quoted(&span),
_ => false,
};
if rule.stance(config.profile) == Stance::Off {
return;
}
if rule.boundary_word && !word_bounded(hay, &span) {
return;
}
if rule.block_start {
if let Some(n) = norm {
if !n.is_block_start(span.start) {
return;
}
}
}
if let Some(anchored) = rule
.params
.as_table()
.and_then(|t| t.get("block_start_only"))
.and_then(|v| v.as_array())
{
let matched = hay[span.clone()].to_lowercase();
if anchored
.iter()
.filter_map(|v| v.as_str())
.any(|e| e == matched)
{
match norm {
Some(n) if n.is_block_start(span.start) => {}
_ => return,
}
}
}
if rule.id == "SLOP-T001" {
let matched = hay[span.clone()].to_ascii_lowercase();
if matched == "overall" {
match norm {
Some(n) if n.is_block_start(span.start) => {}
_ => return,
}
}
}
if exempted(hay, &span, &rule.exemptions) {
return;
}
if rule.id == "SLOP-A002" && participial_adjective(hay, &span) {
return;
}
let source_span = match (ctx, norm) {
(ScanCtx::Norm, Some(n)) => match n.to_source(span.clone()) {
Some(s) => crate::widen_to_char_boundaries(src, s),
None => return,
},
_ => crate::widen_to_char_boundaries(hay, span.clone()),
};
if source_span.start >= source_span.end {
return;
}
let mut hit = Hit::new(rule_idx, source_span);
hit.quoted = quoted;
if let (ScanCtx::Norm, Some(n)) = (ctx, norm) {
if n.span_has_flag(&span, crate::extract::F_FULL_FOLD) {
hit.force_candidate = true;
}
}
hit.trigger = Some(hay[span].to_string());
hits.push(hit);
}
fn scan_link_url_decoded(
cp: &CompiledPolicy,
caches: &mut RxCaches,
config: &Config,
src: &str,
region: &Range<usize>,
decoded: &str,
hits: &mut Vec<Hit>,
) -> Result<(), AnalysisError> {
if region.start >= region.end || decoded.is_empty() {
return Ok(());
}
let mut sub = Vec::new();
scan_ac_haystack(
cp,
config,
decoded,
None,
ScanCtx::LinkUrl,
None,
decoded,
&mut sub,
);
scan_ac_cs(cp, config, decoded, 0..decoded.len(), &mut sub);
scan_rx(
cp,
caches,
config,
decoded,
0..decoded.len(),
ScanCtx::LinkUrl,
None,
decoded,
&mut sub,
)?;
let raw = &src[region.clone()];
for mut hit in sub {
let trigger = match &hit.trigger {
Some(t) => t.clone(),
None => match decoded.get(hit.span.clone()) {
Some(t) => t.to_string(),
None => continue,
},
};
hit.span = match raw.find(&trigger) {
Some(p) => region.start + p..region.start + p + trigger.len(),
None => region.clone(),
};
hit.trigger = Some(trigger);
hit.decoded = Some(decoded.to_string());
hits.push(hit);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn scan_rx(
cp: &CompiledPolicy,
caches: &mut RxCaches,
config: &Config,
hay: &str,
region: Range<usize>,
ctx: ScanCtx,
norm: Option<&NormView>,
src: &str,
hits: &mut Vec<Hit>,
) -> Result<(), AnalysisError> {
if region.start >= region.end || cp.rx_meta.is_empty() {
return Ok(());
}
let input = Input::new(hay).range(region.clone());
let mut state = OverlappingState::start();
let mut seen: HashSet<(usize, usize, usize)> = HashSet::new();
loop {
cp.rx_fwd
.try_search_overlapping_fwd(&mut caches.fwd, &input, &mut state)
.map_err(|e| AnalysisError::Instrumentation(format!("forward dfa search: {e}")))?;
let Some(hm) = state.get_match() else { break };
let pid = hm.pattern();
let end = hm.offset();
let meta = &cp.rx_meta[pid.as_usize()];
let rule = &cp.pkg.rules[meta.rule];
if !rule_in_ctx(rule, ctx) {
continue;
}
let rev_lo = match meta.max_width {
Some(w) => region.start.max(end.saturating_sub(w)),
None => region.start,
};
let rin = Input::new(hay)
.range(rev_lo..end)
.anchored(Anchored::Pattern(pid));
let start = match cp
.rx_rev
.try_search_rev(&mut caches.rev, &rin)
.map_err(|e| AnalysisError::Instrumentation(format!("reverse dfa search: {e}")))?
{
Some(h) => h.offset(),
None => continue,
};
if !seen.insert((pid.as_usize(), start, end)) {
continue;
}
let mut mstart = start;
let mut mend = end;
if meta.trim_start > 0 {
match hay[mstart..mend].chars().next() {
Some(c) if unicode_ident::is_xid_continue(c) => mstart += c.len_utf8(),
_ => continue,
}
}
if meta.trim_end > 0 {
match hay[mstart..mend].chars().next_back() {
Some(c) if unicode_ident::is_xid_continue(c) => mend -= c.len_utf8(),
_ => continue,
}
}
let mut span = mstart..mend;
if span.start >= span.end {
continue;
}
span = crate::widen_to_char_boundaries(hay, span);
if (meta.bound_start && !unicode_word_boundary(hay, span.start))
|| (meta.bound_end && !unicode_word_boundary(hay, span.end))
{
continue;
}
match rule.id.as_str() {
"SLOP-P003" => {
if hay[span.clone()].starts_with('\u{3010}') && cjk_present(&hay[span.clone()]) {
continue;
}
if hay[span.clone()].starts_with(['†', '‡']) {
let ls = hay[..span.start].rfind('\n').map(|p| p + 1).unwrap_or(0);
if hay[ls..span.start].trim().is_empty() {
continue;
}
}
}
"SLOP-M004" => {
let m = &hay[span.clone()];
let is_zero_width = m.chars().all(|c| {
matches!(
c,
'\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}'
)
});
if is_zero_width {
let before = hay[..span.start].chars().next_back();
let after = hay[span.end..].chars().next();
let inside_word = before.map(unicode_ident::is_xid_continue).unwrap_or(false)
&& after.map(unicode_ident::is_xid_continue).unwrap_or(false);
if !inside_word {
continue;
}
}
}
"SLOP-C004" => {
let matched = &hay[span.clone()];
let opens_on_while = matched
.trim_start()
.get(..5)
.is_some_and(|w| w.eq_ignore_ascii_case("while"));
if opens_on_while {
let clause = while_clause(matched);
if carries_progressive(clause) || participial_while(clause) {
continue;
}
}
let first = hay[span.clone()].chars().next();
if matches!(first, Some('.' | '!' | '?')) {
let before = &hay[..span.start];
if before
.chars()
.next_back()
.is_some_and(|c| c.is_ascii_digit())
{
let line = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
if before[line..]
.trim_start()
.chars()
.all(|c| c.is_ascii_digit())
{
continue;
}
}
if first == Some('.')
&& !crate::rules::contrast::period_is_terminal(hay, span.start + 1)
{
continue;
}
let mark = first.map(char::len_utf8).unwrap_or(1);
let gap: usize = hay[span.start + mark..span.end]
.chars()
.take_while(|c| c.is_whitespace())
.map(char::len_utf8)
.sum();
span.start += mark + gap;
if span.start >= span.end {
continue;
}
}
}
"SLOP-E002" => {
let prefix = hay[..span.start].trim_end();
if prefix.ends_with("MUST")
|| prefix.ends_with("SHOULD")
|| prefix.ends_with("SHALL")
|| prefix.ends_with("MAY")
{
continue;
}
}
_ => {}
}
let quoted = match (ctx, norm) {
(ScanCtx::Norm, Some(n)) => n.all_quoted(&span),
_ => false,
};
if rule.stance(config.profile) == Stance::Off {
continue;
}
let source_span = match (ctx, norm) {
(ScanCtx::Norm, Some(n)) => match n.to_source(span.clone()) {
Some(s) => crate::widen_to_char_boundaries(src, s),
None => continue,
},
_ => crate::widen_to_char_boundaries(hay, span.clone()),
};
if source_span.start >= source_span.end {
continue;
}
let mut hit = Hit::new(meta.rule, source_span);
hit.quoted = quoted;
hit.trigger = Some(hay[span].to_string());
hits.push(hit);
}
Ok(())
}
pub fn resolve_overlaps(hits: &mut Vec<Hit>) {
hits.sort_by(|a, b| {
(a.rule, a.span.start, std::cmp::Reverse(a.span.end)).cmp(&(
b.rule,
b.span.start,
std::cmp::Reverse(b.span.end),
))
});
let mut out: Vec<Hit> = Vec::new();
for h in hits.drain(..) {
if let Some(prev) = out.last() {
if prev.rule == h.rule && h.span.start >= prev.span.start && h.span.end <= prev.span.end
{
continue;
}
}
out.push(h);
}
*hits = out;
}
#[cfg(test)]
mod bounded_width_tests {
use super::validate_bounded_width;
#[test]
fn unbounded_trailing_whitespace_is_rejected() {
assert!(
validate_bounded_width(r"\?\s+").is_err(),
"trailing \\s+ must be rejected"
);
assert!(
validate_bounded_width(r"foo\s*").is_err(),
"trailing \\s* must be rejected"
);
assert!(
validate_bounded_width(r"a\s+b").is_err(),
"interior \\s+ must be rejected"
);
}
#[test]
fn unbounded_nonwhitespace_is_still_rejected() {
assert!(validate_bounded_width(r"\d+").is_err());
assert!(validate_bounded_width(r".*").is_err());
assert!(validate_bounded_width(r"[^.]*").is_err());
}
#[test]
fn bounded_whitespace_and_text_are_accepted() {
assert!(matches!(validate_bounded_width(r"\?\s{1,8}"), Ok(Some(_))));
assert!(matches!(
validate_bounded_width(r"foo\s{0,8}bar"),
Ok(Some(_))
));
assert!(matches!(
validate_bounded_width(r"[^.\r\n]{0,120}"),
Ok(Some(_))
));
assert!(matches!(validate_bounded_width(r"a\sb"), Ok(Some(_))));
}
}