use std::collections::BTreeMap;
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
use super::super::context::Item;
use super::super::defaults::FUNCTION_WORDS;
use super::super::terms::singular;
use crate::identity::Key;
use crate::text::{code_spans, lowercase, token_ranges};
const LONGEST: usize = 3;
pub(super) type PhraseKey = Vec<String>;
#[derive(Default)]
pub(super) struct Counted {
pub count: usize,
pub forms: BTreeMap<String, usize>,
pub nodes: Vec<Key>,
}
impl Counted {
pub(super) fn text(&self) -> &str {
self.forms
.iter()
.max_by(|left, right| left.1.cmp(right.1).then(right.0.cmp(left.0)))
.map_or("", |(form, _)| form)
}
}
pub(super) fn collect<'a>(
items: impl Iterator<Item = &'a Item<'a>>,
) -> BTreeMap<PhraseKey, Counted> {
let mut phrases: BTreeMap<PhraseKey, Counted> = BTreeMap::new();
for item in items {
let name = clause_text_of_name(&item.name());
let body = item.body().map(clause_text_of_body);
for text in std::iter::once(name).chain(body) {
for clause in clauses(&text) {
add_runs(&clause, item.id.key(), &mut phrases);
}
}
}
phrases
}
fn clause_text_of_name(name: &str) -> String {
let mut shown = String::with_capacity(name.len());
let mut copied = 0;
for span in code_spans(name) {
shown.push_str(&name[copied..span.start]);
shown.push('\n');
copied = span.end;
}
shown.push_str(&name[copied..]);
shown
}
fn clause_text_of_body(markdown: &str) -> String {
let mut text = String::new();
let mut excluded = 0_u32;
for event in Parser::new_ext(markdown, Options::empty()) {
match event {
Event::Start(Tag::CodeBlock(_) | Tag::HtmlBlock) => excluded += 1,
Event::End(TagEnd::CodeBlock | TagEnd::HtmlBlock) => excluded -= 1,
Event::Text(piece) if excluded == 0 => text.push_str(&piece),
Event::SoftBreak | Event::HardBreak => text.push(' '),
Event::Code(_)
| Event::Html(_)
| Event::InlineHtml(_)
| Event::End(TagEnd::Paragraph | TagEnd::Heading(_) | TagEnd::Item) => {
text.push('\n');
}
_ => {}
}
}
text
}
fn clauses(text: &str) -> Vec<Vec<&str>> {
let mut clauses = Vec::new();
let mut clause: Vec<&str> = Vec::new();
let mut end = 0;
for range in token_ranges(text) {
let gap = &text[end..range.start];
let joined = gap == "-" || gap.chars().all(|c| c == ' ' || c == '\t');
if !joined && !clause.is_empty() {
clauses.push(std::mem::take(&mut clause));
}
end = range.end;
let word = &text[range];
let possessive = word.ends_with("'s") || word.ends_with("\u{2019}s");
if word.chars().next().is_some_and(char::is_alphabetic) && !possessive {
clause.push(word);
} else if !clause.is_empty() {
clauses.push(std::mem::take(&mut clause));
}
}
if !clause.is_empty() {
clauses.push(clause);
}
clauses
}
pub(super) fn is_function_word(word: &str) -> bool {
FUNCTION_WORDS.binary_search(&word).is_ok()
}
fn add_runs(clause: &[&str], node: Key, phrases: &mut BTreeMap<PhraseKey, Counted>) {
let lower: Vec<String> = clause.iter().map(|word| lowercase(word)).collect();
for start in 0..clause.len() {
for length in 1..=LONGEST.min(clause.len() - start) {
let words = &lower[start..start + length];
if is_function_word(&words[0]) || is_function_word(&words[length - 1]) {
continue;
}
let mut key = words.to_vec();
let last = key.last_mut().expect("a run of at least one word");
*last = singular(last);
let counted = phrases.entry(key).or_default();
counted.count += 1;
*counted
.forms
.entry(clause[start..start + length].join(" "))
.or_default() += 1;
if counted.nodes.len() < 3 && !counted.nodes.contains(&node) {
counted.nodes.push(node);
}
}
}
}