use std::cmp::Reverse;
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,
from_file: &str,
) -> Vec<usize> {
let from_norm = crate::content_graph::normalize_path(from_file);
let from_lang = crate::home::lang_tree_prefix(&from_norm);
let from_dirs = crate::content_graph::dir_components(&from_norm);
let mut idx: Vec<usize> = (0..candidates.len()).collect();
idx.retain(|&i| matches(prefix, &candidates[i].insert));
idx.sort_by_cached_key(|&i| score(prefix, &candidates[i], embed, from_lang, &from_dirs));
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,
from_lang: Option<&str>,
from_dirs: &[&str],
) -> (u8, u8, u8, Reverse<usize>, usize, String, 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 };
let cand_norm = crate::content_graph::normalize_path(&c.rel_path);
let cand_lang = crate::home::lang_tree_prefix(&cand_norm);
let lang_rank = match (from_lang, cand_lang) {
(Some(f), Some(cc)) if f.eq_ignore_ascii_case(cc) => 0u8,
(None, None) => 0u8,
_ => 1u8,
};
let proximity = crate::content_graph::common_prefix_len(
&crate::content_graph::dir_components(&cand_norm),
from_dirs,
);
(
kind_rank,
starts,
lang_rank,
Reverse(proximity), c.insert.chars().count(), norm(&c.insert),
cand_norm, )
}
#[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,
}
}
fn cand_at(insert: &str, rel_path: &str, kind: CandidateKind) -> CompletionCandidate {
CompletionCandidate {
insert: insert.to_string(),
label: insert.to_string(),
rel_path: rel_path.to_string(),
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);
}
#[test]
fn same_language_tree_ranks_before_other_language() {
let cands = vec![
cand_at("guide", "en/guide.md", CandidateKind::Page),
cand_at("guide", "zh-hans/guide.md", CandidateKind::Page),
];
let ranked = rank_completions("guide", &cands, false, "zh-hans/about.md");
assert_eq!(cands[ranked[0]].rel_path, "zh-hans/guide.md");
}
#[test]
fn closer_in_tree_ranks_before_farther_in_same_language() {
let cands = vec![
cand_at("note", "zh-hans/note.md", CandidateKind::Page),
cand_at("note", "zh-hans/游记/note.md", CandidateKind::Page),
];
let ranked = rank_completions("note", &cands, false, "zh-hans/游记/index.md");
assert_eq!(cands[ranked[0]].rel_path, "zh-hans/游记/note.md");
}
#[test]
fn match_quality_outranks_language() {
let cands = vec![
cand_at("周报report", "zh-hans/周报report.md", CandidateKind::Page), cand_at("report-en", "en/report-en.md", CandidateKind::Page), ];
let ranked = rank_completions("report", &cands, false, "zh-hans/about.md");
assert_eq!(cands[ranked[0]].rel_path, "en/report-en.md");
}
#[test]
fn root_source_prefers_root_candidate_over_language_tree() {
let cands = vec![
cand_at("about", "zh-hans/about.md", CandidateKind::Page),
cand_at("about", "about.md", CandidateKind::Page),
];
let ranked = rank_completions("about", &cands, false, "index.md");
assert_eq!(cands[ranked[0]].rel_path, "about.md");
}
#[test]
fn ties_break_by_path_deterministically() {
let cands = vec![
cand_at("guide", "zh-hans/b/guide.md", CandidateKind::Page),
cand_at("guide", "zh-hans/a/guide.md", CandidateKind::Page),
];
let ranked = rank_completions("guide", &cands, false, "zh-hans/x.md");
assert_eq!(cands[ranked[0]].rel_path, "zh-hans/a/guide.md");
}
#[test]
fn language_and_proximity_do_not_perturb_a_single_language_vault() {
let cands = vec![
cand_at("changelog", "en/changelog.md", CandidateKind::Page), cand_at("angle", "en/angle.md", CandidateKind::Page), ];
let ranked = rank_completions("ang", &cands, false, "en/index.md");
assert_eq!(cands[ranked[0]].insert, "angle");
}
}