ADR-0024 ranker hook scaffolding
Adds the `Ranker` plug-in point ADR-0024 §ranker-layer specified. The grammar tree declares *what's valid*; the ranker decides *what's likely useful first*. Default `identity_ranker` preserves declaration order from the grammar. API: - `pub type Ranker = fn(Vec<Candidate>) -> Vec<Candidate>;` - `pub const fn identity_ranker(c) -> Vec<Candidate>` returns its input unchanged. - `candidates_at_cursor_with(input, cursor, cache, ranker)` applies a custom ranker; the default `candidates_at_cursor` delegates with `identity_ranker`. Three new tests cover identity preservation, custom reordering, and the empty-list-collapses-to-None edge. This is a future-work hook — no production caller passes a non-identity ranker yet. Hooks for frequency-based ranking, content-aware priors, or recency plug in here without touching the grammar declarations. Tests: 809 passing, 0 failing, 1 ignored. Clippy clean.
This commit is contained in:
@@ -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<Candidate>) -> Vec<Candidate>;
|
||||
|
||||
/// Identity ranker: returns its input unchanged.
|
||||
#[must_use]
|
||||
pub const fn identity_ranker(candidates: Vec<Candidate>) -> Vec<Candidate> {
|
||||
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<Completion> {
|
||||
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<Completion> {
|
||||
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<Candidate>) -> Vec<Candidate> {
|
||||
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<String> = 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<Candidate>) -> Vec<Candidate> {
|
||||
Vec::new()
|
||||
}
|
||||
let cache = SchemaCache::default();
|
||||
assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user