use unicode_normalization::UnicodeNormalization;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CandidateKind {
Page,
Asset,
Heading,
}
#[derive(Debug, Clone)]
pub struct CompletionCandidate {
pub insert: String,
pub label: String,
pub rel_path: String,
pub kind: CandidateKind,
}
pub fn rank_completions(prefix: &str, candidates: &[CompletionCandidate], embed: bool) -> Vec<usize> {
let mut idx: Vec<usize> = (0..candidates.len()).collect();
idx.retain(|&i| matches(prefix, &candidates[i].insert));
idx.sort_by_key(|&i| score(prefix, &candidates[i], embed));
idx
}
fn norm(s: &str) -> String {
s.nfc().collect::<String>().to_lowercase()
}
fn matches(prefix: &str, insert: &str) -> bool {
if prefix.is_empty() {
return true;
}
norm(insert).contains(&norm(prefix))
}
fn score(prefix: &str, c: &CompletionCandidate, embed: bool) -> (u8, u8, usize, String) {
let kind_rank = match (embed, c.kind) {
(true, CandidateKind::Asset) | (false, CandidateKind::Page) => 0u8,
_ => 1u8,
};
let starts = if !prefix.is_empty() && norm(&c.insert).starts_with(&norm(prefix)) { 0u8 } else { 1u8 };
(kind_rank, starts, c.insert.chars().count(), norm(&c.insert)) }
#[cfg(test)]
mod tests {
use super::*;
fn cand(insert: &str, kind: CandidateKind) -> CompletionCandidate {
CompletionCandidate {
insert: insert.to_string(),
label: insert.to_string(),
rel_path: format!("{insert}.x"),
kind,
}
}
#[test]
fn empty_prefix_returns_all_candidates() {
let cands = vec![
cand("about", CandidateKind::Page),
cand("photo.png", CandidateKind::Asset),
];
let ranked = rank_completions("", &cands, false);
assert_eq!(ranked.len(), 2);
assert_eq!(cands[ranked[0]].kind, CandidateKind::Page);
assert_eq!(cands[ranked[1]].kind, CandidateKind::Asset);
}
#[test]
fn prefix_filters_and_starts_with_ranks_first() {
let cands = vec![
cand("changelog", CandidateKind::Page), cand("angle", CandidateKind::Page), cand("about", CandidateKind::Page), ];
let ranked = rank_completions("ang", &cands, false);
assert_eq!(ranked.len(), 2);
assert_eq!(cands[ranked[0]].insert, "angle");
assert_eq!(cands[ranked[1]].insert, "changelog");
}
#[test]
fn case_insensitive_match() {
let cands = vec![cand("README", CandidateKind::Page)];
assert_eq!(rank_completions("read", &cands, false).len(), 1);
}
#[test]
fn embed_ranks_assets_before_pages() {
let cands = vec![
cand("hero", CandidateKind::Page),
cand("hero.png", CandidateKind::Asset),
];
let ranked = rank_completions("hero", &cands, true);
assert_eq!(cands[ranked[0]].kind, CandidateKind::Asset);
let ranked2 = rank_completions("hero", &cands, false);
assert_eq!(cands[ranked2[0]].kind, CandidateKind::Page);
}
#[test]
fn cjk_prefix_matches() {
let cands = vec![
cand("刘果的笔记", CandidateKind::Page),
cand("about", CandidateKind::Page),
];
let ranked = rank_completions("刘果", &cands, false);
assert_eq!(ranked.len(), 1);
assert_eq!(cands[ranked[0]].insert, "刘果的笔记");
}
#[test]
fn heading_candidates_rank_starts_with_before_contains() {
let cands = vec![
cand("Background and context", CandidateKind::Heading), cand("Context", CandidateKind::Heading), cand("Conclusion", CandidateKind::Heading), ];
let ranked = rank_completions("context", &cands, false);
assert_eq!(ranked.len(), 2);
assert_eq!(cands[ranked[0]].insert, "Context");
assert_eq!(cands[ranked[1]].insert, "Background and context");
}
#[test]
fn heading_embed_flag_does_not_reorder_headings() {
let cands = vec![
cand("bbbb", CandidateKind::Heading),
cand("aaaa", CandidateKind::Heading),
];
let with_embed = rank_completions("", &cands, true);
let without = rank_completions("", &cands, false);
assert_eq!(with_embed, without);
assert_eq!(cands[with_embed[0]].insert, "aaaa");
}
#[test]
fn nfc_and_nfd_forms_match_each_other() {
let nfc = "caf\u{00e9}"; let nfd = "cafe\u{0301}"; assert_ne!(nfc, nfd, "precondition: the two byte-forms differ");
let cands = vec![cand(nfd, CandidateKind::Page)];
assert_eq!(rank_completions(nfc, &cands, false).len(), 1);
let cands2 = vec![cand(nfc, CandidateKind::Page)];
assert_eq!(rank_completions(nfd, &cands2, false).len(), 1);
}
}