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 query = parse_query(prefix);
let mut hits: Vec<(Prepared<'_>, Hit)> = candidates
.iter()
.enumerate()
.filter_map(|(i, c)| {
let p = Prepared {
idx: i,
c,
insert_norm: norm(&c.insert),
cand_norm: crate::content_graph::normalize_path(&c.rel_path),
};
let hit = match_query(&query, &p)?;
Some((p, hit))
})
.collect();
hits.sort_by_cached_key(|(p, hit)| score(&query, p, *hit, embed, from_lang, &from_dirs));
hits.into_iter().map(|(p, _)| p.idx).collect()
}
struct Prepared<'a> {
idx: usize,
c: &'a CompletionCandidate,
insert_norm: String,
cand_norm: String,
}
struct Query {
segs: Vec<String>,
path_qualified: bool,
dir_only: bool,
}
fn parse_query(prefix: &str) -> Query {
let raw = prefix.replace('\\', "/");
Query {
path_qualified: raw.contains('/'),
dir_only: raw.ends_with('/'),
segs: raw.split('/').filter(|s| !s.is_empty()).map(norm).collect(),
}
}
#[derive(Debug, Clone, Copy)]
struct Hit {
seg_hit: u8,
dir_tight: u8,
}
fn match_query(q: &Query, p: &Prepared<'_>) -> Option<Hit> {
if q.segs.is_empty() {
return Some(Hit { seg_hit: 0, dir_tight: 0 });
}
if !q.path_qualified || p.c.kind == CandidateKind::Heading {
return if p.insert_norm.contains(&q.segs[0]) {
Some(Hit { seg_hit: 0, dir_tight: 0 })
} else {
None
};
}
let comps: Vec<&str> = p.cand_norm.split('/').filter(|s| !s.is_empty()).collect();
if comps.is_empty() {
return None;
}
let file_i = comps.len() - 1;
let limit = if q.dir_only { file_i } else { comps.len() };
let mut positions: Vec<usize> = Vec::with_capacity(q.segs.len());
let mut next = 0usize;
for seg in &q.segs {
let mut found = None;
while next < limit {
let at = next;
next += 1;
if comps[at].contains(seg.as_str()) {
found = Some(at);
break;
}
}
positions.push(found?);
}
let last = *positions.last()?;
let contiguous = positions.windows(2).all(|w| w[1] == w[0] + 1);
Some(Hit {
seg_hit: u8::from(last != file_i),
dir_tight: u8::from(!(contiguous && last == file_i)),
})
}
pub fn insert_for(prefix: &str, c: &CompletionCandidate, from_rel: &str) -> Option<String> {
let q = parse_query(prefix);
if !q.path_qualified || q.segs.is_empty() {
return None;
}
let rel = c.rel_path.replace('\\', "/");
match c.kind {
CandidateKind::Heading => None,
CandidateKind::Page => Some(rel.strip_suffix(".md").unwrap_or(&rel).to_string()),
CandidateKind::Asset => {
let from = from_rel.replace('\\', "/");
let from_dir = crate::resolve::parent_dir(&from);
Some(source_relative(from_dir, &rel).unwrap_or_else(|| format!("/{rel}")))
}
}
}
fn source_relative(from_dir: &str, rel_path: &str) -> Option<String> {
let base: Vec<&str> = from_dir.split('/').filter(|s| !s.is_empty()).collect();
let target: Vec<&str> = rel_path.split('/').filter(|s| !s.is_empty()).collect();
if target.len() <= base.len() {
return None;
}
if base.iter().enumerate().any(|(i, b)| target[i] != *b) {
return None;
}
Some(target[base.len()..].join("/"))
}
fn norm(s: &str) -> String {
s.nfc().collect::<String>().to_lowercase()
}
fn score(
q: &Query,
p: &Prepared<'_>,
hit: Hit,
embed: bool,
from_lang: Option<&str>,
from_dirs: &[&str],
) -> (u8, u8, u8, u8, u8, Reverse<usize>, usize, String, String) {
let c = p.c;
let kind_rank = match (embed, c.kind) {
(true, CandidateKind::Asset) | (false, CandidateKind::Page) => 0u8,
_ => 1u8,
};
let starts = match q.segs.last() {
Some(last) if p.insert_norm.starts_with(last.as_str()) => 0u8,
_ => 1u8,
};
let cand_lang = crate::home::lang_tree_prefix(&p.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(&p.cand_norm),
from_dirs,
);
(
kind_rank,
starts,
hit.seg_hit,
hit.dir_tight,
lang_rank,
Reverse(proximity), c.insert.chars().count(), p.insert_norm.clone(),
p.cand_norm.clone(), )
}
#[cfg(test)]
#[path = "link_completions_tests.rs"]
mod tests;