use super::{
manual,
markdown::{BlockKind, Chapter, plain_text},
topics,
};
use std::sync::OnceLock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GlossaryEntry {
pub term: String,
pub text: String,
pub plain: String,
pub aliases: Vec<String>,
lower: String,
}
impl GlossaryEntry {
#[must_use]
pub fn summary(&self) -> String {
const LIMIT: usize = 160;
let sentence_end = self
.plain
.match_indices(". ")
.next()
.map_or(self.plain.len(), |(at, _)| at + 1);
let sentence = &self.plain[..sentence_end];
if sentence.chars().count() <= LIMIT {
return sentence.to_owned();
}
let cut: String = sentence.chars().take(LIMIT).collect();
let cut = cut.rsplit_once(' ').map_or(cut.as_str(), |(head, _)| head);
format!("{cut}\u{2026}")
}
}
#[must_use]
pub fn parse_glossary(chapter: &Chapter) -> Vec<GlossaryEntry> {
chapter
.blocks
.iter()
.filter(|block| block.kind == BlockKind::Paragraph && block.indent == 0)
.filter_map(|block| entry_of(&block.text))
.collect()
}
fn entry_of(paragraph: &str) -> Option<GlossaryEntry> {
let rest = paragraph.strip_prefix("**")?;
let (term, after) = rest.split_once("**")?;
let after = after.trim_start();
let text = ["\u{2014}", "\u{2013}", "--", "- "]
.iter()
.find_map(|dash| after.strip_prefix(dash))?
.trim();
let term = term.trim();
if term.is_empty() || text.is_empty() {
return None;
}
let plain = plain_text(text);
Some(GlossaryEntry {
term: term.to_owned(),
text: text.to_owned(),
aliases: aliases_of(term),
lower: plain.to_lowercase(),
plain,
})
}
fn aliases_of(term: &str) -> Vec<String> {
let lower = term.trim().to_lowercase();
let mut aliases: Vec<String> = Vec::new();
let mut add = |name: &str| {
let name = name.trim();
if !name.is_empty() && !aliases.iter().any(|alias| alias == name) {
aliases.push(name.to_owned());
}
};
add(&lower);
for part in lower.split(" / ") {
add(part);
if let Some((outside, inside)) = part.split_once('(') {
add(outside);
add(inside.trim_end_matches(')'));
}
}
aliases
}
#[must_use]
pub fn entries() -> &'static [GlossaryEntry] {
static ENTRIES: OnceLock<Vec<GlossaryEntry>> = OnceLock::new();
ENTRIES.get_or_init(|| {
manual::chapters()
.iter()
.find(|chapter| chapter.stem == topics::GLOSSARY)
.map(|chapter| parse_glossary(&chapter.parsed))
.unwrap_or_default()
})
}
#[must_use]
pub fn glossary_lookup(term: &str) -> Option<&'static GlossaryEntry> {
lookup_in(entries(), term)
}
#[must_use]
pub fn lookup_in<'a>(entries: &'a [GlossaryEntry], term: &str) -> Option<&'a GlossaryEntry> {
let wanted = term.trim().to_lowercase();
if wanted.is_empty() {
return None;
}
let singular = wanted.strip_suffix('s');
entries.iter().find(|entry| {
entry.aliases.iter().any(|alias| {
alias.as_str() == wanted || singular.is_some_and(|singular| alias.as_str() == singular)
})
})
}
#[must_use]
pub fn filter<'a>(entries: &'a [GlossaryEntry], query: &str) -> Vec<&'a GlossaryEntry> {
let query = query.trim().to_lowercase();
if query.is_empty() {
return entries.iter().collect();
}
let mut ranked: Vec<(u8, &GlossaryEntry)> = entries
.iter()
.filter_map(|entry| rank(entry, &query).map(|rank| (rank, entry)))
.collect();
ranked.sort_by_key(|&(rank, _)| rank);
ranked.into_iter().map(|(_, entry)| entry).collect()
}
fn rank(entry: &GlossaryEntry, query: &str) -> Option<u8> {
if entry.aliases.iter().any(|alias| alias == query) {
Some(0)
} else if entry.aliases.iter().any(|alias| alias.starts_with(query)) {
Some(1)
} else if entry.aliases.iter().any(|alias| alias.contains(query)) {
Some(2)
} else if entry.lower.contains(query) {
Some(3)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gui::help::markdown::parse_chapter;
const SAMPLE: &str = "# Glossary\n\nIntro text.\n\n**A note on things.** More words follow.\n\n\
**Anchor** \u{2014} A tier whose scale is stated directly. It needs no neighbours.\n\n\
**Refractive index (RI)** \u{2014} A measure of how strongly a material bends\nlight.\n\n\
**Detach / Reattach** - The toggle.\n\n**Tier** \u{2014} One row of the cutting instructions.\n\n\
**Pinned tier** \u{2014} A tier with a stated scale.\n\n- **Not** \u{2014} a list item, ignored.\n";
fn sample() -> Vec<GlossaryEntry> {
parse_glossary(&parse_chapter(SAMPLE))
}
#[test]
fn entries_are_the_bold_term_paragraphs_only() {
let terms: Vec<String> = sample().into_iter().map(|entry| entry.term).collect();
assert_eq!(
terms,
[
"Anchor",
"Refractive index (RI)",
"Detach / Reattach",
"Tier",
"Pinned tier"
]
);
}
#[test]
fn wrapped_text_is_joined_and_marks_are_stripped_in_plain() {
let entries = sample();
assert_eq!(
entries[1].plain,
"A measure of how strongly a material bends light."
);
assert_eq!(
entries[0].text,
"A tier whose scale is stated directly. It needs no neighbours."
);
}
#[test]
fn aliases_cover_parts_and_brackets() {
let entries = sample();
assert_eq!(
entries[1].aliases,
["refractive index (ri)", "refractive index", "ri"]
);
assert_eq!(
entries[2].aliases,
["detach / reattach", "detach", "reattach"]
);
}
#[test]
fn lookup_finds_any_name_in_any_case_and_a_plural() {
let entries = sample();
let term = |wanted: &str| lookup_in(&entries, wanted).map(|e| e.term.as_str());
assert_eq!(term("RI"), Some("Refractive index (RI)"));
assert_eq!(term(" refractive INDEX "), Some("Refractive index (RI)"));
assert_eq!(term("Reattach"), Some("Detach / Reattach"));
assert_eq!(term("tiers"), Some("Tier"));
assert_eq!(term("pinned tiers"), Some("Pinned tier"));
assert_eq!(term("no such term"), None);
assert_eq!(term(""), None);
}
#[test]
fn filtering_ranks_names_before_explanations() {
let entries = sample();
let terms = |query: &str| -> Vec<&str> {
filter(&entries, query)
.into_iter()
.map(|e| e.term.as_str())
.collect()
};
assert_eq!(terms("tier"), ["Tier", "Pinned tier", "Anchor"]);
assert_eq!(terms("bends"), ["Refractive index (RI)"]);
assert_eq!(terms("").len(), 5);
assert_eq!(terms("zzzz").len(), 0);
}
#[test]
fn a_summary_is_the_first_sentence_and_fits_a_tooltip() {
let entries = sample();
assert_eq!(
entries[0].summary(),
"A tier whose scale is stated directly."
);
let long = parse_glossary(&parse_chapter(&format!(
"**Long** \u{2014} {}\n",
"word ".repeat(80)
)));
let summary = long[0].summary();
assert!(summary.ends_with('\u{2026}'));
assert!(summary.chars().count() <= 161);
}
#[test]
fn the_real_glossary_has_the_known_terms() {
let entries = entries();
assert!(entries.len() > 20, "{} entries", entries.len());
for term in [
"Anchor",
"Tier",
"Mast",
"Yield",
"Culet",
"Refractive index (RI)",
] {
assert!(
entries.iter().any(|entry| entry.term == term),
"{term} is missing from the glossary"
);
}
assert!(glossary_lookup("RI").is_some());
assert!(glossary_lookup("masts").is_some());
assert!(!entries.iter().any(|entry| entry.term.starts_with("A note")));
}
}