diff --git a/src/completion.rs b/src/completion.rs index d156ee9..f960ee0 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -73,6 +73,25 @@ pub struct Candidate { pub kind: CandidateKind, } +/// Re-ranker for a freshly-computed candidate list (ADR-0024 +/// §ranker-layer). +/// +/// The grammar tree declares *what's valid*; the ranker decides +/// *what's likely useful first*. Lives outside the trie so +/// frequency-based ranking, content-aware priors (e.g. `Email` +/// → text first), and recency hooks can plug in without +/// touching grammar declarations. +/// +/// Default is `identity_ranker` — declaration order from the +/// grammar tree is preserved. +pub type Ranker = fn(Vec) -> Vec; + +/// Identity ranker: returns its input unchanged. +#[must_use] +pub const fn identity_ranker(candidates: Vec) -> Vec { + candidates +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CandidateKind { /// One of the parser's expected keywords. @@ -120,6 +139,20 @@ pub fn candidates_at_cursor( input: &str, cursor: usize, cache: &SchemaCache, +) -> Option { + candidates_at_cursor_with(input, cursor, cache, identity_ranker) +} + +/// Variant of [`candidates_at_cursor`] that applies a custom +/// `Ranker` to the final candidate list (ADR-0024 §ranker-layer). +/// The default `candidates_at_cursor` calls this with +/// `identity_ranker`. +#[must_use] +pub fn candidates_at_cursor_with( + input: &str, + cursor: usize, + cache: &SchemaCache, + ranker: Ranker, ) -> Option { let cursor = cursor.min(input.len()); @@ -305,6 +338,11 @@ pub fn candidates_at_cursor( return None; } + let candidates = ranker(candidates); + if candidates.is_empty() { + return None; + } + Some(Completion { replaced_range: (start, cursor), partial_prefix, @@ -1227,4 +1265,55 @@ mod tests { memo.selection_idx = 1; assert_eq!(memo.prev_idx(), 0); } + + // ---- Ranker hook (ADR-0024 §ranker-layer) ---- + + #[test] + fn identity_ranker_preserves_input_order() { + let input = vec![ + Candidate { + text: "b".to_string(), + kind: CandidateKind::Keyword, + }, + Candidate { + text: "a".to_string(), + kind: CandidateKind::Keyword, + }, + Candidate { + text: "c".to_string(), + kind: CandidateKind::Identifier, + }, + ]; + let out = identity_ranker(input.clone()); + assert_eq!(out, input); + } + + #[test] + fn ranker_can_reorder_candidates() { + // Hooks like frequency-based ranking or content-aware + // priors plug in through `Ranker` without touching the + // grammar. Smoke-test the call site with a sorter. + fn alphabetic_ranker(mut c: Vec) -> Vec { + c.sort_by(|a, b| a.text.cmp(&b.text)); + c + } + // `add ` exposes `column` and `1:n` — alphabetic ranker + // flips them. + let cache = SchemaCache::default(); + let comp = candidates_at_cursor_with("add ", 4, &cache, alphabetic_ranker) + .expect("some completion"); + let texts: Vec = comp.candidates.into_iter().map(|c| c.text).collect(); + assert_eq!(texts, vec!["1:n".to_string(), "column".to_string()]); + } + + #[test] + fn ranker_can_filter_to_empty() { + // A ranker that returns an empty list collapses the + // completion to `None`. + fn empty_ranker(_: Vec) -> Vec { + Vec::new() + } + let cache = SchemaCache::default(); + assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none()); + } }