mod phrases;
mod subjects;
use std::collections::{BTreeMap, BTreeSet};
use super::context::Context;
use super::terms::phrase;
use super::{Groups, LintConfig, RuleId, Severity};
use crate::document::Document;
use crate::identity::Key;
use phrases::{Counted, PhraseKey};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TermReport {
pub groups: Vec<Group>,
pub products: Vec<ProductSubjects>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Group {
pub rule: RuleId,
pub severity: Severity,
pub message: String,
pub phrases: Vec<Phrase>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Phrase {
pub text: String,
pub count: usize,
pub nodes: Vec<Key>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProductSubjects {
pub product: Option<Key>,
pub subjects: Vec<Subject>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Subject {
pub text: String,
pub count: usize,
pub mark: Option<Mark>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mark {
Term,
Alias(String),
Node,
}
type Found = Vec<(String, Vec<PhraseKey>)>;
type Phrases = BTreeMap<PhraseKey, Counted>;
type Rule = fn(&Phrases, &LintConfig) -> Found;
const LEAST_USES: usize = 3;
#[must_use]
pub fn term_report(document: &Document, config: &LintConfig) -> TermReport {
let context = Context::new(document);
let phrases = phrases::collect(context.items.iter());
let decided: BTreeSet<PhraseKey> = document
.terms
.iter()
.flat_map(|term| std::iter::once(&term.name).chain(&term.aliases))
.map(|name| phrase(name))
.collect();
let phrases = &phrases;
let rules: [(RuleId, Rule); 3] = [
(RuleId::Td001, head_nouns),
(RuleId::Td002, spellings),
(RuleId::Td003, abbreviations),
];
let mut groups: Vec<Group> = rules
.into_iter()
.filter(|(rule, _)| config.enabled(*rule))
.flat_map(|(rule, find)| {
find(phrases, config)
.into_iter()
.filter(|(_, keys)| !keys.iter().all(|key| decided.contains(key)))
.map(move |(message, keys)| group(rule, config, message, &keys, phrases))
.collect::<Vec<_>>()
})
.collect();
groups.sort_by(|left, right| {
let second = |group: &Group| group.phrases.get(1).map_or(0, |phrase| phrase.count);
second(right)
.cmp(&second(left))
.then_with(|| left.phrases[0].text.cmp(&right.phrases[0].text))
.then(left.rule.cmp(&right.rule))
});
TermReport {
groups,
products: subjects::of(&context),
}
}
fn group(
rule: RuleId,
config: &LintConfig,
message: String,
keys: &[PhraseKey],
phrases: &Phrases,
) -> Group {
let mut members: Vec<Phrase> = keys
.iter()
.map(|key| {
let counted = &phrases[key];
Phrase {
text: counted.text().to_owned(),
count: counted.count,
nodes: counted.nodes.clone(),
}
})
.collect();
members.sort_by(|left, right| {
right
.count
.cmp(&left.count)
.then(left.text.cmp(&right.text))
});
Group {
rule,
severity: config.severity(rule),
message,
phrases: members,
}
}
fn uses(keys: &[PhraseKey], phrases: &Phrases) -> usize {
keys.iter().map(|key| phrases[key].count).sum()
}
fn head_nouns(phrases: &Phrases, config: &LintConfig) -> Found {
let groups = config.noun_groups();
let in_a_group = |word: &str| groups.values().any(|words| words.contains(word));
let mut by_qualifier: BTreeMap<(&str, &str), Vec<PhraseKey>> = BTreeMap::new();
for key in phrases.keys() {
let [qualifier, head] = key.as_slice() else {
continue;
};
if in_a_group(qualifier) {
continue;
}
for (name, words) in groups {
if words.contains(head) {
by_qualifier
.entry((qualifier, name))
.or_default()
.push(key.clone());
}
}
}
by_qualifier
.into_iter()
.filter(|(_, keys)| keys.len() > 1 && uses(keys, phrases) >= LEAST_USES)
.map(|((qualifier, name), keys)| {
let message =
format!("`{qualifier}` is followed by head nouns of the noun group `{name}`.");
(message, keys)
})
.collect()
}
fn spellings(phrases: &Phrases, _: &LintConfig) -> Found {
let mut by_letters: BTreeMap<String, Vec<PhraseKey>> = BTreeMap::new();
for key in phrases.keys() {
by_letters
.entry(key.concat())
.or_default()
.push(key.clone());
}
by_letters
.into_values()
.filter(|keys| {
keys.iter().any(|key| key.len() == 1)
&& keys.iter().any(|key| key.len() > 1)
&& uses(keys, phrases) >= LEAST_USES
})
.map(|keys| {
let closed = keys
.iter()
.find(|key| key.len() == 1)
.expect("a single word");
let written = phrases[closed].text();
let camel = written
.chars()
.zip(written.chars().skip(1))
.any(|(a, b)| a.is_lowercase() && b.is_uppercase());
let message = if camel {
format!(
"`{written}` is an identifier written as prose: put it in a code span, or \
write the name one way."
)
} else {
"One name is written as one word and as two or more.".to_owned()
};
(message, keys)
})
.collect()
}
fn abbreviations(phrases: &Phrases, config: &LintConfig) -> Found {
let candidates = phrases.keys().filter(|key| {
(2..=3).contains(&key.len()) && !key.iter().any(|word| phrases::is_function_word(word))
});
let mut found = Found::new();
for key in candidates {
for (at, word, long) in swaps(key, config.pairs()) {
let other = swapped(key, at, long);
if phrases.contains_key(&other) && !holds_a_pair(phrases, key, at, long) {
let message = format!("`{word}` abbreviates `{long}`.");
found.push((message, vec![key.clone(), other]));
}
}
}
found
}
fn swaps<'a>(key: &'a [String], pairs: &'a Groups) -> Vec<(usize, &'a str, &'a str)> {
key.iter()
.enumerate()
.filter_map(|(at, word)| pairs.get(word).map(|longs| (at, word, longs)))
.flat_map(|(at, word, longs)| {
longs
.iter()
.map(move |long| (at, word.as_str(), long.as_str()))
})
.collect()
}
fn swapped(key: &[String], at: usize, long: &str) -> PhraseKey {
let mut other = key.to_vec();
long.clone_into(&mut other[at]);
other
}
fn holds_a_pair(phrases: &Phrases, key: &[String], at: usize, long: &str) -> bool {
key.len() == 3
&& [at.checked_sub(1), Some(at)]
.into_iter()
.flatten()
.filter(|start| start + 2 <= key.len())
.any(|start| {
let pair = &key[start..start + 2];
phrases.contains_key(pair) && phrases.contains_key(&swapped(pair, at - start, long))
})
}