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:
claude@clouddev1
2026-05-15 17:28:11 +00:00
parent bbe12524ab
commit 7ae1a0fde1
+89
View File
@@ -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());
}
}