use crate::finding::{Finding, Severity};
use crate::instance::Outcome;
use crate::scope::{DocumentCheck, DocumentView, OutsideCheck};
use crate::shape::{LanguageRegime, Shape};
use headwater_doc::body::BlockKind;
pub const RULE: &str = "language.controlled.not_met";
const MAX_WORDS: usize = 25;
const HOUSE: [(&str, Option<&str>); 2] = [("ASD-STE100", Some("house")), ("ste-house", None)];
const SPELLINGS: [(&str, &str); 24] = [
("organisation", "organization"),
("organisations", "organizations"),
("organise", "organize"),
("organised", "organized"),
("organises", "organizes"),
("organising", "organizing"),
("artefact", "artifact"),
("artefacts", "artifacts"),
("behaviour", "behavior"),
("behaviours", "behaviors"),
("behavioural", "behavioral"),
("customise", "customize"),
("customised", "customized"),
("customises", "customizes"),
("customising", "customizing"),
("customisation", "customization"),
("judgement", "judgment"),
("judgements", "judgments"),
("licence", "license"),
("licences", "licenses"),
("licenced", "licensed"),
("catalogue", "catalog"),
("catalogues", "catalogs"),
("catalogued", "cataloged"),
];
const EXPANSIONS: [(&str, &str); 22] = [
("doesn't", "does not"),
("don't", "do not"),
("didn't", "did not"),
("isn't", "is not"),
("aren't", "are not"),
("wasn't", "was not"),
("weren't", "were not"),
("hasn't", "has not"),
("haven't", "have not"),
("hadn't", "had not"),
("won't", "will not"),
("can't", "cannot"),
("couldn't", "could not"),
("shouldn't", "should not"),
("wouldn't", "would not"),
("mustn't", "must not"),
("i'm", "I am"),
("i've", "I have"),
("we've", "we have"),
("they've", "they have"),
("we're", "we are"),
("they're", "they are"),
];
fn expansion(word: &str) -> Option<String> {
let key = word.replace('\u{2019}', "'").to_lowercase();
let (_, expanded) = EXPANSIONS.iter().find(|(from, _)| *from == key)?;
Some(crate::patch::matching_case(word, expanded))
}
pub fn known(controlled: &str, profile: Option<&str>) -> bool {
HOUSE.iter().any(|(language, house_profile)| {
language.eq_ignore_ascii_case(controlled) && *house_profile == profile
})
}
pub struct Language {
bound: Vec<Bound>,
outside: Vec<Bound>,
scent: Option<String>,
}
struct Bound {
kind: String,
regime: String,
profile: Option<Profile>,
declared: String,
tag: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Profile {
House,
}
impl Language {
pub fn over(shape: &Shape) -> Self {
let bound = shape
.kinds
.iter()
.filter_map(|kind| bind(&kind.name, shape.language_of(&kind.name)?))
.collect();
let outside = shape
.language
.iter()
.filter(|regime| !regime.outside_root.is_empty())
.filter_map(|regime| bind("", regime))
.collect();
Language {
bound,
outside,
scent: crate::frontmatter::scent_facet(shape),
}
}
fn bound_to(&self, kind: &str) -> Option<&Bound> {
self.bound.iter().find(|bound| bound.kind == kind)
}
fn bound_for(&self, view: &DocumentView<'_>) -> Option<&Bound> {
match view.outside_regime() {
Some(regime) => self.outside.iter().find(|bound| bound.regime == regime),
None => self.bound_to(view.kind()),
}
}
}
fn bind(kind: &str, regime: &LanguageRegime) -> Option<Bound> {
let controlled = regime.controlled.as_deref().unwrap_or("none");
if controlled.eq_ignore_ascii_case("none") {
return None;
}
let profile = known(controlled, regime.profile.as_deref()).then_some(Profile::House);
Some(Bound {
kind: kind.to_string(),
regime: regime.name.clone(),
profile,
declared: match ®ime.profile {
Some(profile) => format!("{controlled}, profile {profile}"),
None => controlled.to_string(),
},
tag: regime.tag.clone(),
})
}
impl OutsideCheck for Language {
fn binds_regime(&self, regime: &str) -> bool {
self.outside.iter().any(|bound| bound.regime == regime)
}
}
impl DocumentCheck for Language {
const RULE: &'static str = self::RULE;
const VERSION: u32 = 4;
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_for(view) else {
return Outcome::Passed;
};
if bound.profile.is_none() {
return Outcome::Skipped(format!(
"`{}` declares {}, and this engine has no rules for it",
bound.regime, bound.declared
));
}
let body = view.body();
let scent = self
.scent
.as_deref()
.and_then(|facet| crate::frontmatter::Scent::of(view, facet));
let american = bound.tag.eq_ignore_ascii_case("en-US");
let mut findings = Vec::new();
let prose = body
.into_iter()
.flat_map(|body| body.sentences().into_iter().map(move |s| (s, Some(body))))
.chain(
scent
.iter()
.flat_map(|scent| scent.sentences.iter().cloned().map(|s| (s, None))),
);
for (sentence, in_body) in prose {
let (line, column, subject) = match (in_body, &scent) {
(Some(_), _) => (
sentence.span.start.line,
sentence.span.start.col,
"this sentence".to_string(),
),
(None, Some(scent)) => (
scent.span.start.line,
scent.span.start.col,
format!("the `{}` facet", scent.facet),
),
(None, None) => (0, 0, "this sentence".to_string()),
};
let counted = match in_body {
Some(_) => "this one",
None => subject.as_str(),
};
let at = |severity, message: String, remediation: String| Finding {
rule: self::RULE,
severity,
obligation: None,
path: view.path().to_string(),
line,
column,
message,
remediation,
patch: None,
};
let substitute = |word: &str, replacement: &str| {
in_body.and_then(|body| {
crate::patch::substitution(view.path(), body, &sentence, word, replacement)
})
};
let by_hand = |remediation: String| match in_body {
Some(_) => remediation,
None => {
format!("{remediation}, by hand: no patch of this engine edits front matter")
}
};
if sentence.words > MAX_WORDS && !is_a_citation_line(&sentence.text) {
findings.push(at(
Severity::Warn,
format!(
"`{}` holds prose to {MAX_WORDS} words a sentence, and {counted} has {}",
bound.regime, sentence.words
),
"split the sentence, or move a clause into its own sentence".to_string(),
));
}
if runs_on(&sentence.authored) && is_running_prose(sentence.kind) {
findings.push(at(
Severity::Warn,
format!(
"`{}` admits no semicolon in running prose, and {subject} writes one",
bound.regime
),
"split the sentence in two, and let each half state one thing".to_string(),
));
}
if let Some(word) = contraction(&sentence.authored) {
let expanded = expansion(&word);
let mut finding = at(
Severity::Error,
format!(
"`{}` admits no contraction, and {subject} writes `{word}`",
bound.regime
),
by_hand(match &expanded {
Some(expanded) => format!("write `{expanded}`"),
None => format!("write `{word}` out in full"),
}),
);
finding.patch = expanded.and_then(|expanded| substitute(&word, &expanded));
findings.push(finding);
}
if american {
if let Some((british, american)) = spelling(&sentence.authored) {
let american = crate::patch::matching_case(&british, american);
let mut finding = at(
Severity::Error,
format!(
"`{}` declares the tag `{}`, and {subject} writes `{british}`",
bound.regime, bound.tag
),
by_hand(format!("write `{american}`")),
);
finding.patch = substitute(&british, &american);
findings.push(finding);
}
}
}
findings.sort_by_key(|finding| (finding.line, finding.column));
Outcome::failed(findings)
}
}
fn is_a_citation_line(text: &str) -> bool {
text.matches(" · ").count() >= 2
}
fn is_running_prose(kind: BlockKind) -> bool {
matches!(kind, BlockKind::Paragraph | BlockKind::Item)
}
fn runs_on(text: &str) -> bool {
let mut depth = 0usize;
for c in text.chars() {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
';' if depth == 0 => return true,
_ => {}
}
}
false
}
fn contraction(text: &str) -> Option<String> {
let words = text.split(|c: char| c.is_whitespace());
for word in words {
let cleaned: String = word
.chars()
.filter(|c| c.is_alphanumeric() || *c == '\'' || *c == '\u{2019}')
.collect();
let lower = cleaned.to_lowercase();
let Some(at) = lower.find(['\'', '\u{2019}']) else {
continue;
};
let (head, tail) = lower.split_at(at);
let tail: String = tail.chars().skip(1).collect();
if head.is_empty() || tail.is_empty() {
continue;
}
if head.ends_with('n') && tail == "t" {
return Some(cleaned);
}
if ["re", "ve", "ll", "d", "m"].contains(&tail.as_str()) {
return Some(cleaned);
}
if tail == "s"
&& [
"it", "that", "there", "here", "what", "who", "let", "he", "she", "which", "this",
]
.contains(&head)
{
return Some(cleaned);
}
}
None
}
fn spelling(text: &str) -> Option<(String, &'static str)> {
for word in text.split(|c: char| !c.is_alphanumeric()) {
let lower = word.to_lowercase();
if let Some((_, american)) = SPELLINGS
.iter()
.find(|(british, _)| *british == lower.as_str())
{
return Some((word.to_string(), american));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_possessive_is_not_a_contraction() {
assert_eq!(
contraction("Headwater's own corpus and the engine's lock"),
None
);
assert_eq!(contraction("the adopter's overlay"), None);
assert_eq!(contraction("it does not hold"), None);
}
#[test]
fn a_contraction_is_found_whatever_its_ending() {
assert_eq!(contraction("it doesn't hold").as_deref(), Some("doesn't"));
assert_eq!(contraction("they're here").as_deref(), Some("they're"));
assert_eq!(contraction("it's here").as_deref(), Some("it's"));
assert_eq!(contraction("we'll read it").as_deref(), Some("we'll"));
}
#[test]
fn a_semicolon_inside_a_parenthesis_is_not_a_run_on() {
assert!(runs_on(
"The resolver reads one profile; the rest are the adopter's"
));
assert!(!runs_on(
"The two sources agree (Smith, 2004; Jones, 2007) on the shape"
));
assert!(!runs_on("No semicolon at all"));
}
#[test]
fn a_semicolon_is_a_run_on_only_in_running_prose() {
assert!(is_running_prose(BlockKind::Paragraph));
assert!(is_running_prose(BlockKind::Item));
assert!(!is_running_prose(BlockKind::Heading(2)));
assert!(!is_running_prose(BlockKind::TableCell));
}
#[test]
fn a_british_spelling_is_found_with_its_american_form() {
assert_eq!(
spelling("the behaviour of a check"),
Some(("behaviour".to_string(), "behavior"))
);
assert_eq!(spelling("the behavior of a check"), None);
assert_eq!(spelling("an enterprise that is precise"), None);
}
}