use crate::finding::{Finding, Severity};
use crate::instance::Outcome;
use crate::scope::{DocumentCheck, DocumentView};
use crate::shape::Shape;
use headwater_doc::Sentence;
pub const RULE: &str = "voice.forbidden_construction";
struct Category {
name: &'static str,
instead: &'static str,
patterns: &'static [&'static str],
}
const CATEGORIES: [Category; 3] = [
Category {
name: "future_intent",
instead:
"state what the system does now, or move the sentence to a document whose kind narrates",
patterns: &[
"will be",
"will become",
"will then",
"will eventually",
"will later",
"is planned",
"are planned",
"we plan to",
"in the future",
"at a later date",
"to be added",
"to be decided",
"coming soon",
"for a future release",
],
},
Category {
name: "change_narration",
instead: "state the position that holds, and leave the change to the decision that made it",
patterns: &[
"we moved from",
"we changed",
"we renamed",
"we replaced",
"used to be",
"used to have",
"used to",
"was renamed",
"were renamed",
"has been renamed",
"as before",
"unlike before",
"in the old",
"the previous version",
"an earlier version",
"there was no",
"there were no",
"did not exist",
"has since",
"formerly",
"no longer",
"now that",
"previously",
],
},
Category {
name: "phased_rollout",
instead: "state what holds, and record the sequence in the plan that owns it",
patterns: &[
"phase one",
"phase two",
"first phase",
"second phase",
"in a later phase",
"for now",
"at first",
"to begin with",
"in the first release",
"in a later release",
"rolls out",
"rolled out in",
"rollout of",
],
},
];
pub struct Voice {
bound: Vec<Bound>,
}
struct Bound {
kind: String,
regime: String,
known: Vec<usize>,
unknown: Vec<String>,
}
impl Voice {
pub fn over(shape: &Shape) -> Self {
let mut bound = Vec::new();
for kind in &shape.kinds {
let Some(regime) = shape.voice_of(&kind.name) else {
continue;
};
if regime.forbid.is_empty() {
continue;
}
let mut known = Vec::new();
let mut unknown = Vec::new();
for name in ®ime.forbid {
match CATEGORIES
.iter()
.position(|category| category.name == name.as_str())
{
Some(index) => known.push(index),
None => unknown.push(name.clone()),
}
}
bound.push(Bound {
kind: kind.name.clone(),
regime: regime.name.clone(),
known,
unknown,
});
}
Voice { bound }
}
fn bound_to(&self, kind: &str) -> Option<&Bound> {
self.bound.iter().find(|bound| bound.kind == kind)
}
}
impl DocumentCheck for Voice {
const RULE: &'static str = self::RULE;
const VERSION: u32 = 2;
const NEEDS_BODY: bool = true;
fn instantiates(&self, kind: &str) -> bool {
self.bound_to(kind).is_some()
}
fn evaluate(&self, view: &DocumentView<'_>) -> Outcome {
let Some(bound) = self.bound_to(view.kind()) else {
return Outcome::Passed;
};
if bound.known.is_empty() {
return Outcome::Skipped(format!(
"`{}` forbids {}, and this engine has no pattern set for {}",
bound.regime,
bound.unknown.join(", "),
match bound.unknown.len() {
1 => "it",
_ => "any of them",
}
));
}
let Some(body) = view.body() else {
return Outcome::Passed;
};
let sentences = body.sentences();
let mut findings = Vec::new();
for index in &bound.known {
let category = &CATEGORIES[*index];
for sentence in &sentences {
let Some(pattern) = matched(category, sentence) else {
continue;
};
findings.push(Finding {
rule: self::RULE,
severity: Severity::Warn,
obligation: None,
path: view.path().to_string(),
line: sentence.span.start.line,
column: sentence.span.start.col,
message: format!(
"`{}` forbids {}, and this sentence writes `{pattern}`",
bound.regime, category.name
),
remediation: category.instead.to_string(),
patch: None,
});
}
}
findings.sort_by_key(|finding| (finding.line, finding.column));
Outcome::failed(findings)
}
}
fn matched(category: &Category, sentence: &Sentence) -> Option<&'static str> {
let text = sentence.authored.to_lowercase();
category
.patterns
.iter()
.copied()
.find(|pattern| contains_word(&text, pattern))
}
pub(crate) fn contains_word(text: &str, pattern: &str) -> bool {
word_at(text, pattern).is_some()
}
pub(crate) fn word_at(text: &str, pattern: &str) -> Option<usize> {
if pattern.is_empty() {
return None;
}
let mut from = 0usize;
while let Some(offset) = text.get(from..)?.find(pattern) {
let at = from + offset;
let end = at + pattern.len();
let before = text[..at].chars().next_back().is_none_or(|c| !is_word(c));
let after = text[end..].chars().next().is_none_or(|c| !is_word(c));
if before && after {
return Some(at);
}
from = end;
}
None
}
fn is_word(c: char) -> bool {
c.is_alphanumeric() || c == '-'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pattern_matches_at_word_boundaries_only() {
assert!(contains_word("the rule will be read", "will be"));
assert!(!contains_word("a goodwill better than", "will be"));
assert!(!contains_word("at first-order logic", "at first"));
assert!(contains_word("at first, it reads", "at first"));
}
#[test]
fn edition_two_reports_the_shapes_edition_one_missed() {
let narration = &CATEGORIES[1];
assert_eq!(narration.name, "change_narration");
for sentence in [
"until the ruling there was no machine form of this verb",
"the emitter writes a routine that did not exist before",
"the table that the entry used to carry decided it on the wrong axis",
"that script has since retired into the check layer",
"an earlier version of the entry named a second field",
"the field is no longer read",
"the register was formerly a single file",
] {
assert!(
narration
.patterns
.iter()
.any(|pattern| contains_word(sentence, pattern)),
"no pattern matched `{sentence}`"
);
}
}
#[test]
fn the_rejected_candidates_stay_out() {
let narration = &CATEGORIES[1];
for sentence in [
"the british spelling and the retired term are warnings",
"evidence that an author found a reference worth making at one point in a text",
"every inline quotation of five characters or more was replaced by one word",
] {
assert!(
!narration
.patterns
.iter()
.any(|pattern| contains_word(sentence, pattern)),
"a pattern matched `{sentence}`, which is not change narration"
);
}
}
}